Error handling is the feature developers consistently under-invest in until a production incident teaches the lesson. Next.js App Router introduced a file-based error boundary system that aligns error handling with your route hierarchy — a major improvement over the Pages Router where you'd set up error boundaries manually. I've built robust error handling into multiple production Next.js applications and the pattern I'm sharing is what actually works in the field.
Next.js App Router has three error handling files: error.tsx catches runtime errors in the route segment and its children. not-found.tsx catches notFound() calls and 404 routes. global-error.tsx catches errors in the root layout (including layout.tsx itself). error.tsx is a Client Component (must have 'use client') and receives an error prop (the thrown Error) and a reset prop (a function to retry rendering). Errors bubble up through the hierarchy — an error in a nested route segment will be caught by the nearest error.tsx above it.
error.tsx wraps page.tsx, loading.tsx, and child layouts in a React error boundary, but does NOT wrap the layout.tsx in the same segment. This means errors in your root layout won't be caught by a root-level error.tsx — you need global-error.tsx for that. global-error.tsx replaces the entire root layout when it renders, so it must include <html> and <body> tags. For most applications, the important boundary is the route segment error.tsx — global-error.tsx is a last-resort fallback.
Next.js App Router error boundary hierarchy:
global-error.tsx ← catches errors in root layout.tsx (last resort)
└── app/layout.tsx
└── app/[locale]/layout.tsx
├── error.tsx ← catches errors in this route segment & children
│ └── app/[locale]/(pages)/layout.tsx
│ ├── error.tsx ← more specific boundary
│ │ ├── loading.tsx
│ │ ├── not-found.tsx ← catches notFound() calls
│ │ └── page.tsx ← your page component
│ └── ...
└── ...
Error file reference:
┌─────────────────┬────────────────────────────────────────────────┐
│ File │ When it renders │
├─────────────────┼────────────────────────────────────────────────┤
│ error.tsx │ Runtime error in page.tsx, loading, child layout│
│ not-found.tsx │ notFound() called or no route match │
│ global-error.tsx│ Error in root layout.tsx (must include html+body)│
└─────────────────┴────────────────────────────────────────────────┘
Data flow for Server Component errors:
Server Component throws → Next.js catches → sanitizes in prod
→ passes minimal info to nearest error.tsx (Client Component)
→ error.tsx renders fallback UI + reset buttonFrom building error handling for an ERP dashboard: nest error.tsx at multiple levels of your route hierarchy, not just at the root. A critical accounting feature that errors should show a targeted message about that feature — not a generic app-level error. Put error.tsx in your dashboard layout, in your specific feature segments, and keep a root error.tsx as the final catch-all. Each level gives users more specific recovery options.
Next.js handles server-side errors differently from client-side errors. A Server Component that throws an error during rendering causes Next.js to pass the error information to the nearest error.tsx Client Component. In development, you see the full error message and stack trace. In production, the error message is sanitized — Next.js shows a generic message to prevent leaking sensitive server information. This means your error.tsx shouldn't try to display error.message in production — the server-side details won't be there.
// app/[locale]/(pages)/error.tsx
"use client"
import { useEffect } from "react"
import * as Sentry from "@sentry/nextjs"
interface Props {
error: Error & { digest?: string }
reset: () => void
}
export default function Error({ error, reset }: Props) {
useEffect(() => {
// Capture client-side error (error boundary was activated client-side)
Sentry.captureException(error)
}, [error])
return (
<div className="flex flex-col items-center justify-center min-h-[400px] gap-6">
<div className="text-center">
<h2 className="text-2xl font-bold text-slate-100 mb-2">Something went wrong</h2>
<p className="text-slate-400 text-sm max-w-md">
{/* In production, error.message is sanitized for Server Component errors */}
An unexpected error occurred. Please try again or contact support
{error.digest ? ` (Error ID: ${error.digest})` : ""}.
</p>
</div>
<div className="flex gap-3">
<button onClick={reset} className="px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600">
Try again
</button>
<a href="/" className="px-4 py-2 border border-slate-600 text-slate-300 rounded-lg hover:bg-slate-800">
Go home
</a>
</div>
</div>
)
}
// ─────────────────────────────────────────────────────────────────
// app/[locale]/(pages)/not-found.tsx (Server Component — no 'use client' needed)
import Link from "next/link"
export default function NotFound() {
return (
<div className="flex flex-col items-center justify-center min-h-[400px] gap-6">
<h2 className="text-6xl font-bold text-slate-100">404</h2>
<p className="text-slate-400">This page does not exist.</p>
<Link href="/" className="px-4 py-2 bg-blue-500 text-white rounded-lg">
Back to Home
</Link>
</div>
)
}
// ─────────────────────────────────────────────────────────────────
// Server Component — call notFound() for missing data
export default async function BlogPost({ params }: { params: { slug: string } }) {
const post = await prisma.post.findUnique({ where: { slug: params.slug } })
if (!post) {
notFound() // ← triggers not-found.tsx, returns proper 404 status
}
return <PostContent post={post} />
}
// ─────────────────────────────────────────────────────────────────
// instrumentation.ts — central Sentry capture for ALL Server Component errors
import * as Sentry from "@sentry/nextjs"
export function onRequestError(
error: Error,
request: { path: string; method: string },
context: { routeType: string }
) {
Sentry.captureException(error, {
extra: { path: request.path, method: request.method, routeType: context.routeType }
})
}The reset prop in error.tsx calls React's error boundary reset — it attempts to re-render the error boundary's children. This works well for transient errors (network flicker, temporary API unavailability) but won't fix a persistent error from bad data or a bug. A good error.tsx UI: a clear error message, a 'Try again' button that calls reset(), and a 'Go home' link that navigates away. For Server Component errors, reset() may trigger a server re-render — if the underlying data issue is resolved, the component renders successfully. If not, the error boundary catches it again.
error.tsx runs client-side and can't access your server-side logging (Pino, Winston, Sentry server integration). Client-side errors in error.tsx should be captured with a client-side error reporting service (Sentry's captureException, Axiom's browser SDK). But server-side errors that caused the boundary to activate won't automatically be sent to Sentry's server integration — you need to use Sentry's captureServerComponentException in your Server Components' try/catch blocks, or configure Next.js's onRequestError instrumentation hook (Next.js 15) to capture all unhandled Server Component errors centrally.
not-found.tsx renders when notFound() is called from a Server Component or when no route matches the URL. Call notFound() in your data fetching logic: if a blog post or product with the requested slug doesn't exist in the database, call notFound() rather than returning null and rendering a broken page. This gives users a proper 404 experience with navigation options. Customize not-found.tsx with your site's navigation so users can find other content — a blank 404 with no navigation is a dead end.
My standard Next.js error handling setup: error.tsx at the (pages) route group level with a styled error card, reset button, and home link. not-found.tsx at the root with search suggestions and popular pages. global-error.tsx as a minimal bare-bones fallback (just a button to refresh). Sentry for error capture — the onRequestError hook in instrumentation.ts captures all Server Component errors, and Sentry.captureException in error.tsx captures client-side errors. This setup catches everything and gives me a Slack notification within seconds of a production error.
Error boundaries are only as good as your testing. Test them: (1) Throw deliberately in a Server Component and verify error.tsx renders. (2) Call notFound() and verify not-found.tsx renders. (3) Throw in your root layout and verify global-error.tsx renders. (4) Verify that error.message is NOT shown in production (server-side error details should be sanitized). (5) Verify Sentry receives the error with proper context. Next.js's dev mode shows full error details; switch to production build for testing the sanitized experience. Untested error boundaries give you a false sense of security.