open atlas
↑ Back to track
Next.js, zero to senior NEXT · 07 · 01

error.tsx hierarchy: what each boundary actually catches, digests, and when reset() lies

error.tsx wraps its segment page, not its layout: layout errors bubble to the parent boundary, and a root crash reaches only global-error.tsx. Production redacts server errors to a digest; reset() heals only transient failures. Place boundaries by blast radius.

NEXT Senior ◷ 17 min
Level
FoundationsJuniorMiddleSenior

Black Friday, 09:14. The e-commerce team had done its homework: after last year’s checkout outage they shipped app/checkout/error.tsx — a friendly fallback that keeps the cart visible, apologizes, and offers a retry. QA verified it by throwing inside page.tsx; green. This morning, the shipping-rates SDK they initialize in checkout/layout.tsx chokes on a malformed feature flag and throws during render. The carefully designed fallback never appears. The error sails straight past it, finds no app/error.tsx above, takes down the root layout — and users get the framework’s unstyled default error screen, mid-purchase, on the biggest traffic day of the year. The browser shows only “a server-side exception has occurred” plus a digest hash, because production redacts server errors. Forty minutes pass before someone greps the server logs for that digest and finds the flag-parser stack trace. Nothing about the boundary was broken. It was standing exactly where it could never catch this error.

The boundary wraps the page, not its own frame

The placement trap exists because of how Next.js composes a segment’s file conventions into one React tree. For each segment, error.tsx becomes a React error boundary that is nested inside that segment’s layout:

// What Next.js conceptually builds from one segment's files
<Layout>                                {/* layout.tsx — OUTSIDE the boundary */}
  <ErrorBoundary fallback={<Error />}>  {/* error.tsx renders the fallback */}
    <Page />                            {/* page.tsx + all nested child segments */}
  </ErrorBoundary>
</Layout>

So checkout/error.tsx catches everything thrown while rendering checkout/page.tsx and any segment below it — but an error thrown by checkout/layout.tsx originates above the boundary in the tree and bubbles upward until it meets the parent segment’s error.tsx. The rule to memorize: to guard a layout, the boundary must live one level up. If nothing up the tree catches it, the crash reaches the root layout, and only global-error.tsx remains. That file is special three ways: it replaces the entire root layout when active, so it must render its own html and body tags; it must be a client component; and in development you will rarely see it because the dev overlay intercepts errors — it is effectively production-only behavior, which means it must be verified against a production build, not next dev.

One more routing distinction that gets folded into error handling by mistake: notFound() throws a special control-flow error that the closest not-found.tsx renders — error.tsx never sees it, just as it never sees the throw inside redirect(). Error boundaries are for failures; not-found is routing semantics. A catch-all that funnels missing entities into an error boundary converts a correct 404 into a fake 500 — wrong status code, wrong cache behavior, wrong page in the search index.

Quiz

app/dashboard/ contains layout.tsx, page.tsx, and error.tsx. A provider initialized in dashboard/layout.tsx throws during render. app/error.tsx also exists. What renders?

reset() retries the render — it does not fix the world

error.tsx receives { error, reset }. Calling reset() re-renders the boundary’s contents — and that distinction decides whether your “Try again” button is honest. For a transient failure — a network blip, a dependency mid-deploy, a timed-out upstream — the retry genuinely recovers. For a deterministic failure — a parsing bug, a null field in the database, the malformed flag from the Hook — reset() re-throws instantly, and an auto-retrying boundary becomes a tight loop hammering the broken dependency. There is also a subtlety with server components: the failed markup came from a server render, so re-rendering on the client alone replays the same broken payload. The working pattern pairs reset() with router.refresh() inside a transition, and caps attempts:

'use client'; // error.tsx is always a client component
import { useRouter } from 'next/navigation';
import { startTransition, useState } from 'react';

export default function Error({
  error,
  reset,
}: {
  error: Error & { digest?: string };
  reset: () => void;
}) {
  const router = useRouter();
  const [attempts, setAttempts] = useState(0);

  const retry = () => {
    setAttempts((n) => n + 1);
    startTransition(() => {
      router.refresh(); // re-fetch the server component payload
      reset();          // then re-render the boundary's children
    });
  };

  return (
    <div role="alert">
      <p>Checkout is temporarily unavailable. Reference: {error.digest}</p>
      {attempts < 2
        ? <button onClick={retry}>Try again</button>
        : <a href="/support">Contact support</a>}
    </div>
  );
}

