Skip to content
Chapter 31Lesson 4

Catching the root layout

The App Router's global-error.tsx, the outermost Error Boundary, catching the one throw no other error.tsx can reach, in the root layout itself.

You have app/error.tsx wired, and after the last lesson the app feels fully covered: any segment can throw and the user lands on a friendly failure screen instead of a crash. Then a deploy goes out with a bad environment variable, and the first thing app/layout.tsx does at request time is read it. The root layout throws before a single page renders. The user does not get your error.tsx. They get the browser’s stark default page, with no branding, no “Try again”, and no message you wrote. One bad deploy takes the whole site down to a blank screen.

The last lesson left this gap open on purpose. app/global-error.tsx closes it.

Why your error.tsx lets the root layout through

Section titled “Why your error.tsx lets the root layout through”

An Error Boundary catches throws from its descendants, but the layout it sits beside is its parent, so a render error in that layout happens before the boundary exists to catch it.

Apply that one level up. The framework places app/error.tsx inside app/layout.tsx, in the layout’s subtree rather than around it, so it cannot catch a throw in app/layout.tsx. And the root layout is the top of the tree, with no error.tsx above it. A throw there has nowhere to bubble up to, so it escapes every boundary you have written and lands on the framework’s bare fallback.

The segment files from the last lesson cover everything under the root layout, but nothing covers the root layout itself. Scrub through the failing render below and watch the throw walk out of the tree:

app/layout.tsx root layout

The error.tsx boundary lives inside the root layout — it is the layout's child.

error.tsx boundary
page.tsx caught
escapes the top of the tree
browser default error screen no branding · no “Try again” · no message you wrote
Errors under the root layout are caught by app/error.tsx, exactly as the last lesson taught.
app/layout.tsx root layout

Throws here — outside the error.tsx ring. The boundary is its child, so it does not exist yet.

error.tsx boundary
page.tsx never renders
escapes the top of the tree
browser default error screen no branding · no “Try again” · no message you wrote
The boundary lives inside the layout, so a layout render error happens before the boundary exists.
app/layout.tsx root layout

Throws here — outside the error.tsx ring. The boundary is its child, so it does not exist yet.

error.tsx boundary
page.tsx never renders
escapes the top of the tree
browser default error screen no branding · no “Try again” · no message you wrote
There is no error.tsx above the root layout — the throw escapes the whole app and the user gets the framework's bare fallback.

That is why you need a boundary above the root layout.

global-error.tsx: the boundary above the root layout

Section titled “global-error.tsx: the boundary above the root layout”

The file that fills the gap is app/global-error.tsx. It is the one Error Boundary the framework wires above the root layout, the outermost boundary in the App Router tree. Every other error.tsx is a boundary inside the app shell; global-error.tsx is the boundary around it, the last net before the browser’s default page. Errors that escape every error.tsx, errors in the root layout itself, even errors in the framework’s own root render all surface here. It wraps app/layout.tsx, so in the diagram you just scrubbed it is the box that now sits at the top of the tree, catching the throw that previously escaped.

Here is the whole file:

app/global-error.tsx
'use client';
export default function GlobalError({
error,
unstable_retry,
}: {
error: Error & { digest?: string };
unstable_retry: () => void;
}) {
return (
<html lang="en">
<body>
<h2>Something went wrong</h2>
{error.digest != null && <p>Reference: {error.digest}</p>}
<button onClick={() => unstable_retry()}>Try again</button>
</body>
</html>
);
}

Most of this is the exact contract from error.tsx: the 'use client' directive, the error and unstable_retry props, the error.digest a user can paste into a support ticket, and the “Try again” button wired to unstable_retry(). The props are identical, so the file receives its data exactly as error.tsx does.

One thing is new, highlighted above: this file returns its own <html> and <body>. No other component you have written does that. It must do this, and it must start with 'use client', the two non-negotiables.

'use client', the same reason as error.tsx

Section titled “'use client', the same reason as error.tsx”

global-error.tsx, like error.tsx, must start with 'use client'. React Error Boundaries are stateful class components built on getDerivedStateFromError and componentDidCatch, and that class machinery runs on the client only. Omit the directive and the build fails. It is the same rule, one level up.

Because the file must be a Client Component, the metadata and generateMetadata exports are not supported here. If your catastrophe screen needs a tab title, render a <title> element inside the returned JSX instead.

Why it must render its own <html> and <body>

Section titled “Why it must render its own <html> and <body>”

Normally app/layout.tsx renders the <html> and <body> that wrap your whole app, and every page renders inside that shell. But global-error.tsx fires precisely because the root layout crashed. By the time it renders, the root layout is gone, and so are the <html> and <body> it would have produced. There is no parent document to render into, because the shell it would normally slot inside is the thing that just failed.

So global-error.tsx does not render inside the root layout. It replaces it: when active, it is rendered as the entire document, so it must reconstruct the document skeleton itself. That is what the <html> and <body> in the file are for.

