🛡️ Chapter 9: Error Handling
Catching what breaks without taking the whole app down, and the difference between an error and an expected outcome.
📖 Plain English Explanation
Things go wrong. A database connection drops. A third-party API returns 503. A user requests a post that was deleted five minutes ago. Someone without permission opens an admin URL.
Not all of these are the same kind of problem, and treating them identically produces a bad app.
Next.js splits them into two categories:
Expected outcomes — things you knew could happen. A missing post. A form with an invalid email. A logged-out user hitting a private page. These aren't bugs; they're branches in your logic. Handle them by returning a value or calling a purpose-built function like notFound().
Unexpected errors — genuine failures. The database is down. A null reference. A bug you didn't anticipate. These get caught by an error boundary: a component that catches errors from everything below it and renders a fallback instead of a blank screen.
The whole chapter is about mapping each failure to the right mechanism.
🚧 error.tsx — the segment error boundary
Add an error.tsx to a folder, and Next.js wraps that segment in a React error boundary.
app/dashboard/
├── error.tsx ← catches errors from page.tsx and below
├── layout.tsx
└── page.tsx
// app/dashboard/error.tsx
'use client' // ← error boundaries MUST be Client Components
import { useEffect } from 'react'
export default function Error({
error,
retry,
}: {
error: Error & { digest?: string }
retry: () => void
}) {
useEffect(() => {
// Report to Sentry, Datadog, etc.
console.error(error)
}, [error])
return (
<div className="rounded-lg border border-red-200 bg-red-50 p-6">
<h2 className="font-semibold">Something went wrong</h2>
<p className="text-sm text-red-700">
{error.digest ? `Reference: ${error.digest}` : null}
</p>
<button
onClick={() => retry()}
className="mt-4 rounded bg-red-600 px-4 py-2 text-white"
>
Try again
</button>
</div>
)
}
// app/dashboard/error.js
'use client'
import { useEffect } from 'react'
export default function Error({ error, retry }) {
useEffect(() => {
console.error(error)
}, [error])
return (
<div className="rounded-lg border border-red-200 bg-red-50 p-6">
<h2 className="font-semibold">Something went wrong</h2>
<p className="text-sm text-red-700">
{error.digest ? `Reference: ${error.digest}` : null}
</p>
<button
onClick={() => retry()}
className="mt-4 rounded bg-red-600 px-4 py-2 text-white"
>
Try again
</button>
</div>
)
}
retry vs reset
⚠️ Changed in Next.js 16
error.tsxnow receives aretryprop, stable as of 16.3 (it wasunstable_retryin 16.2). Prefer it overreset.
Prop What it does retry()Re-fetches and re-renders the boundary's children. Recovers from Server Component errors. reset()Clears the error state and re-renders without re-fetching. Cannot recover from a Server Component failure. If your data fetch failed on the server,
reset()just re-renders the same broken tree.retry()actually asks the server again. Older tutorials all showreset— switch them.
retry() runs inside a React Transition, so Client Component state outside the boundary is preserved.
What error.tsx catches — and what it doesn't
app/dashboard/
├── layout.tsx ← ❌ NOT caught by this error.tsx
├── template.tsx ← ❌ NOT caught
├── error.tsx ← the boundary
├── loading.tsx ← ✅ caught
├── not-found.tsx ← ✅ caught
├── page.tsx ← ✅ caught
└── settings/
└── page.tsx ← ✅ caught (nested)
The boundary sits inside its segment's layout. An error thrown by app/dashboard/layout.tsx bubbles up to the parent segment's error.tsx.
This is deliberate — the error UI renders inside the layout, so the user keeps their navigation and can click elsewhere.
The digest and why production messages are vague
In production, error.message from a Server Component is replaced with a generic string. That's a security feature: a raw error might contain a connection string, a query, or a file path.
Instead you get error.digest — a hash that also appears in your server logs:
[server log] Error: connect ECONNREFUSED 10.0.0.4:5432
digest: 3721896449
[browser] Something went wrong. Reference: 3721896449
Show the digest in your error UI. It turns "the site is broken" into a support ticket you can actually trace.
Errors from Client Components keep their real message, since that code already shipped to the browser.
🌍 global-error.tsx
If the root layout itself throws, there's no parent boundary left. app/global-error.tsx is the last line of defense — it replaces the root layout, so it must render its own <html> and <body>.
// app/global-error.tsx
'use client'
export default function GlobalError({
error,
retry,
}: {
error: Error & { digest?: string }
retry: () => void
}) {
return (
<html lang="en">
<body style={{ fontFamily: 'system-ui', padding: '4rem', textAlign: 'center' }}>
<h1>Something went badly wrong</h1>
<p>We've been notified. Reference: {error.digest}</p>
<button onClick={() => retry()}>Try again</button>
</body>
</html>
)
}
// app/global-error.js
'use client'
export default function GlobalError({ error, retry }) {
return (
<html lang="en">
<body style={{ fontFamily: 'system-ui', padding: '4rem', textAlign: 'center' }}>
<h1>Something went badly wrong</h1>
<p>We've been notified. Reference: {error.digest}</p>
<button onClick={() => retry()}>Try again</button>
</body>
</html>
)
}
Three things to know:
- It replaces the root layout, so your global CSS, fonts, and providers are gone. Use inline styles or import a minimal stylesheet directly.
- It's a Client Component, so
metadataexports don't work. Use React's<title>component if you need one. - It renders its own document and won't pick up an app-level theme class. If you support dark mode, apply it inside
global-erroryourself.
Keep it simple. A global error page that itself throws is a very bad day.
🎯 catchError — boundaries anywhere
error.tsx is tied to route segments. Sometimes you want a boundary around one widget in the middle of a page. That's catchError, stable in 16.3:
// app/widget-boundary.tsx
'use client'
import { catchError, type ErrorInfo } from 'next/error'
function Fallback({ label }: { label: string }, { error, retry }: ErrorInfo) {
// ErrorInfo["error"] is typed `unknown` — narrow before reading .message
const message = error instanceof Error ? error.message : 'Unknown error'
return (
<div className="rounded border border-amber-300 bg-amber-50 p-4 text-sm">
<p>{label} is unavailable: {message}</p>
<button onClick={() => retry()}>Reload</button>
</div>
)
}
export default catchError(Fallback)
// app/widget-boundary.js
'use client'
import { catchError } from 'next/error'
function Fallback({ label }, { error, retry }) {
const message = error instanceof Error ? error.message : 'Unknown error'
return (
<div className="rounded border border-amber-300 bg-amber-50 p-4 text-sm">
<p>{label} is unavailable: {message}</p>
<button onClick={() => retry()}>Reload</button>
</div>
)
}
export default catchError(Fallback)
TypeScript note:
ErrorInfois{ error: unknown; reset: () => void; retry: () => void }. Readingerror.messagedirectly is a compile error — narrow witherror instanceof Errorfirst. The official docs example omits this; your build won't.
Use the returned component anywhere:
// app/dashboard/page.tsx
import WidgetBoundary from '../widget-boundary'
import { Suspense } from 'react'
export default function Page() {
return (
<div className="grid gap-6">
<WidgetBoundary label="Revenue chart">
<Suspense fallback={<Skeleton />}>
<RevenueChart />
</Suspense>
</WidgetBoundary>
<WidgetBoundary label="Activity feed">
<Suspense fallback={<Skeleton />}>
<ActivityFeed />
</Suspense>
</WidgetBoundary>
</div>
)
}
Why not a hand-written React error boundary? Because catchError knows about Next.js internals: it won't swallow redirect() or notFound(), its retry() re-fetches server data, and the error state clears automatically on client navigation. A plain class-component boundary gets all three wrong.
🔍 notFound() and not-found.tsx
For content that doesn't exist:
// app/blog/[slug]/page.tsx
import { notFound } from 'next/navigation'
import { getPost } from '@/lib/posts'
export default async function Page(props: PageProps<'/blog/[slug]'>) {
const { slug } = await props.params
const post = await getPost(slug)
if (!post) notFound()
return <article>{post.body}</article>
}
// app/blog/[slug]/page.js
import { notFound } from 'next/navigation'
import { getPost } from '@/lib/posts'
export default async function Page({ params }) {
const { slug } = await params
const post = await getPost(slug)
if (!post) notFound()
return <article>{post.body}</article>
}
// app/blog/not-found.tsx
import Link from 'next/link'
export default function NotFound() {
return (
<div className="py-16 text-center">
<h2 className="text-2xl font-semibold">Post not found</h2>
<p className="mt-2 text-slate-600">It may have been moved or deleted.</p>
<Link href="/blog" className="mt-4 inline-block underline">
Browse all posts
</Link>
</div>
)
}
notFound() renders the nearest not-found.tsx and sends a real 404 status. A root app/not-found.tsx also handles URLs that match no route at all.
Use this, not error.tsx, for missing content. A 404 is an expected outcome with a specific HTTP status and specific SEO meaning. Throwing a generic error gives you a 500 and tells Google your site is broken.
🔐 unauthorized() and forbidden()
Two more purpose-built interrupts, for the difference between "who are you?" and "you can't do that":
| Function | Status | Meaning | File |
|---|---|---|---|
unauthorized() | 401 | Not signed in | unauthorized.tsx |
forbidden() | 403 | Signed in, but not allowed | forbidden.tsx |
They require an experimental flag:
// next.config.ts
import type { NextConfig } from 'next'
const nextConfig: NextConfig = {
experimental: {
authInterrupts: true,
},
}
export default nextConfig
// next.config.js
module.exports = {
experimental: {
authInterrupts: true,
},
}
// app/admin/page.tsx
import { unauthorized, forbidden } from 'next/navigation'
import { verifySession } from '@/lib/dal'
export default async function Page() {
const session = await verifySession()
if (!session) unauthorized() // 401 → unauthorized.tsx
if (session.role !== 'admin') forbidden() // 403 → forbidden.tsx
return <AdminPanel />
}
// app/admin/page.js
import { unauthorized, forbidden } from 'next/navigation'
import { verifySession } from '@/lib/dal'
export default async function Page() {
const session = await verifySession()
if (!session) unauthorized()
if (session.role !== 'admin') forbidden()
return <AdminPanel />
}
// app/unauthorized.tsx
import { LoginForm } from '@/components/LoginForm'
export default function Unauthorized() {
return (
<main className="mx-auto max-w-sm py-16">
<h1 className="text-xl font-semibold">Please sign in</h1>
<LoginForm />
</main>
)
}
Both are marked experimental in Next.js 16.3 — the API may still change, so weigh that before adopting them in production. redirect('/login') remains the conservative choice.
A caveat about status codes
If the auth check runs inside a <Suspense> boundary, the response has already started streaming as a 200 and the status can't be changed. The user still sees the right UI, but the HTTP status is wrong.
To get a real 401/403 status, the check must run before streaming begins — which in practice means in proxy.ts (Chapter 17).
💥 The control-flow trap
redirect(), notFound(), forbidden(), and unauthorized() all work by throwing a special error. That means a try/catch will swallow them.
// ❌ the redirect silently never happens
export default async function Page() {
try {
const session = await getSession()
if (!session) redirect('/login')
const data = await getData()
return <View data={data} />
} catch (error) {
return <p>Something went wrong</p> // ← catches the redirect signal
}
}
Three ways to fix it:
1. Keep the interrupt outside the try (best):
export default async function Page() {
const session = await getSession()
if (!session) redirect('/login') // outside any try
let data
try {
data = await getData()
} catch {
return <p>Couldn't load your data</p>
}
return <View data={data} />
}
2. Re-throw framework errors with unstable_rethrow:
import { unstable_rethrow } from 'next/navigation'
export default async function Page() {
try {
const session = await getSession()
if (!session) redirect('/login')
return <View data={await getData()} />
} catch (error) {
unstable_rethrow(error) // lets redirect/notFound pass through
return <p>Something went wrong</p> // only real errors reach here
}
}
import { unstable_rethrow } from 'next/navigation'
export default async function Page() {
try {
const session = await getSession()
if (!session) redirect('/login')
return <View data={await getData()} />
} catch (error) {
unstable_rethrow(error)
return <p>Something went wrong</p>
}
}
unstable_rethrow must be the first statement in the catch block, before any logging.
3. Don't wrap render logic in try/catch at all. Let error.tsx do its job. try/catch in a Server Component is usually a sign you're duplicating the error boundary.
Another gotcha: un-awaited promises
// ❌ the interrupt throws where nothing catches it
export default async function Page() {
checkAuth() // not awaited!
return <Dashboard />
}
If checkAuth() calls unauthorized(), the throw happens outside the render path. You get an unhandledRejection in the server log and no error UI. Always await anything that might interrupt.
📝 Expected errors in Server Actions
Server Actions are different. A validation failure is not an exception — it's a normal result the UI needs to display.
Return an error object; don't throw.
// app/actions.ts
'use server'
import { z } from 'zod'
import { db } from '@/lib/db'
const schema = z.object({
email: z.string().email('Enter a valid email address'),
name: z.string().min(2, 'Name is too short'),
})
export type State = {
errors?: Record<string, string[]>
message?: string
}
export async function createUser(prev: State, formData: FormData): Promise<State> {
const parsed = schema.safeParse({
email: formData.get('email'),
name: formData.get('name'),
})
if (!parsed.success) {
return { errors: parsed.error.flatten().fieldErrors }
}
try {
await db.user.create({ data: parsed.data })
} catch (error) {
console.error(error)
return { message: 'Could not create the account. Please try again.' }
}
return { message: 'Account created.' }
}
// app/actions.js
'use server'
import { z } from 'zod'
import { db } from '@/lib/db'
const schema = z.object({
email: z.string().email('Enter a valid email address'),
name: z.string().min(2, 'Name is too short'),
})
export async function createUser(prev, formData) {
const parsed = schema.safeParse({
email: formData.get('email'),
name: formData.get('name'),
})
if (!parsed.success) {
return { errors: parsed.error.flatten().fieldErrors }
}
try {
await db.user.create({ data: parsed.data })
} catch (error) {
console.error(error)
return { message: 'Could not create the account. Please try again.' }
}
return { message: 'Account created.' }
}
Consumed with useActionState:
// app/signup/Form.tsx
'use client'
import { useActionState } from 'react'
import { createUser, type State } from '../actions'
const initial: State = {}
export function Form() {
const [state, formAction, isPending] = useActionState(createUser, initial)
return (
<form action={formAction}>
<input name="name" />
{state.errors?.name && <p className="text-red-600">{state.errors.name[0]}</p>}
<input name="email" type="email" />
{state.errors?.email && <p className="text-red-600">{state.errors.email[0]}</p>}
<button disabled={isPending}>{isPending ? 'Saving…' : 'Sign up'}</button>
{state.message && <p>{state.message}</p>}
</form>
)
}
An uncaught throw inside a Server Action bubbles to the nearest error.tsx — which blows away the entire form, including everything the user typed. That's almost never what you want for a validation failure. Chapter 12 covers actions in full.
🗺️ The decision table
| Situation | Use | Result |
|---|---|---|
| Content doesn't exist | notFound() | 404 + not-found.tsx |
| User isn't signed in | redirect('/login') or unauthorized() | 307 or 401 |
| Signed in, wrong role | forbidden() | 403 + forbidden.tsx |
| Form validation failed | return an error object | UI shows field errors |
| Third-party API is down | try/catch + fallback UI | degraded but working page |
| Database is down | let it throw | error.tsx |
| Bug in a component | let it throw | error.tsx |
| Root layout broke | let it throw | global-error.tsx |
| One widget on a page broke | catchError boundary | rest of the page survives |
⚠️ Common Pitfalls
1. Forgetting 'use client' in error.tsx
Error: Components using `useState` or event handlers must be Client Components
Error boundaries must be Client Components. Every single time.
2. redirect() inside try/catch
Covered above. It's the most common App Router bug, full stop.
3. Using reset() instead of retry()
// ❌ won't recover from a server-side failure
<button onClick={() => reset()}>Try again</button>
// ✅
<button onClick={() => retry()}>Try again</button>
reset() re-renders without re-fetching. If the server threw, you'll just render the same error.
4. Expecting error.tsx to catch its own layout
app/dashboard/
├── layout.tsx ← throws
└── error.tsx ← does NOT catch it
The boundary lives inside the layout. Fix: put an error.tsx in the parent segment, or move the risky work out of the layout.
5. Throwing for a 404
// ❌ 500 status, generic error UI, bad for SEO
if (!post) throw new Error('Post not found')
// ✅ 404 status, dedicated UI
if (!post) notFound()
6. Leaking error details in production
// ❌ could expose a connection string or query
<pre>{error.stack}</pre>
// ✅
<p>Reference: {error.digest}</p>
Next.js already scrubs Server Component messages in production — don't undo it by rendering the stack.
7. Throwing validation errors from Server Actions
Wipes the form and the user's input. Return an error object instead.
8. No error boundary at all
An unhandled error in production shows the built-in error page — functional, but jarring and off-brand. Put an error.tsx at the root of each major section at minimum.
🎯 When & Why to Use
error.tsx → every major section (dashboard, admin, checkout)
global-error.tsx → once, at the app root, kept minimal
not-found.tsx → root + any section with its own missing-content UI
catchError → a single widget that shouldn't take the page down
notFound() → content genuinely doesn't exist
redirect() → send the user somewhere else (auth gates, moved URLs)
unauthorized() → 401 with a login prompt (experimental)
forbidden() → 403 for wrong-role access (experimental)
try/catch → a specific recoverable failure with a real fallback
return { error } → Server Action validation and expected failures
A pragmatic minimum for any real app:
app/
├── error.tsx ← catches everything
├── global-error.tsx ← catches the root layout
├── not-found.tsx ← unmatched URLs
└── (app)/
└── error.tsx ← keeps the sidebar usable when the app section breaks
🏋️ Mini Practice Problems
Problem 1: Why doesn't the redirect fire?
export default async function Page() {
try {
const user = await getUser()
if (!user) redirect('/login')
return <Profile user={user} />
} catch (e) {
console.error(e)
return <p>Error</p>
}
}
Explain the bug and give two fixes.
Problem 2: Pick the mechanism
For each, choose from: notFound(), redirect(), forbidden(), try/catch, return-an-error-object, or let-it-throw.
- A.
/users/99999— no such user - B. A logged-out visitor opens
/settings - C. A signed-in editor opens
/admin - D. The weather widget's API times out; the rest of the page is fine
- E. A signup form gets an email that's already taken
- F.
DATABASE_URLis unset in production
Problem 3: Where does it get caught?
app/
├── error.tsx
└── shop/
├── layout.tsx
├── error.tsx
└── cart/
└── page.tsx
Which boundary catches each?
- A.
shop/cart/page.tsxthrows - B.
shop/layout.tsxthrows - C.
app/layout.tsxthrows - D.
shop/error.tsxitself throws
Problem 4: Write it
Build a /reports/[id] page that:
- 404s when the report doesn't exist
- 403s when the user's org doesn't own it
- Renders the report with a stale-data warning if the live metrics API fails
- Falls back to a segment error boundary for anything else
💼 Interview Notes
Common Questions
Q: Why must error.tsx be a Client Component?
It's a React error boundary, which needs componentDidCatch and interactive recovery — both client-only. It also has to catch errors that happen during hydration in the browser.
Q: What's the difference between error.tsx and global-error.tsx?
error.tsx catches errors in its segment and below, and renders inside the parent layout so navigation survives. global-error.tsx catches errors in the root layout, replaces the entire document, and must render its own <html> and <body>.
Q: What's the difference between retry() and reset()?
retry() re-fetches server data and re-renders — it can recover from a Server Component failure. reset() only clears the error state and re-renders with the same data, so a server-side failure recurs immediately. retry became stable in 16.3 and is the one to use.
Q: Why does error.message say something generic in production?
Server Component error messages are scrubbed to avoid leaking connection strings, queries, or file paths to the browser. You get error.digest instead — a hash that matches the full error in your server logs.
Q: Why shouldn't you wrap redirect() in a try/catch?
It signals control flow by throwing a special error. A catch intercepts it and the redirect silently doesn't happen. Either keep the call outside the try, or call unstable_rethrow(error) as the first line of the catch.
Q: How should Server Actions report validation errors?
Return a serializable error object and render it with useActionState. Throwing bubbles to error.tsx, which unmounts the form and discards everything the user typed.
Q: notFound() or throw new Error() for a missing record?
notFound(). It produces a real 404 status, renders the dedicated not-found.tsx, and tells search engines the resource doesn't exist. A thrown error produces a 500, which is both wrong and bad for SEO.
🏢 Asked at Companies
- Vercel: "Design the error handling for an e-commerce checkout. Where does each boundary go and why?"
- Stripe: "A user reports 'Something went wrong' with no detail. How do you find what actually failed?"
- Airbnb: "Explain why
redirect()throws, and what that implies for how you write code around it." - Datadog: "How do you make sure every production error reaches your monitoring while keeping messages out of the browser?"
📊 Visual Memory Aid
ERROR BOUNDARY HIERARCHY
global-error.tsx
└── catches: root layout failures
replaces the whole document
app/layout.tsx
└── app/error.tsx
└── catches: everything below
renders INSIDE app/layout
app/shop/layout.tsx
└── app/shop/error.tsx
└── catches: shop pages + nested
does NOT catch shop/layout.tsx
EXPECTED vs UNEXPECTED
EXPECTED UNEXPECTED
──────── ──────────
notFound() → 404 throw → error.tsx
redirect() → 307 DB down → error.tsx
forbidden() → 403 null reference → error.tsx
return {error} → form UI root broke → global-error.tsx
THE THROW TRAP
redirect() ──┐
notFound() ──┤
forbidden()──┼──► these THROW to signal control flow
unauthorized()┘
try { redirect('/x') } catch {} ← ❌ swallowed, nothing happens
catch (e) {
unstable_rethrow(e) ← ✅ first line, lets them through
// real error handling below
}
🎯 Key Takeaways
- Separate expected outcomes from unexpected failures.
notFound(),redirect(), and returned error objects handle the former; error boundaries handle the latter. error.tsxmust be a Client Component, catches its segment and below, and does not catch the layout it lives beside.- Use
retry(), notreset(). Onlyretry()re-fetches, so only it can recover from a Server Component failure. This changed in Next.js 16. redirect()and friends work by throwing — never wrap them intry/catch, or useunstable_rethrow(error)as the first line of your catch.- Return errors from Server Actions, don't throw them. Throwing unmounts the form and loses the user's input.
Next Chapter: Server & Client Components →
Practice: Add error.tsx, not-found.tsx, and global-error.tsx to an app. Then deliberately break things — throw in a page, throw in a layout, throw in the root layout, request a missing record — and confirm each lands in the boundary you expect.