Skip to main content

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/users shows 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.tsx catches runtime crashes; not-found.tsx handles missing resources (404s)
  • error.tsx must always be a client component ('use client') - no exceptions
  • Errors bubble up: if no local error.tsx exists, Next.js looks at the parent segment, then root

Quick example

tsx
// 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.tsx in that folder
  • Missing resource on a dynamic route (/posts/123 where the post doesn't exist): call notFound() from next/navigation, not throw new Error(...) - this triggers not-found.tsx and 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.tsx doesn't intercept Server Action errors
  • Errors in API route handlers (app/api/route.ts): standard try/catch inside the handler, error.tsx skips 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:

tsx
// 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.tsx

An 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:

tsx
'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:

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():

tsx
// 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():

tsx
// 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 this

The 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.tsx for checkout flow crashes, error.digest forwarded to Vercel Logs
  • T3 Stack (tRPC + NextAuth): app/(auth)/error.tsx catches tRPC query failures while keeping the app shell
  • Shadcn/Tremor admin dashboards: admin/[team]/error.tsx isolates team data errors per tenant
  • Payload CMS: custom error.tsx that 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

tsx
// 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> }
tsx
// 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

tsx
// 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

tsx
// 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.' } } }
tsx
// 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 ready
Premium

A concise answer to help you respond confidently on this topic during an interview.

Finished reading?