Next.js global-error vs error for layout (tested)
app/error.tsx catches errors thrown by pages and nested layouts, but not errors thrown by the root app/layout.tsx itself. That surprises people because the docs say error.tsx is for "unexpected errors" in a route segment — but the root layout wraps error.tsx's own boundary, so if the layout throws, there is no error.tsx rendered to catch it. We proved it by throwing from a real root layout and watching error.tsx get skipped, then added app/global-error.tsx and watched it catch the exact same throw.
1. The setup we tested
A one-route Next.js 16.3.8 app with three files:
// app/layout.js
export const dynamic = "force-dynamic";
export default function RootLayout({ children }) {
throw new Error("boom in root layout");
return (
<html>
<body>{children}</body>
</html>
);
}
// app/error.js
"use client";
export default function Error({ error, reset }) {
return <div>CUSTOM ERROR BOUNDARY FIRED: {error.message}</div>;
}
export const dynamic = "force-dynamic" matters here too: without it, Next.js tries to prerender / at build time, and an unconditional throw in the root layout fails the build itself ("Error occurred prerendering page /") before the app ever runs. The error.tsx/global-error.tsx distinction only matters once the app is actually serving requests, so we forced the throw to happen per-request instead.
We ran next build (it succeeded, now a dynamic route) then next start -p 8977, and hit the route with curl and with headless Chromium so we could see the fully hydrated page, not just the raw server markup.
2. With only app/error.js: the custom boundary never renders
Hitting the running server with both app/layout.js and app/error.js present, no global-error.js:
$ curl -s -o /dev/null -w "%{http_code}\n" http://localhost:8977/
500
Loading the page in headless Chromium and reading the hydrated DOM text, byte for byte:
This page couldn’t load
A server error occurred. Reload to try again.
Reload
ERROR 1139808596
That is Next.js's own built-in default error screen, not our "CUSTOM ERROR BOUNDARY FIRED" text. app/error.js was in the project, correctly exported, and would have worked for a throw inside app/page.js — but it never ran for this throw, because the throw happened in the root layout that renders above error.js's own boundary in the component tree. The boundary itself never gets a chance to mount.
3. Adding app/global-error.js: now it's caught
We added a second file, keeping app/error.js exactly as it was:
// app/global-error.js
"use client";
export default function GlobalError({ error, reset }) {
return (
<html>
<body>
<div>GLOBAL ERROR BOUNDARY FIRED: {error.message}</div>
</body>
</html>
);
}
Notice it defines its own <html> and <body> tags. That's required, not stylistic: global-error.js replaces the entire root layout (including <html>/<body>) when it activates, because the root layout is exactly what failed and can no longer be trusted to render.
Same throw, same next build && next start, status still 500, but the hydrated DOM text this time:
GLOBAL ERROR BOUNDARY FIRED: Minified React error #441; visit https://react.dev/errors/441 for the full message or use the non-minified dev environment for full errors and additional helpful warnings.
This time our fallback fired. Both app/error.js and app/global-error.js were present simultaneously in this build; the root layout's throw skipped error.js entirely and was caught by global-error.js, confirming the nesting order rather than some file-naming precedence rule. (The error.message text itself is a minified production React error because we ran a production build; it doesn't affect the boundary-selection result we're testing here.)
4. Why the nesting works this way
Conceptually, the App Router wraps your tree roughly like this:
<GlobalErrorBoundary> (from app/global-error.js, wraps EVERYTHING including html/body)
<RootLayout> (app/layout.js — your html/body)
<ErrorBoundary> (from app/error.js)
<Page />
</ErrorBoundary>
</RootLayout>
</GlobalErrorBoundary>
An error boundary can only catch errors thrown by components below it in the tree. app/error.js's boundary sits inside RootLayout, so when RootLayout itself throws, that boundary is torn down along with everything else below it and never gets to intercept the error — the error has to propagate to the next boundary up, which is global-error.js's, outside the root layout. If you don't have a global-error.js, there is no boundary left to catch it, and Next.js falls back to its own built-in minimal error screen, exactly what we saw in section 2.
5. Quick reference
| Where the throw happens | app/error.tsx catches it? | app/global-error.tsx catches it? |
|---|---|---|
| app/page.tsx or a nested layout | Yes | Yes (if error.tsx absent) |
| app/layout.tsx (the root layout) | No — measured: default Next.js error screen instead | Yes — measured: our fallback rendered |
Practical takeaway: if your root layout does anything that can throw — reading cookies/headers, calling a database or CMS for shared nav/header data, a provider that validates an env var — add an app/global-error.tsx alongside your app/error.tsx. Without it, a root layout failure shows users Next.js's unstyled default screen instead of your own branded fallback.
Frequently asked questions
Why doesn't app/error.tsx catch my root layout error?
Because error.tsx's boundary is rendered inside the root layout component tree. We threw from app/layout.js and measured that error.js never mounted its fallback; only a global-error.js, which wraps the layout itself, caught it.
Does app/global-error.tsx replace my normal error.tsx?
No, use both. We kept app/error.js in place and added app/global-error.js alongside it. error.tsx still catches errors from pages and nested layouts; global-error.tsx is only a safety net for errors in the root layout itself, which we confirmed by reproducing both cases.
Why does global-error.tsx need its own html and body tags?
Because it replaces the entire root layout when it activates, not just a slice of the page. We defined <html> and <body> inside our global-error.js; Next.js rendered them directly since the real root layout had already thrown and couldn't be trusted.
What happens if I have neither error.tsx nor global-error.tsx?
Next.js shows its own built-in minimal error screen. We saw it directly: 'This page couldn't load / A server error occurred. Reload to try again.' plus an ERROR line with the digest number, with no custom styling.
Can I test this without deploying?
Yes. We reproduced it locally: add export const dynamic = "force-dynamic" to a root layout that throws, run next build && next start, and hit it with curl or a browser. Without force-dynamic, the throw fails next build itself instead, since Next tries to prerender the root layout at build time.
Written with AI assistance. Measured on 2026-10-03 with Node.js 24.21.0 and Next.js 16.3.8 (App Router, Turbopack). Built a minimal app with a root app/layout.js that throws unconditionally, added export const dynamic = "force-dynamic" so the throw happens per-request instead of at build time, ran next build && next start, and inspected the real response with curl plus headless Chromium (Playwright) to see the hydrated DOM, with and without an app/global-error.js present. PlainHeaders is our extension; curl, Chromium and Next.js are free.