Static Site Renders Blank Page

Browser shows nothing, no error — almost always hydration or JS import failure.

You hit the production URL, the tab title flashes in, and then the page sits empty — the HTML is there, <div id="root"> is mounted, but nothing renders inside it. “Blank page, no error in the UI” almost always has one of three causes: client-side JS threw and was swallowed, an asset path returns HTML where the browser expects JS, or a Content-Security-Policy blocked the script tags. This guide walks you to root cause in under 10 minutes and covers Astro, Next.js, Vite, CRA, Nuxt, and any other static or hybrid framework.

Common causes

Ordered by hit rate, highest first.

1. Client-side JS threw before hydration

The most common one: React/Vue throws on first render, the whole tree is discarded, the root node ends up empty. Console will show a red Uncaught TypeError: Cannot read properties of undefined (reading 'xxx') or Hydration failed because the initial UI does not match.

Uncaught Error: Minified React error #418; visit https://reactjs.org/docs/error-decoder.html?invariant=418
  at chunk-XXX.js:1:12345

How to spot it: DevTools → Console, look at the first red line. Hydration errors are usually caused by Date.now(), Math.random(), or timezone-dependent rendering on the server vs client.

2. Wrong base path — JS request returns HTML

Deploying to a subpath (/blog/, /docs/) or a custom domain without updating the build base. The browser requests /assets/main.js but the server 404s and falls back to index.html, so the browser parses HTML as JavaScript and immediately throws Unexpected token '<'.

Refused to execute script from 'https://example.com/assets/main-abc123.js' 
because its MIME type ('text/html') is not executable.

How to spot it: DevTools → Network → click the JS request → Response tab. If you see <!DOCTYPE html>, that’s the bug.

3. CSP blocked inline scripts or CDN

You configured Content-Security-Policy: script-src 'self' on Vercel / Cloudflare / Netlify, but your framework injects inline <script> (Astro’s is:inline, Next.js’s __NEXT_DATA__) or pulls from a CDN (unpkg.com, cdn.jsdelivr.net). The browser refuses to execute.

Refused to execute inline script because it violates the following 
Content Security Policy directive: "script-src 'self'".

How to spot it: Search Console for Content Security Policy or Refused to. Each line names the blocked URL and the offending directive.

4. ES module syntax fails on older browsers

build.target is set to esnext (default emits ES2022+), but a user is on Safari < 14, an old Edge, or WeChat’s in-app browser. The bundle uses ??=, private class fields, or top-level await and throws SyntaxError, killing the entire module load.

How to spot it: Open on BrowserStack or an older device. Console shows SyntaxError: Unexpected token with a line in the bundle.

5. Service Worker cached a broken build

A previous deploy shipped half-broken code, the SW cached it, and even after you fix prod, returning users keep loading the broken JS forever.

How to spot it: DevTools → Application → Service Workers shows an active worker; Application → Cache Storage has stale bundle filenames.

Shortest path to fix

Step 1: Catch the first error with DevTools

Open the page in an incognito window with DevTools already open before navigation (otherwise you miss early errors). Then:

1. Console — copy every red line
2. Network — tick "Disable cache" and reload
3. Sort by Type — check Status and MIME type on JS / Document rows

200 OK + text/html on a JS row = path bug. 200 OK + text/javascript but console errors = code bug. Status 0 / blocked = CSP or network filter.

Step 2: Map the error to the fix

Error messageReal causeFix direction
Unexpected token '<'Wrong base pathUpdate base in vite.config / astro.config
Hydration failedSSR/CSR mismatchWrap timestamp/random components in <ClientOnly>
Refused to execute inline scriptCSP too strictAdd 'unsafe-inline' or use nonces
Cannot read properties of undefinedMissing data guardAdd null check + error boundary
SyntaxError: Unexpected tokenTarget too newLower build.target to es2018

Step 3: Fix base path

Vite / Astro deployed to https://example.com/blog/:

// astro.config.mjs
export default defineConfig({
  site: 'https://example.com',
  base: '/blog',
  trailingSlash: 'always',
});

Next.js:

// next.config.js
module.exports = {
  basePath: '/blog',
  assetPrefix: '/blog',
};

Run npm run build && npm run preview locally to verify before pushing.

Step 4: Add a root error boundary

Stop a single throw from blanking the page:

// React
import { ErrorBoundary } from 'react-error-boundary';

function Fallback({ error }: { error: Error }) {
  return (
    <div style={{ padding: 24 }}>
      <h1>Something broke</h1>
      <pre>{error.message}</pre>
    </div>
  );
}

export default function App() {
  return (
    <ErrorBoundary FallbackComponent={Fallback}>
      <YourApp />
    </ErrorBoundary>
  );
}

Vue uses errorCaptured, Svelte 5+ has <svelte:boundary>.

Step 5: Kill a broken Service Worker

If old users see blank pages but new users are fine, it’s almost always SW cache. Ship a one-shot unregister:

// public/unregister-sw.js — deploy once to flush all visitors' caches
if ('serviceWorker' in navigator) {
  navigator.serviceWorker.getRegistrations().then(rs => {
    rs.forEach(r => r.unregister());
  });
  caches.keys().then(keys => keys.forEach(k => caches.delete(k)));
}

Remove this script after a week.

Prevention

  • Always wrap the app root in an error boundary so a single child throw cannot blank the page
  • Add a Playwright/Puppeteer smoke test in CI that loads the homepage and asserts document.body.innerText.length &gt; 100
  • Run WebPageTest or Lighthouse CI after each deploy — catches CSP/MIME issues you’d miss locally
  • Keep build.target compatible with at least two LTS browser versions (Safari ≥ 14, Chrome ≥ 90)
  • Register Service Workers with a version-keyed cache + skipWaiting so old SWs don’t trap users on stale builds

Tags: #Hosting #Debug #Troubleshooting