Skip to main content
ASoc
Tutorial

Next.js Error Boundaries: One File, and a Digest Nothing Read

One error.tsx covering 27 page files, no global-error.tsx, and a digest the boundary declared but discarded — the audit, and both fixes that shipped with this post.

The ASoc Team11 min read

A Next.js error boundary is a error.tsx file that default-exports a Client Component. It catches throws from the segment it sits in and everything nested below it, and shows your fallback instead of a blank page. What it does not catch is the surprising half: the layout in its own segment, route handlers, Server Action return values, and notFound().

This codebase had exactly one of these files covering 27 page files, and auditing it for this post turned up two real defects. Both are fixed in the tree as of today. Here is the whole surface, measured.

What a boundary actually covers

error.tsx renders inside the layout of its own segment. That one sentence explains most of the confusion around it:

Thrown fromCaught by app/error.tsx?Caught by app/global-error.tsx?
A page's render, any depth belowYesOnly if no nearer boundary
A Server Component's data fetchYesOnly if no nearer boundary
A nested layout.tsx below the rootYesOnly if no nearer boundary
The root layout.tsx itselfNoYes
A route.ts handlerNoNo
An event handler (onClick)NoNo
notFound()No — renders not-found.tsxNo
redirect()No — it is a control signal, not a failureNo

The two "no" rows at the top of that list are why this repo now has a src/app/global-error.tsx. Before this post it had none, so a throw in the root layout — the <html> shell, the next/font call, the theme script — had no boundary anywhere in the tree and produced the framework's own unstyled crash page.

global-error.tsx has to render its own <html> and <body>, because the root layout is the thing that just failed and nothing else is supplying them. It also ships no imports, no font and no design tokens for the same reason — the stylesheet was loaded by the layout that threw:

// src/app/global-error.tsx
"use client";

export default function GlobalError({
  error,
  reset,
}: {
  error: Error & { digest?: string };
  reset: () => void;
}) {
  return (
    <html lang="en">
      <body style={{ /* inline only — no stylesheet is loaded here */ }}>
        <h1>Something went wrong</h1>
        {error.digest && <p>Reference: {error.digest}</p>}
        <button type="button" onClick={reset}>Try again</button>
      </body>
    </html>
  );
}

In development you will almost never see it — the Next.js error overlay takes the screen instead. It is a production-only surface, which is exactly why it goes missing.

The defect: a digest that nothing read

Here is the shipped src/app/error.tsx as it stood before this post, reduced to its signature:

"use client";

export default function Error({
  reset,
}: {
  error: Error & { digest?: string };
  reset: () => void;
}) {
  // …heading, apology copy, a "Try again" button wired to reset
}

The prop type declares error. The destructure does not take it. Grep this repository for digest outside the test files and you get exactly one hit — that type annotation — and zero uses.

That is not a style nit, because of what digest is. In production Next.js does not send the error message to the browser at all. It replaces it with a generic string and gives you a hash of the original, logged server-side under the same value. The digest is the only thing that exists on both ends. Discard it in the UI and the support path becomes: a user writes "the page broke", and nobody can find which of the day's server errors was theirs.

The fix is four lines:

// src/app/error.tsx — after
export default function Error({
  error,
  reset,
}: {
  error: Error & { digest?: string };
  reset: () => void;
}) {
  return (
    // …
    {error.digest && (
      <p className="mt-2 font-mono text-xs text-text-color-secondary dark:text-gray-500">
        Reference: {error.digest}
      </p>
    )}
  );
}

Now the reference code in the user's email is a grep key against the server log. Nothing else about the page changed.

What reset() does, and the expectation it does not meet

reset() re-renders the boundary's children. For a client-side failure — a bad computation, a null dereference in a component — that is genuinely a retry, because the second render can succeed where the first did not.

For a Server Component that failed while fetching, it is weaker than the button label implies. The failed segment's payload came from the server; clearing the boundary's error state and rendering again does not by itself re-run the server work that produced it. Users click "Try again", the same failure returns, and they conclude the button is decorative.

Two things follow. First, do not promise more than the button delivers — copy like "Try again" alongside a support address is honest; "Retry the request" is not. Second, if the failure is a transient fetch you genuinely want to re-attempt, put the retry where the fetch is, not on the boundary: catch it in the data layer and return a typed result the page can render, which is what this codebase already does everywhere it matters.

Why a static site barely has this surface

The reason one boundary covered 27 page files without anyone noticing is architectural. This site's latest build prerenders 412 URLs and leaves 8 rendered on demand. A throw during prerendering does not reach a user — it fails next build, in CI, before anything deploys. The blast radius of a runtime render error is the eight dynamic routes: /dashboard, /dashboard/settings, /login, /signup, /reset-password, /auth/callback, /api/download, /api/webhooks/lemonsqueezy.

