Error handling in Next.js
Error handling in Next.js App Router - dedicated files (error.tsx, not-found.tsx, global-error.tsx) create automatic React Error Boundaries at each route segment level, so one failing part doesn't take down the whole app.
Theory
TL;DR
- Each route segment can have its own
error.tsx- an error in/dashboard/usersshows that segment's error UI while the navbar and sidebar stay intact - Think of it like a circuit breaker per floor in a building: one tripped breaker doesn't kill the lights everywhere
error.tsxcatches runtime crashes;not-found.tsxhandles missing resources (404s)error.tsxmust always be a client component ('use client') - no exceptions- Errors bubble up: if no local
error.tsxexists, Next.js looks at the parent segment, then root
Quick example
// app/dashboard/error.tsx
'use client'
export default function Error({
error,
reset,
}: {
error: Error & { digest?: string }
reset: () => void
}) {
return (
<div>
<h2>Dashboard failed to load</h2>
<p>{error.message}</p>
{/* digest is a hash for server-side logging, safe to display */}
<details>Error ID: {error.digest}</details>
{/* reset() re-renders only this segment, not the whole app */}
<button onClick={reset}>Try again</button>
</div>
)
}Place this file in app/dashboard/ and any error thrown inside /dashboard or its children triggers this UI. The root layout (navbar, sidebar) stays untouched.
Key difference
error.tsx creates a segment-scoped boundary. An error in app/blog/[slug]/page.tsx only triggers app/blog/error.tsx (or bubbles to root), not a white screen. This contrasts with Pages Router, where a crash in one page wiped out the whole shell. One more thing: error.tsx does NOT catch errors thrown inside layout.tsx of the same segment, because the boundary sits inside the layout. For layout errors, you need error.tsx in the parent segment, or global-error.tsx.
When to use
- Runtime error in a data fetch or component inside a segment: add
error.tsxin that folder - Missing resource on a dynamic route (
/posts/123where the post doesn't exist): callnotFound()fromnext/navigation, notthrow new Error(...)- this triggersnot-found.tsxand returns a proper 404 status - Root layout errors (auth provider crash, theme provider fail):
app/global-error.tsx - Errors inside Server Actions (form submissions, mutations): return
{ error: '...' }from the action, don't throw -error.tsxdoesn't intercept Server Action errors - Errors in API route handlers (
app/api/route.ts): standardtry/catchinside the handler,error.tsxskips these entirely
global-error.tsx
When an error happens in the root layout itself, error.tsx can't help because there's no parent segment to catch it. global-error.tsx takes over:
// app/global-error.tsx
'use client'
export default function GlobalError({
error,
reset,
}: {
error: Error & { digest?: string }
reset: () => void
}) {
return (
// Must include html + body - this replaces the root layout entirely
<html>
<body>
<h2>Something went wrong</h2>
<button onClick={reset}>Reload</button>
</body>
</html>
)
}In development mode, Next.js shows its own error overlay instead of global-error.tsx. Your custom component only appears in production builds.
Bubbling order
layout.tsx
└── error.tsx (catches errors from children)
└── loading.tsx
└── not-found.tsx
└── page.tsxAn error thrown in page.tsx bubbles up to the nearest error.tsx. If the segment has no error.tsx, it goes to the parent's. If nothing catches it at all, the app returns a 500 or global-error.tsx fires.
How it works internally
Next.js scans the file system at build/SSR time and wraps each route segment's component tree in a React Error Boundary when error.tsx exists. When a throw happens, React's boundary captures it via its componentDidCatch-like mechanism and re-renders only that segment's subtree with the error UI. The reset function triggers a state update that makes React retry rendering the original component tree without a full navigation.
On the server side, error.digest is a hash of the stack trace. The client gets this hash but not the actual stack, so sensitive server internals stay private. You forward the digest to your logging service (Sentry, Datadog, Vercel Logs) for tracing.
Server Actions
Server Actions return errors differently from UI components. Because error.tsx doesn't intercept them, the pattern is to return an error object:
'use server'
export async function updateProfile(formData: FormData) {
try {
await db.user.update({ /* ... */ })
revalidatePath('/settings')
return { success: true }
} catch (error) {
return { error: 'Failed to update profile' }
}
}The calling component checks the return value and shows its own error state.
Common mistakes
Forgetting 'use client' in error.tsx:
// Wrong - Next.js will throw a build error
export default function Error({ error }: any) {
return <div>{error.message}</div>
}Error Boundaries require React state management internally. They can't be Server Components. Add 'use client' at the top, always.
Using throw new Error(...) for missing data instead of notFound():
// Wrong - shows crash UI, returns 500 status
if (!post) throw new Error('Post not found')
// Correct - triggers not-found.tsx, returns 404 status
import { notFound } from 'next/navigation'
if (!post) notFound()A 500 for a missing blog post is bad for SEO. Search engines treat a 404 and a 500 very differently.
Storing retry state around reset():
// Problematic - reset() fully re-mounts the segment, local state is gone
const [retries, setRetries] = useState(0)
<button onClick={() => { setRetries(x => x + 1); reset() }}>Retry</button>reset() replaces the segment tree, which destroys all component state. Use error.digest for logging; don't try to track retries in local state.
No root error.tsx:
In practice, this is the most common oversight - teams skip root-level error handling until something breaks in production. Always add at least app/error.tsx and app/global-error.tsx as a safety net. Think of it as the final catch block at the application level.
Expecting error.tsx to catch layout errors in the same segment:
app/dashboard/
layout.tsx <- throws here
error.tsx <- does NOT catch thisThe boundary is mounted inside the layout, so it can't catch the layout's own errors. Move error.tsx one level up, or handle the error inside layout.tsx directly.
Real-world usage
- Vercel Commerce template: segment
error.tsxfor checkout flow crashes,error.digestforwarded to Vercel Logs - T3 Stack (tRPC + NextAuth):
app/(auth)/error.tsxcatches tRPC query failures while keeping the app shell - Shadcn/Tremor admin dashboards:
admin/[team]/error.tsxisolates team data errors per tenant - Payload CMS: custom
error.tsxthat forwards digest to their internal error webhook
Follow-up questions
Q: What happens if error.tsx itself throws?
A: It bubbles to the nearest parent error.tsx. React has a boundary stacking limit to prevent infinite loops, so eventually it hits global-error.tsx or the framework's default handler.
Q: How does error.digest work in production without leaking server details?
A: Next.js generates a hash of the full stack trace server-side and sends only that hash to the client. You log the hash with your monitoring tool (Sentry, Vercel Logs). The client sees a short ID, not internal stack frames.
Q: What's the difference between error.tsx and wrapping a component in a React ErrorBoundary class?
A: Next.js wraps segments automatically based on file presence. A class-based boundary requires manual placement around every risky component. Also, reset() in Next.js re-fetches data via the navigation state, not just a local re-render.
Q: How do parallel routes affect error bubbling?
A: An error inside a parallel route slot (like @modal) stays in that slot. If the slot has no error.tsx, it bubbles to the parent group layout, and reset() there retries the whole parallel group including other slots. Add slot-specific error.tsx for isolation.
Q: (Senior) How does Next.js handle server-thrown errors during SSR without leaking the stack to the client?
A: During RSC rendering, if a throw happens, Next.js renders the error boundary fallback on the server and sends a sanitized RSC payload to the client. The client hydrates with the boundary already in the error state. The digest hash connects client-visible error IDs to full server-side traces in your logging system.
Examples
Basic: catch errors in a dynamic product route
// app/shop/[id]/page.tsx
import { notFound } from 'next/navigation'
export default async function ProductPage({
params,
}: {
params: { id: string }
}) {
const id = parseInt(params.id)
// This throw is caught by app/shop/error.tsx
if (isNaN(id)) throw new Error('Invalid product ID')
const product = await fetchProduct(id)
// This triggers not-found.tsx with correct 404 status
if (!product) notFound()
return <div>{product.name}</div>
}// app/shop/error.tsx
'use client'
export default function ShopError({
error,
reset,
}: {
error: Error & { digest?: string }
reset: () => void
}) {
return (
<div>
<h2>Could not load product</h2>
<button onClick={reset}>Try again</button>
</div>
)
}Visiting /shop/abc shows the error UI while the main navigation stays visible. Visiting /shop/999 (valid ID, no product) shows the not-found page with a 404 status code.
Intermediate: production error component with Sentry logging
// app/admin/users/error.tsx
'use client'
import { useEffect } from 'react'
import * as Sentry from '@sentry/nextjs'
export default function AdminUsersError({
error,
reset,
}: {
error: Error & { digest?: string }
reset: () => void
}) {
useEffect(() => {
// digest ties this client-side log to the full server-side trace in Sentry
Sentry.captureException(error, {
tags: { segment: 'admin-users', digest: error.digest },
})
}, [error])
return (
<div className="p-8">
<h2 className="text-xl font-bold text-red-600">Admin panel error</h2>
<p className="mt-2 text-sm text-gray-500">Error ID: {error.digest}</p>
<button
onClick={reset}
className="mt-4 px-4 py-2 bg-blue-600 text-white rounded"
>
Retry
</button>
</div>
)
}The useEffect runs on mount, sending the error to Sentry before the user clicks anything. The digest tag in Sentry lets you pull up the full server stack trace from your dashboard.
Advanced: Server Action with returned error state
// app/settings/actions.ts
'use server'
export async function updateProfile(formData: FormData) {
try {
const name = formData.get('name') as string
await db.user.update({ where: { id: session.userId }, data: { name } })
revalidatePath('/settings')
return { success: true }
} catch {
return { error: 'Failed to update profile. Try again.' }
}
}// app/settings/page.tsx
'use client'
import { updateProfile } from './actions'
import { useState } from 'react'
export default function SettingsForm() {
const [errorMsg, setErrorMsg] = useState<string | null>(null)
async function handleSubmit(formData: FormData) {
const result = await updateProfile(formData)
if (result.error) setErrorMsg(result.error)
}
return (
<form action={handleSubmit}>
<input name="name" />
{errorMsg && <p className="text-red-500">{errorMsg}</p>}
<button type="submit">Save</button>
</form>
)
}Server Actions live outside the error.tsx boundary. Returning { error: '...' } instead of throwing gives the UI component full control over how to display the problem.
Short Answer
Interview readyA concise answer to help you respond confidently on this topic during an interview.