React's default failure mode is total. When a component throws during render and nothing catches it, React does not skip that component and carry on — it unmounts the whole tree. The reasoning is sound: a half-rendered UI can be worse than no UI, because a user might act on state that is stale or corrupted. The practical consequence is less sound: a third-party chart choking on a null value blanks out a page where everything else was fine.
An error boundary is a fence around part of the tree. When something inside throws during render, React unwinds to the nearest boundary, renders that boundary's fallback in place of the crashed subtree, and leaves everything outside untouched. Where you draw the fences decides what dies together. One boundary around the whole app means a broken tooltip takes down navigation, content, and the half-finished form two sections away. A boundary around every component means you drown in fallbacks and errors that should be loud become wallpaper.
Ships solved this with bulkheads. A hull without them sinks from one breach; a hull that is nothing but bulkheads carries no cargo. The design question is which compartments can flood without sinking the ship, and that is exactly the question to ask of a component tree. The rest of this article answers it for a real Next.js app.
A boundary catches errors thrown while React is rendering the tree below it: in component bodies, in hooks running during render, in lifecycle methods. That clause — while React is rendering — is the source of every surprise, because plenty of error-shaped things happen outside of rendering, and boundaries see none of them.
- Event handlers. A click handler that throws never passes through the render pipeline. The error lands in the console and the UI stays exactly as it was.
- Asynchronous code. setTimeout callbacks, rejected promises, a fetch that fails — by the time these blow up, the render that scheduled them finished long ago.
- The boundary's own crash. A boundary cannot catch an error it throws itself; that propagates to the next boundary up.
- Server-side errors, in the classic model. A class boundary cannot recover during SSR — there is no interactive re-render on the server. The App Router changes this.
Each gap has its own fix, and keeping them straight matters more than memorising the boundary API. Event handlers want a plain try/catch with error state — you are in ordinary JavaScript, so handle it like ordinary JavaScript:
'use client';
import { useState } from 'react';
export function ExportButton() {
const [error, setError] = useState<string | null>(null);
async function handleExport() {
setError(null);
try {
const res = await fetch('/api/export', { method: 'POST' });
if (!res.ok) throw new Error('Export failed: ' + res.status);
} catch {
setError('Export failed. Your data is untouched — try again.');
}
}
return (
<>
<button onClick={handleExport}>Export CSV</button>
{error && <p role="alert">{error}</p>}
</>
);
}Async code is the same idea with one useful trick. When an async failure should behave like a render crash — a critical data load with no sensible inline fallback — store the error in state and throw it during the next render. The react-error-boundary package wraps this as showBoundary; hand-rolled, it is three lines. The error now happens where boundaries can see it, so your placement strategy covers async failures too.
Server errors are the interesting case. The framework forwards them into the boundary system for you, which is the App Router's quiet superpower and the subject of the next two sections.
In the App Router you rarely write componentDidCatch. You write a file. Drop error.tsx into a route segment and Next.js wraps that segment's page — and everything nested below it — in an error boundary, with your component as the fallback. The file convention is the placement decision: segment granularity maps directly onto blast radius.
An error.tsx at app/dashboard/ means a crash anywhere under /dashboard renders your fallback inside the dashboard's slot, while the root layout, navigation, theme, and every other route stay alive. Add app/dashboard/settings/error.tsx and a settings crash is contained tighter still, leaving the rest of the dashboard usable. Every nested error.tsx is a smaller compartment, and you choose the compartment size by choosing the folder.
Two relatives complete the family. The global-error.tsx file is the last-resort boundary: it catches crashes in the root layout itself, and because it replaces that layout, it must render its own html and body tags. It only activates in production builds — development shows the error overlay instead — which means it is the one boundary you have probably never seen render. Test it in a production build on purpose. And not-found.tsx is the same mechanism pointed at one specific error: calling notFound() throws, and the nearest not-found file renders. Seen together, this is one system: framework-managed boundaries triggered by throws, scoped by where you put the file.
One nuance catches nearly everyone. An error.tsx does not catch errors thrown in the layout.tsx of its own segment — the boundary wraps the page, not the layout that wraps the boundary. A crashing dashboard layout bubbles to the parent segment's boundary, or to global-error.tsx if nothing else exists. If a layout does anything risky, an auth lookup or a feature-flag fetch, the boundary that handles its failure lives one level up.
info
error.tsx must begin with 'use client', and the reason is worth understanding rather than memorising: a fallback has to keep working after rendering failed — including when the failure happened on the server. So the boundary lives in the browser, receives a serialized error object, and can hold state, re-render, and offer interactive recovery. A server component could do none of that after its own render threw.
Your error.tsx receives two props: the error and a reset function. Calling reset tells React to re-render the boundary's contents from scratch. Whether that helps depends entirely on why the crash happened, and this is where most retry buttons become theater.
A retry can succeed when the cause was transient: a network hiccup mid-request, a race between two updates, a dependency that loaded late. A retry cannot succeed when the cause is deterministic: a null in the data that is still null, a bug in your code that throws identically every render. Clicking Try again on a deterministic crash replays the crash. A fallback that offers retry as its only action is implicitly promising the user the problem is transient — and for most render crashes, it is not. Design the ladder explicitly: the first failure offers retry; a second identical failure should change the message and offer a different exit — back, home, or support. A counter in component state is enough to do this.
There is a second, sneakier way retry becomes theater in the App Router. reset() re-renders the client tree but does not refetch Server Components — it reuses the payload the server already sent. If the error was born on the server, reset alone re-renders the same crash from cache. The real retry pairs it with router.refresh(), which refetches the route's server payload, wrapped in a transition so both apply together:
'use client';
import { useEffect, useTransition } from 'react';
import { useRouter } from 'next/navigation';
import * as Sentry from '@sentry/nextjs';
type Props = { error: Error & { digest?: string }; reset: () => void };
export default function DashboardError({ error, reset }: Props) {
const router = useRouter();
const [isPending, startTransition] = useTransition();
useEffect(() => {
Sentry.captureException(error);
}, [error]);
return (
<section role="alert">
<h2>The dashboard hit an error</h2>
<p>Your data is safe, and the rest of the site is working.</p>
<button
disabled={isPending}
onClick={() =>
startTransition(() => {
router.refresh();
reset();
})
}
>
{isPending ? 'Retrying…' : 'Try again'}
</button>
</section>
);
}warning
If your retry button calls reset() alone and the error came from a Server Component, it will faithfully reproduce the failure from the cached payload every single time. router.refresh() inside startTransition is the difference between a retry and a replay.
A boundary without reporting is a muffler. Before you added it, the crash was a blank screen and an angry email — bad, but loud. After, the user sees a polite fallback, shrugs, and navigates away, and no signal reaches you at all. You traded a loud bug for a silent one. The fallback improved the user's afternoon and quietly destroyed your ability to know the bug exists.
Every boundary therefore needs a reporting side-channel, and the example above shows the conventional shape: an effect keyed on the error instance that fires captureException once per crash. For boundaries you compose yourself with react-error-boundary, the onError prop does the same job. And for the server side, Next.js exposes an onRequestError hook in instrumentation.ts that catches errors across Server Components, route handlers, and middleware in one place — wire it to the same backend so client and server crashes land in the same queue.
Pay attention to the digest property on the error object. In production it is the only identifier the browser ever sees, and it is what ties a user's report — I saw an error page at 14:02 — to the server log line holding the real message. Log it on both ends. If your server logs are unstructured strings, that correlation is grep and prayer; structured logging is what turns a digest lookup into a one-line query.
The fallback is the only part of your error handling a user ever sees, and most fallbacks are written in the last thirty seconds of a feature. They deserve the attention you give an empty state. A good fallback answers three questions in plain language: what broke, what survived, and what to do now.
What broke: name the scope honestly. The revenue chart could not load beats Something went wrong, because it tells the user the boundary held. What survived: say it out loud — your data is safe, the rest of the dashboard is working — because a user looking at an error assumes the worst by default. What to do: one action that can plausibly succeed, designed with the retry ladder from the previous section. Then there are the never-rules:
- Never a blank screen. Blankness reads as the whole site is down, and the user's next move is leaving.
- Never a raw message or stack trace. It leaks internals, reads as negligence, and in production the server message is stripped anyway — you would be printing a hash.
- Never a fallback whose size differs wildly from what it replaces. A 400-pixel chart collapsing into a one-line apology shoves the entire page around; render the fallback at roughly the same dimensions.
Server Components moved where errors are born. A data fetch that used to fail in the browser now fails on the server during render; Next.js catches it, serializes a stripped version, and triggers the nearest error.tsx on the client. If the mental model of what runs where is still fuzzy, the Server Components guide walks through it — boundaries make a lot more sense once you can say with confidence which side of the wire a given component renders on.
Stripped is the operative word. In production, the real message and stack stay on the server: your error prop carries a generic message plus the digest hash. This is deliberate, not an oversight — server error messages routinely contain connection strings, file paths, and query fragments that have no business reaching a browser. The cost is that debugging from the client alone is impossible by design. The digest is a claim ticket; the server log holds the coat. The first time you hit this in production you will assume something is broken in your setup. It is not — it is the feature working.
Streaming adds one more wrinkle. With Suspense, the server flushes the page shell immediately and streams slower subtrees as they finish. By the time a slow Server Component throws, the response status — 200 — left the building long ago. The error arrives mid-stream and React swaps in the boundary fallback on the client. Two practical consequences: you cannot rely on HTTP status codes to monitor these failures, which is one more reason boundary-level reporting matters, and an error boundary wrapped around a Suspense boundary is the natural pairing — one handles the slow case, the other handles the failed case, of the same subtree.
Strategy, distilled: three layers. The shell layer is global-error.tsx plus a root error.tsx — catastrophic failures, root layout crashes. You hope they never render; write them anyway, and keep them dependency-free, because if your theme provider is what crashed, a fallback styled by the theme provider crashes with it. The route layer is an error.tsx in every segment that owns a distinct surface where a user could lose context or work: dashboard, settings, checkout, editor. These are cheap — a file per segment. The widget layer is individual boundaries around components with higher crash probability: third-party charting libraries, markdown renderers fed user-generated content, embeds, anything parsing data you do not control.
app/
├─ layout.tsx # shell: nav, theme, providers
├─ global-error.tsx # last resort — replaces the root layout
├─ error.tsx # page crashed, shell survives
├─ blog/
│ └─ page.tsx # static content, low risk — root boundary is enough
└─ dashboard/
├─ error.tsx # dashboard crashed, /blog unaffected
├─ page.tsx
└─ _components/
├─ revenue-chart.tsx # own boundary: third-party chart lib
└─ activity-feed.tsx # own boundary: renders user contentThe widget layer is where react-error-boundary earns its place, because writing a class for every risky component gets old fast:
import { ErrorBoundary } from 'react-error-boundary';
import * as Sentry from '@sentry/nextjs';
import { RevenueChart } from './revenue-chart';
export function ChartSection() {
return (
<ErrorBoundary
fallback={
<div role="alert" className="grid h-80 place-content-center">
The revenue chart could not load. Everything else is up to date.
</div>
}
onError={(error) => Sentry.captureException(error)}
>
<RevenueChart />
</ErrorBoundary>
);
}Notice what the tree above does not have: a boundary around every component. The blog segment leans on the root boundary because static content rarely throws, and a fallback there would be ceremony. Boundary placement is one line item in a larger discipline — the production-grade code checklist treats error handling, logging, and recovery as a single system, which is the right altitude to think at. A boundary is not a feature; it is what keeps the rest of your features standing.
Open your app directory and count the error.tsx files. If the answer is zero, start with two: a root error.tsx and one in whichever segment users would be angriest to lose. Then walk the tree looking for the risky widgets — anything third-party, anything rendering content you did not write — and give each its own fence. Wire reporting before you add more boundaries, not after; an unreported fallback is a bug-hiding machine.
Then test it the only way that counts: throw on purpose. Add a deliberate throw to a Server Component, run a production build, and check three things — the fallback renders inside its compartment instead of blanking the page, the retry button actually recovers after you remove the throw, and the digest in the browser matches a line in your server logs. Ten minutes of deliberate breakage tells you more than any article can. The difference between a tutorial project and a production app is not how it behaves on the happy path — it is what stays standing on the worst day, and that is something you design, file by file, before the worst day arrives.