That error.digest is the production contract. When a server component throws in production, Next.js strips the message and stack before anything crosses the wire — so a thrown Error("duplicate key on users.email: alice@…") cannot leak schema, SQL, or PII to the browser. What the client receives is a generic message plus a digest: a hash that Next.js also attaches to the server-side log of the original error. Support workflows hang on that hash — render the digest in the fallback UI, log it server-side, and a user screenshot becomes a one-grep lookup. Client-component errors are not redacted; they already executed in the browser. Two failure modes follow directly: a team that hides the digest from users has no correlation path at all, and a team that logs to ephemeral stdout with one-day retention has a digest pointing at deleted evidence.

Why this works

Why does production redact rather than trust developers to write clean error messages? Because error messages are written at the throw site, years before anyone decides what is safe to show, and they interpolate whatever was at hand — connection strings, query fragments, user emails. The redaction is the framework refusing to bet your security posture on the discipline of every throw in every dependency. The digest preserves exactly one property across the trust boundary: correlatability — enough to find the full truth where it is safe to keep, in server logs.

Designing the tier: boundaries by blast radius

When you understand where boundaries sit in the tree, the next question is how many to draw — and getting that wrong is quieter than a missing boundary but just as damaging. Not every segment deserves an error.tsx. Each one is a small client-bundle chunk (a boundary is a client component — roughly 1–2 KB plus whatever UI it imports) and, more importantly, an isolation decision. The working heuristic: draw a boundary where the rest of the page is still worth showing. A dashboard with four independent widget groups wants a boundary per group — a failing analytics panel must not kill navigation or the other three panels. A checkout funnel wants one boundary at the funnel root that preserves cart context in its fallback. Marketing pages can ride on a single app/error.tsx. And the tier always terminates in two files: app/error.tsx catching everything below the root layout, and a branded global-error.tsx as the last resort above it.

The failure mode of over-tiering is quieter: when something systemic dies — the auth store, the database pool — fourteen widget boundaries render fourteen polite sad-faces, and the page looks “degraded but fine” while nothing on it works. Systemic failures should reach a high boundary that tells one honest story. Last mechanical boundary fact, inherited from React itself: error boundaries catch errors thrown during render (server or client) and lifecycle — not inside event handlers or detached async code. The onClick that fails a fetch never triggers error.tsx; it needs its own try/catch and UI state.

Quiz

In production, a server component throws Error('FK violation on orders.user_id=42'). What does the user's browser actually receive?

Recall before you leave
  1. 01
    An error thrown in a segment's layout.tsx — which boundary renders it, and why not the error.tsx sitting in the same folder?
  2. 02
    What reaches the browser when a server component throws in production, and how do you make it actionable?
Recap

Next.js builds each route segment as layout wrapping error boundary wrapping page, which yields the placement rule that decides incidents: error.tsx catches its page and everything below, never the layout beside it — layout errors bubble to the parent segment’s boundary, and a root-layout crash skips the entire tier until global-error.tsx, a client component that must render its own html and body because it replaces the root layout outright, and which only shows in production builds since the dev overlay masks it. notFound() and redirect() throw control-flow signals that error.tsx never sees — folding 404s into error boundaries fakes a 500. reset() re-renders the boundary’s contents, which honestly recovers transient failures and lies about deterministic ones; because a server component’s markup comes from the server, real retry pairs reset() with router.refresh() inside startTransition, with an attempt cap so a broken dependency is not hammered in a loop. In production the framework strips message and stack from server-thrown errors and ships only a digest that also lands in the server logs — show it to users, retain the logs, and a support screenshot becomes one grep; client-component errors arrive unredacted because they already ran in the browser. Design the tier by blast radius: a boundary wherever the rest of the page still earns its render — per independent dashboard region, one per checkout funnel preserving cart context — plus app/error.tsx and a branded global-error.tsx as the floor, while remembering that event-handler and detached async errors never reach any boundary, and that over-tiering makes a systemic outage cosplay as fourteen local glitches. Now when you see a fallback that never fires despite a real crash, check which file threw — if it was a layout, the boundary you need is one level up.

Practice

Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.

recallapplystretch0 of 6 done

Something unclear?

Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.

Apply this

Put this lesson to work on a real build.

shortcuts expand
search
K
prev piece
k
next piece
j
cycle tier
t
this menu
?
sources2
expand
  1. 01
  2. 02

Trademarks belong to their respective owners. Editorial reference only.