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 message | Real cause | Fix direction |
|---|---|---|
Unexpected token '<' | Wrong base path | Update base in vite.config / astro.config |
Hydration failed | SSR/CSR mismatch | Wrap timestamp/random components in <ClientOnly> |
Refused to execute inline script | CSP too strict | Add 'unsafe-inline' or use nonces |
Cannot read properties of undefined | Missing data guard | Add null check + error boundary |
SyntaxError: Unexpected token | Target too new | Lower 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 > 100 - Run WebPageTest or Lighthouse CI after each deploy — catches CSP/MIME issues you’d miss locally
- Keep
build.targetcompatible with at least two LTS browser versions (Safari ≥ 14, Chrome ≥ 90) - Register Service Workers with a version-keyed cache +
skipWaitingso old SWs don’t trap users on stale builds
Related
Tags: #Hosting #Debug #Troubleshooting