Static Site Renders a Blank Page: Find the Cause in 10 Minutes

Production URL loads to an empty page with no UI error. Open DevTools before navigating, read the first red console line, and map it to the fix — base path, hydration, CSP, build target, or a stale Service Worker.

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.

Fastest fix: open the site in an incognito window with DevTools already open before you navigate, read the first red line in the Console, and match it to the table in Step 2. One error string tells you which of five buckets you are in, and four of the five take a one-line config change.

“Blank page, no error in the UI” almost always has one of three root causes: client-side JS threw and the whole render tree was discarded, an asset path returns HTML where the browser expects JavaScript, or a Content-Security-Policy blocked the script tags. The other two — a build target too new for the visitor’s browser, and a stale Service Worker serving a broken cached build — are rarer but each has a distinct fingerprint. This guide covers Astro, Next.js, Vite, Create React App, Nuxt, SvelteKit, 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 a hydration mismatch.

Uncaught Error: Minified React error #418; visit https://react.dev/errors/418
  at chunk-XXX.js:1:12345

In a production (minified) build, hydration mismatches surface as Minified React error #418, #423, or #425 — error #418 is the “text content did not match” case. The decoded message for #418 is Hydration failed because the server rendered HTML didn't match the client. React 19 improved this: it now prints the server vs client values together in one error instead of a wall of separate warnings, and it skips over tags injected by browser extensions and third-party scripts in <head>/<body> so those no longer trigger false mismatches.

How to spot it: DevTools → Console, look at the first red line. Hydration mismatches are usually caused by Date.now(), Math.random(), new Date().toLocaleString(), typeof window !== 'undefined' branches, or locale/timezone-dependent rendering that differs between the server build and the client. A non-hydration crash (a plain TypeError) is instead almost always unguarded data: an API field is undefined and a component reads a property off it.

2. Wrong base path — JS request returns HTML

