
Custom Error Boundaries in Next.js with error.tsx and global-error.tsx
Every app eventually throws an error you didn't plan for. A database times out, an upstream API returns HTML instead of JSON, or someone ships a component that reads a property of undefined. Without a boundary in place, React unmounts the whole tree and your users see a blank screen or the default Next.js error page.
The App Router gives you file-based error boundaries so you can contain those failures. Drop an error.tsx next to a route and anything that throws inside that segment gets replaced with your fallback UI, while the rest of the page (the header, the sidebar, the layout) keeps working. For the rare case where the root layout itself breaks, there's global-error.tsx.
In this post I'll cover how these two files work in Next.js 16, what the error and retry props actually give you, where to place boundaries, how to report errors, and the newer catchError helper for component-level boundaries. I'll also point out the things error boundaries deliberately do not catch, since that's where most confusion comes from.
Expected Errors vs. Uncaught Exceptions
Before writing any boundary, it helps to separate two kinds of failure:
- Expected errors are part of normal operation: a form field fails validation, a record doesn't exist, a payment is declined. You should handle these explicitly and render something meaningful, usually by returning a value from a Server Function or rendering a message in a Server Component.
- Uncaught exceptions are bugs or infrastructure failures: a null dereference, a crashed service, a malformed response. You can't reasonably handle each one where it happens, so you let it throw and catch it at a boundary.
Error boundaries are for the second category. If you find yourself throwing an error just to show "Invalid email", that's a sign the error should be a return value instead. Keeping that distinction makes your boundaries rarer and more meaningful.
How error.tsx Works
An error.tsx file wraps its route segment in a React error boundary. When something inside that boundary throws during rendering, Next.js renders your error.tsx component in its place.
Here's a complete, production-ready starting point:
// app/dashboard/error.tsx
"use client";
import { useEffect } from "react";
export default function DashboardError({
error,
retry,
}: {
error: Error & { digest?: string };
retry: () => void;
}) {
useEffect(() => {
// Send to your logging or monitoring service
console.error(error);
}, [error]);
return (
<div role="alert" className="rounded-lg border p-6">
<h2 className="text-lg font-semibold">We couldn't load your dashboard</h2>
<p className="mt-2 text-sm text-gray-600">
Something went wrong on our side. You can try again, and if it keeps
happening, contact support.
</p>
{error.digest && (
<p className="mt-2 font-mono text-xs text-gray-500">
Reference: {error.digest}
</p>
)}
<button
onClick={() => retry()}
className="mt-4 rounded bg-black px-4 py-2 text-white"
>
Try again
</button>
</div>
);
}
A few things are worth pointing out:
"use client"is required. Error boundaries are a client-side React feature, so the file must be a Client Component. That also means you can't exportmetadatafrom it.useEffecthandles reporting. Logging inside an effect runs once per error, not on every re-render.- The digest is shown to the user. That gives support staff a reference they can search for in your server logs.
retry()attempts recovery. More on that below.
What the Boundary Wraps
The position of error.tsx in the component hierarchy matters. For a given segment, Next.js nests the special files roughly like this:
// Conceptual structure for app/dashboard (not real code)
<Layout>
<Template>
<ErrorBoundary fallback={<Error />}>
<Suspense fallback={<Loading />}>
<NotFoundBoundary fallback={<NotFound />}>
<Page />
</NotFoundBoundary>
</Suspense>
</ErrorBoundary>
</Template>
</Layout>
The boundary sits inside the segment's layout.tsx and template.tsx. So app/dashboard/error.tsx catches errors from app/dashboard/page.tsx, its loading.tsx, not-found.tsx, and any nested layouts and pages below it, but not errors thrown by app/dashboard/layout.tsx itself.
That's intentional. The layout stays mounted so navigation, sidebars, and shared state survive while only the broken content is swapped out. If you want to catch errors from app/dashboard/layout.tsx, put an error.tsx one level up in app/error.tsx.
Errors Bubble Up
When an error is thrown, it travels up to the nearest error boundary. That lets you layer boundaries from coarse to fine:
app/
├── error.tsx # catches anything below the root layout
├── layout.tsx
├── global-error.tsx # catches errors in the root layout itself
└── dashboard/
├── layout.tsx
├── error.tsx # catches dashboard pages
└── settings/
├── page.tsx
└── error.tsx # catches only the settings page
If settings/page.tsx throws, settings/error.tsx renders inside the dashboard layout. If dashboard/layout.tsx throws, the error skips the dashboard boundary and lands in app/error.tsx. If the root layout throws, only global-error.tsx can handle it.
You can also escalate deliberately. If your error component decides it can't handle a particular error, throwing from it sends the error to the next boundary up.
The error and retry Props
error
The error prop is an Error instance, but what it contains depends on where the error came from and which environment you're running in.
- Errors thrown in Client Components keep their original
message. - Errors thrown in Server Components are sanitized in production. The client receives a generic message plus a
digest, a hash that identifies the error in your server logs.
In development you'll see the real message from server errors too, which makes debugging easy but can hide the production behavior. Don't build UI that depends on error.message from a server error, because in production it won't contain what you expect. Use the digest to correlate, and keep user-facing copy generic.
This sanitization is a security feature. Server errors often include connection strings, SQL fragments, file paths, or internal hostnames. Sending those to the browser would be a leak.
retry
Next.js 16.3 stabilized the retry prop. Calling it re-fetches and re-renders the boundary's children. If the second attempt succeeds, your fallback disappears and the real content takes its place.
That's important for Server Component errors. If a server render failed because a service briefly timed out, re-rendering only on the client would fail again with the same cached result. retry() goes back to the server for fresh data, which is what you usually want.
There's also a reset() function, which clears the error state and re-renders without re-fetching. It's the older behavior and only helps when the error was purely client-side, for example a component that threw because of some transient client state. In most cases, use retry().
// app/reports/error.tsx
"use client";
export default function ReportsError({
error,
retry,
reset,
}: {
error: Error & { digest?: string };
retry: () => void;
reset: () => void;
}) {
return (
<div role="alert">
<p>The report failed to load.</p>
<button onClick={() => retry()}>Reload report</button>
{/* Only useful when the failure was client-only */}
<button onClick={() => reset()}>Reset view</button>
</div>
);
}
If you're upgrading from an older version, you may have code that receives reset and calls it to recover. It still works, but switching to retry gives you real recovery from server failures.
Handling Root Layout Errors with global-error.tsx
app/error.tsx can't catch errors thrown by app/layout.tsx, because it renders inside that layout. For those, Next.js provides app/global-error.tsx. When it's active, it replaces the root layout entirely, which has some consequences:
- It must render its own
<html>and<body>tags. - Your global CSS, fonts, and providers from the root layout aren't present.
- It must be a Client Component, so
metadataexports aren't supported. Use React's<title>element instead.
// app/global-error.tsx
"use client";
import { useEffect } from "react";
export default function GlobalError({
error,
retry,
}: {
error: Error & { digest?: string };
retry: () => void;
}) {
useEffect(() => {
console.error(error);
}, [error]);
return (
<html lang="en">
<body
style={{
fontFamily: "system-ui, sans-serif",
display: "grid",
placeItems: "center",
minHeight: "100vh",
margin: 0,
}}
>
<title>Something went wrong</title>
<main style={{ maxWidth: 480, padding: 24, textAlign: "center" }}>
<h1>Something went wrong</h1>
<p>The page couldn't be displayed. Please try again in a moment.</p>
<button onClick={() => retry()}>Try again</button>
</main>
</body>
</html>
);
}
Inline styles are a sensible choice here. Since your global stylesheet isn't loaded, Tailwind classes or CSS modules imported only in the root layout won't apply. You can import a small CSS file directly in global-error.tsx if you prefer, but keep it minimal: this page needs to render even when other parts of the app are broken.
The same applies to theming. If your app sets a dark mode class or data-theme attribute in the root layout, global-error.tsx won't inherit it. Either follow the OS preference with a prefers-color-scheme media query or apply your own theme logic inside the component.
Because global-error.tsx only triggers when the root layout fails, you should still have app/error.tsx for everything else. Think of global-error.tsx as the last safety net, not your primary error UI.
Component-Level Boundaries with catchError
File-based boundaries are tied to route segments. Sometimes you want a smaller blast radius: a dashboard with six independent widgets where one failing chart shouldn't hide the other five.
Next.js 16.3 includes a stable catchError function in next/error for exactly this. You give it a fallback function, and it returns a component that wraps its children in a boundary:
// app/ui/widget-boundary.tsx
"use client";
import { catchError, type ErrorInfo } from "next/error";
function WidgetFallback(props: { title: string }, { error, retry }: ErrorInfo) {
return (
<div role="alert" className="rounded border border-red-200 p-4">
<p className="font-medium">{props.title} is unavailable</p>
<button className="mt-2 text-sm underline" onClick={() => retry()}>
Retry
</button>
</div>
);
}
export default catchError(WidgetFallback);
The fallback receives two arguments: the props you passed to the wrapper (minus children), and an ErrorInfo object with error, retry, and reset. Now you can use the wrapper from a Server Component:
// app/dashboard/page.tsx
import WidgetBoundary from "@/app/ui/widget-boundary";
import { RevenueChart } from "@/app/ui/revenue-chart";
import { SignupsChart } from "@/app/ui/signups-chart";
export default function DashboardPage() {
return (
<div className="grid gap-6 md:grid-cols-2">
<WidgetBoundary title="Revenue">
<RevenueChart />
</WidgetBoundary>
<WidgetBoundary title="Signups">
<SignupsChart />
</WidgetBoundary>
</div>
);
}
If RevenueChart throws, only that card shows the fallback. The signups chart keeps rendering.
Why use catchError instead of writing your own class-based boundary? It's integrated with the framework:
retry()re-fetches from the server, just like inerror.tsx.redirect()andnotFound()work by throwing special errors internally. A hand-rolled boundary can accidentally swallow them;catchErrorpasses them through.- The error state clears automatically when the user navigates to another route.
Combine this with Suspense for the best effect. Wrap each widget in both a boundary and a Suspense fallback, and each part of the page loads and fails independently.
What Error Boundaries Don't Catch
This is the section that saves debugging time. Error boundaries catch errors thrown during rendering. They don't catch:
- Errors in event handlers. A
throwinsideonClickhappens after rendering, so no boundary sees it. - Errors in async code that runs outside rendering, like a
setTimeoutcallback or a promise you didn't await during render. - Errors in the boundary itself. If
error.tsxthrows, the error goes to the next boundary up.
For event handlers, catch the error and put it in state:
// app/ui/export-button.tsx
"use client";
import { useState } from "react";
export function ExportButton() {
const [error, setError] = useState<string | null>(null);
async function handleClick() {
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. Please try again.");
}
}
return (
<div>
<button onClick={handleClick}>Export CSV</button>
{error && <p role="alert">{error}</p>}
</div>
);
}
There's one useful exception: an unhandled error thrown inside startTransition (from useTransition) does propagate to the nearest error boundary. If you want a failing action to show your boundary's fallback, running it in a transition is the way to do that.
Don't Swallow Framework Errors
notFound(), redirect(), and permanentRedirect() all work by throwing. If you call them inside a try block, a broad catch will intercept them and the redirect or 404 never happens. Next.js exports unstable_rethrow to handle this:
// app/posts/[id]/page.tsx
import { notFound, unstable_rethrow } from "next/navigation";
export default async function PostPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
try {
const res = await fetch(`https://api.example.com/posts/${id}`);
if (res.status === 404) notFound();
if (!res.ok) throw new Error(`Upstream error ${res.status}`);
const post: { title: string; body: string } = await res.json();
return (
<article>
<h1>{post.title}</h1>
<p>{post.body}</p>
</article>
);
} catch (err) {
unstable_rethrow(err);
// Only real application errors reach this point
throw err;
}
}
Call unstable_rethrow first in the catch block. It re-throws Next.js control-flow errors and does nothing for everything else. Often the cleaner fix is to keep the try block narrow so notFound() isn't inside it at all.
Reporting Errors on the Server
Logging from useEffect in error.tsx only tells you about errors that reached a client boundary, and in production the message is sanitized. For the full picture, report errors where they happen, on the server.
Next.js calls an onRequestError hook from instrumentation.ts whenever the server captures an error during rendering, in a Route Handler, a Server Action, or the proxy:
// instrumentation.ts
import { type Instrumentation } from "next";
export const onRequestError: Instrumentation.onRequestError = async (
err,
request,
context,
) => {
const message = err instanceof Error ? err.message : String(err);
const digest =
typeof err === "object" && err !== null && "digest" in err
? String(err.digest)
: undefined;
await fetch(process.env.ERROR_REPORTING_URL!, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
message,
digest,
path: request.path,
method: request.method,
routePath: context.routePath,
routeType: context.routeType,
}),
});
};
The server-side record includes the full message and the same digest the client sees. When a user sends you "Reference: 2814791034", you can search your logs for that digest and find the real stack trace. Most monitoring services (Sentry, Datadog, and others) ship Next.js integrations that hook into this for you.
Testing Your Boundaries
Error UI is easy to forget because you rarely see it. A few ways to exercise it:
- Throw on purpose. Add a temporary
throw new Error("test")to a page, or gate it behind a search param in development. - Use React DevTools. The Components panel lets you force an error boundary into its error state without changing code.
- Check production behavior. Run
next buildandnext startlocally to confirm you see the digest instead of the raw server message. - Break the root layout. Throwing in
app/layout.tsxonce confirmsglobal-error.tsxrenders correctly without your global styles.
Placement Guidelines
A practical setup for most apps:
| File | Purpose |
|---|---|
app/global-error.tsx | Last resort when the root layout fails. Minimal, self-styled. |
app/error.tsx | Generic fallback for any route, rendered inside your layout. |
app/(section)/error.tsx | Section-specific copy and recovery, e.g. dashboard or checkout. |
catchError wrappers | Independent widgets that shouldn't take each other down. |
Don't add an error.tsx to every folder. Add one where the recovery story is different: where you'd show different copy, a different action, or keep different parts of the layout alive.
Conclusion
error.tsx turns unexpected exceptions into contained, recoverable UI scoped to a route segment. global-error.tsx covers the one place it can't reach, the root layout, at the cost of rendering without your usual styles. In Next.js 16, retry() gives both of them real recovery from server failures, and catchError brings the same behavior down to individual components.
Handle expected errors as values, let real exceptions throw, keep server error details on the server, and use the digest to connect what users see with what your logs recorded. If you're also building the "this page doesn't exist" side of things, that's handled by a different file, not-found.tsx, which I cover in building custom 404 pages.


