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
.jsrequest 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 tokenpointing into the bundle, and only on an old/in-app browser? Build target (bucket 4) → lowerbuild.target. - Console says
Minified React error #418/#423/#425,Hydration failed, or a plainTypeError? 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 message | Real cause | Fix direction |
|---|---|---|
Unexpected token '<' / MIME type ('text/html') is not executable | Wrong base path; host returns index.html for a .js request | Update base in vite.config / astro.config; exclude /assets/* from the SPA fallback |
Minified React error #418 / #423 / Hydration failed | SSR/CSR mismatch | Wrap timestamp/random/locale components so they render only on the client |
Refused to ... Content Security Policy | CSP too strict | Allow the blocked origin, or use a nonce/hash instead of 'unsafe-inline' |
Cannot read properties of undefined | Missing data guard | Add a null check + a root error boundary |
SyntaxError: Unexpected token (only on old/in-app browsers) | Build target too new | Lower 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:
- 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.
- Network proof: in the Network tab, every
.jsand.cssrow is Status200withContent-Type: application/javascript/text/css— notext/htmlon an asset row. - No Service Worker: Application → Service Workers shows none registered (or only your new versioned one); Cache Storage holds only current bundle hashes.
- Old/in-app browser (only if you hit bucket 4): re-open inside the WeChat or in-app browser that failed before.
- 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.targetat the defaultbaseline-widely-available(Chrome/Edge 111, Firefox 114, Safari 16.4 as of June 2026); add@vitejs/plugin-legacyonly if your analytics show meaningful traffic from older or in-app browsers - Register Service Workers with a version-keyed cache +
skipWaitingso 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.
Related
Tags: #Hosting #Debug #Troubleshooting