This is the single most common cause of a blank page after deploy. You deployed to a subpath (/blog/, /docs/, a GitHub Pages project URL like user.github.io/repo/) or a new host without updating the build base. The browser requests /assets/main.js, the host 404s that path, and the SPA fallback hands back index.html instead. The browser then parses HTML as JavaScript and immediately throws Unexpected token '<', or refuses the script outright:

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 failing .js request → Response tab. If the body starts with <!DOCTYPE html> instead of JavaScript, that is the bug — the host is returning your index page where the browser expected a script. On the Headers tab the response Content-Type will read text/html instead of text/javascript / application/javascript, and Status is usually 200 (the fallback “succeeded”), which is why it is easy to miss. This shows up on GitHub Pages, Azure Static Web Apps, and any host whose /* -> /index.html catch-all also matches /assets/* — verified still current as of June 2026.

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

The bundle uses syntax the visitor’s browser cannot parse — ??=, private class fields (#field), or top-level await — so the whole module throws SyntaxError before any of your code runs. As of June 2026 Vite’s default build.target is baseline-widely-available, which targets Chrome/Edge 111, Firefox 114, and Safari 16.4 (the Baseline-Widely-Available set as of 2026-01-01). That default is safe for mainstream browsers, so this bucket now mostly bites two groups: visitors who manually set build.target: 'esnext', and visitors on locked-down in-app webviews — WeChat, older Android System WebView, kiosk/POS browsers, or an outdated Safari on an un-updated iPhone.

How to spot it: reproduce on the actual old browser (BrowserStack, or open the link inside the WeChat/in-app browser that is failing). Console shows SyntaxError: Unexpected token or SyntaxError: Unexpected identifier pointing at a line inside your hashed bundle, with no other red lines above it.

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. A clean tell: the page is blank in a normal window but renders fine in incognito (which has no Service Worker registered).

Which bucket are you in?

Answer these in order — the first “yes” is your bucket:

  • Blank for everyone, but fine in incognito for a fresh visitor? Not SW. Go to bucket 1–4.
  • Blank in your normal window, fine in incognito? Stale Service Worker (bucket 5) → Step 5.
  • A .js request in the Network tab returns <!DOCTYPE html> / Content-Type: text/html? Base path (bucket 2) → Step 3.
  • Console says Refused to ... Content Security Policy? CSP (bucket 3) → fix the header.
  • Console says SyntaxError: Unexpected token pointing into the bundle, and only on an old/in-app browser? Build target (bucket 4) → lower build.target.
  • Console says Minified React error #418/#423/#425, Hydration failed, or a plain TypeError? Client JS / hydration (bucket 1) → Step 4.

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 '<' / MIME type ('text/html') is not executableWrong base path; host returns index.html for a .js requestUpdate base in vite.config / astro.config; exclude /assets/* from the SPA fallback
Minified React error #418 / #423 / Hydration failedSSR/CSR mismatchWrap timestamp/random/locale components so they render only on the client
Refused to ... Content Security PolicyCSP too strictAllow the blocked origin, or use a nonce/hash instead of 'unsafe-inline'
Cannot read properties of undefinedMissing data guardAdd a null check + a root error boundary
SyntaxError: Unexpected token (only on old/in-app browsers)Build target too newLower build.target (e.g. es2019) or add @vitejs/plugin-legacy

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',
};

If base is correct and a .js request still returns text/html, the host’s SPA fallback is rewriting asset URLs too. Exclude the assets folder so the catch-all does not match it. On Netlify, list the /assets/* rule before the catch-all:

# netlify.toml — specific rule first, catch-all last
[[redirects]]
  from = "/assets/*"
  to = "/assets/:splat"
  status = 200

[[redirects]]
  from = "/*"
  to = "/index.html"
  status = 200

On Azure Static Web Apps, add the folder to navigationFallback.exclude in staticwebapp.config.json (e.g. "exclude": ["/assets/*", "/*.{js,css,png,svg}"]). The Network tab is the proof: after the fix, the .js row must show Status 200 and Content-Type: application/javascript, not text/html.

Run npm run build && npm run preview locally to verify before pushing — preview serves the real production bundle from the same base path the host will use.

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>. The boundary stops the blank page; you still have to fix the underlying throw it now surfaces.

If the boundary reveals a hydration mismatch (bucket 1), fix the source of the server/client difference rather than suppressing it. Render the volatile part on the client only:

// Next.js App Router — render a clock client-side only
import dynamic from 'next/dynamic';
const Clock = dynamic(() => import('./Clock'), { ssr: false });

For a single inevitable difference (e.g. a server-formatted date), add suppressHydrationWarning to that one element. In Astro, mark the island client:only="react" so it never server-renders. Do not reach for suppressHydrationWarning on a whole subtree — that hides real bugs.

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.

How to confirm it’s fixed

Do not trust your own already-cached browser. Verify in this order:

  1. Fresh visitor: open the live URL in an incognito window with DevTools open. The Console should have zero red lines and the page should render.
  2. Network proof: in the Network tab, every .js and .css row is Status 200 with Content-Type: application/javascript / text/css — no text/html on an asset row.
  3. No Service Worker: Application → Service Workers shows none registered (or only your new versioned one); Cache Storage holds only current bundle hashes.
  4. Old/in-app browser (only if you hit bucket 4): re-open inside the WeChat or in-app browser that failed before.
  5. CI guard: a headless smoke test loads the homepage and asserts document.body.innerText.length > 100, so the same blank page cannot ship again.

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 > 100
  • Run WebPageTest or Lighthouse CI after each deploy — catches CSP/MIME issues you’d miss locally
  • Leave Vite’s build.target at the default baseline-widely-available (Chrome/Edge 111, Firefox 114, Safari 16.4 as of June 2026); add @vitejs/plugin-legacy only if your analytics show meaningful traffic from older or in-app browsers
  • Register Service Workers with a version-keyed cache + skipWaiting so old SWs don’t trap users on stale builds

FAQ

The page is blank but the Console has no errors at all — now what? Three possibilities. Either DevTools opened after the early error scrolled off (reload with it already open), or the error is swallowed inside a try/catch or a framework boundary that renders nothing, or the HTML genuinely rendered but your CSS hid everything (check the Elements tab — is the content present but display:none / zero height?). Toggle “Disable cache” and hard-reload before concluding it is error-free.

Why does it work on localhost but break in production? localhost serves from the root, so a wrong base never bites; production serves from a subpath or CDN where the asset URLs are wrong. Dev also skips minification, so a Minified React error #418 only appears in the production build. Always reproduce with npm run build && npm run preview, not npm run dev.

It renders for me but is blank for some users. Two usual suspects: a stale Service Worker serving those returning users a broken cached build (Step 5), or a build target too new for their browser — common with WeChat and other in-app webviews (bucket 4). Incognito-vs-normal is the fast discriminator: blank only in the normal window points at the Service Worker.

Is Unexpected token '<' always a base-path problem? Almost always — it means the browser received HTML where it expected JavaScript, because the host returned index.html for a missing asset. Confirm on the Network tab: the failing .js request shows Content-Type: text/html and a body starting with <!DOCTYPE html>. Fix base/basePath and exclude /assets/* from the SPA fallback.

Adding suppressHydrationWarning made the error go away — am I done? Only if the difference is genuinely cosmetic and on a single element (a date, a relative timestamp). It suppresses the warning, not the underlying mismatch, so React still discards and re-renders that node on the client. For anything structural — different elements, conditional typeof window branches — fix the root cause and render the volatile part client-side instead.

Tags: #Hosting #Debug #Troubleshooting