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 happensapp/error.tsx catches it?app/global-error.tsx catches it?
app/page.tsx or a nested layoutYesYes (if error.tsx absent)
app/layout.tsx (the root layout)No — measured: default Next.js error screen insteadYes — 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.