Two of those eight are route handlers, which no boundary covers, so they return their own failures explicitly:

// src/app/api/download/route.ts — a handler owns its error responses
return NextResponse.json(
  { error: "Too many downloads. Try again later." },
  { status: 429 },
);

And the Server Actions behind the auth pages never throw for expected failures at all — they return a discriminated result, and the form renders the message:

// src/lib/actions/auth.ts
return { ok: false, message: "Incorrect email or password." };

That is the pattern worth copying. An error boundary is for the failure you did not model. Everything you did model — a wrong password, a rate limit, a product slug that does not exist — should be a value your UI renders, not a throw your boundary catches. This is the same argument React form validation makes about the server being the real gate: expected outcomes are data.

notFound() is not an error

The two notFound() calls in this codebase, one in templates/[slug]/page.tsx and one in blog/[slug]/page.tsx, are frequently mistaken for the error path because they are also implemented as a throw. They are not: Next.js intercepts that signal and renders not-found.tsx with a 404 status. error.tsx never sees it, and putting "something went wrong" copy in front of a mistyped URL would be wrong anyway. redirect() behaves the same way — which is also why you must never wrap either one in a try/catch that swallows what it throws.

Troubleshooting

SymptomCauseFix
The error page is Next.js's own unstyled crash screenThe throw came from the root layout, which error.tsx sits insideAdd global-error.tsx at app/ — with its own <html> and <body>
error.message is "an error occurred in the Server Components render"Production redacts server error messages on purposeUse error.digest to correlate with the server log; the real message is only there
error.tsx never fires for a failing API callIt was a route.ts handler, or a fetch whose non-2xx you never threw onHandlers return their own error responses; fetch only rejects on network failure, so check res.ok yourself
"Try again" appears to do nothingreset() re-renders; it does not by itself re-run the server work that failedSay what the button does, and put real retries in the data layer
A build fails instead of showing the boundaryThe route is prerendered, so the throw happened at build timeCorrect behaviour — fix the build error; boundaries only cover runtime rendering
error.tsx errors with "cannot use hooks in a Server Component"The "use client" directive is missingEvery error boundary is a Client Component, without exception
Metadata exported from global-error.tsx is ignoredMetadata exports are unsupported thereNothing to fix — it renders below the point where metadata is resolved

FAQ

Do I need both error.tsx and global-error.tsx? Yes, if you want full coverage. error.tsx renders inside its segment's layout, so it cannot catch a throw from the root layout — only global-error.tsx can. One of each at app/ covers everything a boundary is able to cover; this repo runs exactly that.

Why is my Next.js error message empty in production? By design. Next.js redacts server-side error messages before they reach the browser, replacing them with a hash exposed as error.digest and logged alongside the real error on the server. Render the digest in your fallback or you lose the only link between the two.

Can an error boundary be a Server Component? No. Error boundaries need React state and an event handler for reset(), so error.tsx and global-error.tsx must both start with "use client". That is the one file convention in the App Router with no server-rendered form.

Where should a Next.js error boundary live? At the granularity where a partial failure still leaves a useful page. A boundary inside a dashboard segment keeps the site chrome and loses one panel; a boundary only at the root turns any failure into a full-page replacement. This app has a root boundary because 412 of its 420 routes are static and the failure modes that remain are whole-page ones — the calculus changes the moment a page composes several independent server fetches.

Templates in this post

ASoc Lura Admin is a project-management dashboard whose panels each load their own data — the exact shape that wants a boundary per section rather than one at the root, so a failing widget costs a widget instead of the page. ASoc Pulse Admin ships five dashboards inside one authenticated shell, where a segment-level error.tsx keeps the shell and the navigation alive through a failure. ASoc Scholar Admin is the catalog's largest admin product at 13 dashboards and 210+ pages, and the clearest case for boundaries placed by segment instead of one catch-all at the top.

Browse the full sets: React admin templates, Next.js admin templates, Tailwind admin templates. For where these files sit among the App Router's other conventions, see the routing census; for why expected failures should be return values, React form validation.

Keep reading

Tutorial10 min read

Next.js Error Monitoring: 41 console.error Calls, Zero APM

No Sentry, no instrumentation.ts — 41 structured console.error calls, an ALERT-marker convention, and the error-hygiene test that keeps server detail out of the browser.

Read more
Tutorial13 min read

Gated File Downloads in Next.js: The Order the Checks Must Run In

Authenticate, verify, validate, authorize, rate-limit and record — then sign. The atomic limiter that closes the count-then-insert race, and what fails closed versus open.

Read more