Compare the two arrangements. On the left, the normal case: the layout owns the document and the page lives inside it. On the right, the global-error case: the layout is gone and global-error.tsx is the document.

app/layout.tsx
export default function RootLayout({ children }: { children: ReactNode }) {
return (
<html lang="en">
<body>{children}</body>
</html>
);
}

app/layout.tsx renders the document shell, and your page renders inside it.

Omit the tags and you ship a document with no <html> and no <body>, which renders as a blank page in production, the worst outcome for the one file whose entire job is the catastrophe screen. The tags here are not boilerplate; they are the reason the page renders at all.

Verify global-error.tsx in a production build

Section titled “Verify global-error.tsx in a production build”

In next dev, when the root layout throws, the Next.js error overlay covers the screen with the full message, stack, and diagnostic. Current versions of Next.js do render global-error.tsx underneath the overlay so you can preview it, but the overlay sits in front, so what you see in dev is still the developer experience, not the user’s. Build the app and run the production build locally to see the real thing: no overlay, the generic message, the digest, and your actual layout.

global-error.tsx is the last line of defense: there is no boundary below it. If it throws while rendering, the user gets nothing recoverable, so it must be the most defensive file in your codebase. Its one job is to render a page that cannot itself fail.

It should hold the catastrophe UI and nothing more: a calm message, the error.digest for support correlation, a “Try again” wired to unstable_retry(), a stripped-down brand-aligned shell, and a path to contact support. It is also the right home for one piece of logic, the report to your error-tracking service, sent with the same useEffect pattern you saw in error.tsx. When global-error.tsx fires, your most serious failure has just happened, so this is exactly where you notify monitoring. Fleshed out, the file looks like this:

app/global-error.tsx
'use client';
import { useEffect } from 'react';
export default function GlobalError({
error,
unstable_retry,
}: {
error: Error & { digest?: string };
unstable_retry: () => void;
}) {
useEffect(() => {
reportToMonitoring(error);
}, [error]);
return (
<html lang="en">
<body>
<h2>Something went wrong</h2>
{error.digest != null && <p>Reference: {error.digest}</p>}
<button onClick={() => unstable_retry()}>Try again</button>
<a href="mailto:support@example.com">Contact support</a>
</body>
</html>
);
}

What must never go in is business logic, data fetching, or anything that can throw. An error page that itself errors is unrecoverable: the fetch you add to render a richer error screen is the fetch that, on a bad day, fails and leaves the user staring at the browser default after all.

Two concrete constraints follow. The first is styling. When global-error.tsx renders, the global CSS, fonts, and design-system providers imported by the crashed root layout may not be in effect, because the layout that loaded them is the thing that is gone. So keep the page simple: minimal inline styles or a small set of Tailwind utility classes (the framework’s Tailwind layer is still available), no design-system providers, no custom font, no client-only context providers.

The second is internationalization. Locale-aware copy needs the i18n setup to have loaded before the crash, and for a failure at the top of the layout it usually has not. So keep global-error.tsx to a single default-locale string (“Something went wrong” and the digest), not a translated message.

Both constraints come from one fact: the styling and the locale you reach for are set up above you in the tree, and the things above you are exactly the things that just failed. Assume nothing above you survived.

app/error.tsx catches errors in app/page.tsx and in any nested segment without its own error.tsx. app/global-error.tsx catches errors in app/layout.tsx plus anything that escapes app/error.tsx. Different scopes, different jobs, so ship both at the app root.

The previous lesson covered the four states of a single segment and the files that handle them. This boundary sits above the whole app, for when the shell itself fails. Together they leave nothing on a route, and nothing around it, without a screen you wrote.

  • Directoryapp
    • layout.tsx the app shell
    • error.tsx catches everything under the shell
    • global-error.tsx catches the shell itself
    • page.tsx

Add a segment-level error.tsx wherever a feature deserves a failure message tailored to it.

Mark each claim true or false; the review at the end explains every one.

Each claim is about global-error.tsx and the app's error surface. Mark each statement True or False.

global-error.tsx must render its own <html> and <body> tags.

It replaces the crashed root layout, which is what normally renders them. With no parent shell left, global-error.tsx is the document — so it has to reconstruct the skeleton itself. Omit the tags and you ship a blank page.

global-error.tsx must start with 'use client'.

Same reason as error.tsx: Error Boundaries are stateful, client-only class machinery. Omit the directive and the build fails.

You can verify your global-error.tsx UI by triggering the error in next dev.

The dev overlay covers it. Current Next.js renders the file in development, but the overlay sits on top — so you see the developer experience, not the user’s. Verify it in a production build.

global-error.tsx is a good place to fetch the data you need to render a richer error page.

It is the last line of defense — any throw, including a failed fetch, leaves the user with nothing recoverable. No data fetching, no logic that can fail.

app/global-error.tsx makes app/error.tsx redundant; you only need one.

They cover different scopes — error.tsx catches under the shell, global-error.tsx catches the shell itself and anything that escapes error.tsx. Ship both at the root.