Back/Module A-17 Error Architecture and Recovery Patterns
Module A-17·24 min read

The complete error hierarchy: segment vs page vs root error boundaries, global-error.tsx for root layout crashes, the digest prop for server-side error correlation, how errors propagate through nested RSC trees, Suspense + error boundary interaction, and the four error categories (validation, not-found, auth, unexpected) with the right handling pattern for each.

A-17 — Error Architecture and Recovery Patterns

Who this is for: Engineers who know error.tsx exists and want the complete picture before a production incident forces them to learn it the hard way.

Next.js has four distinct error handling mechanisms. Most developers know one of them. Using the wrong mechanism for the wrong error category produces two failure modes: swallowed errors (the user sees nothing, you debug nothing) or nuclear 500s for recoverable situations that should have been inline validation messages. This module gives you the complete map.


The Four Error Categories

Before touching any code, classify your errors. The classification drives everything else.

CategoryExampleCorrect mechanism
Validation error"Email already taken", "Price must be positive"Return from Server Action, display inline
Not-foundProduct slug doesn't exist, deleted recordnotFound()not-found.tsx
Auth error"You don't have permission to view this"redirect('/login') or forbidden()
Unexpected errorDB timeout, null dereference, third-party API crashthrow → caught by error.tsx

The rule: anything a user can cause by providing bad input or lacking permissions should never reach error.tsx. Those are expected outcomes of a working system. Only genuinely unexpected failures — things the code should never encounter under normal operation — belong in error.tsx.

Mixing these up is the root cause of most Next.js error handling bugs:

  • Throwing on validation → user sees "Something went wrong" instead of "Email already taken"
  • Returning on DB timeout → error is silently swallowed, user sees a form that never submits
  • Using notFound() for auth errors → crawler sees a 404 instead of a 401, affecting SEO
  • Using error.tsx for not-found → you lose the 404 status code semantics entirely

error.tsx — Segment-Level Error Boundaries

error.tsx is a React error boundary implemented as a Next.js file convention. It catches unexpected errors thrown from Server Components, Client Components, and Server Actions within the same route segment and all child segments that don't have their own error.tsx.

Under the hood, it wraps the route segment's content in a class-based React error boundary. You write a function component; Next.js handles the class wrapper.

typescript
// app/dashboard/error.tsx 'use client' // REQUIRED — error boundaries must be Client Components export default function DashboardError({ error, reset, }: { error: Error & { digest?: string } reset: () => void }) { return ( <div> <h2>Something went wrong loading the dashboard</h2> <p> If this continues, contact support with reference:{' '} <code>{error.digest}</code> </p> <button onClick={reset}>Try again</button> </div> ) }

Two props are passed:

error — the Error object. In production, error.message is a generic string ("An error occurred in the Server Components render") — not the original error message. Next.js deliberately strips error details from the client-side error to prevent information leakage. The error.digest is what connects the client-visible error to your server logs.

reset — a function that triggers a re-render of the error boundary's children. More on this below.

The 'use client' directive is not optional. Error boundaries are fundamentally about rendering fallback UI when a component fails to render. Detecting that failure requires React's componentDidCatch lifecycle, which only runs in the browser. Server Components cannot be error boundaries.

The digest — Your Incident Correlation Token

When Next.js catches an error server-side, it:

  1. Generates a deterministic hash (the digest) from the error details.
  2. Logs the full error (stack trace, request context) server-side, tagged with the digest.
  3. Sends only the digest to the client.

The user sees digest: "1234abcd". Your server logs contain the full stack trace tagged digest=1234abcd. You search your logging platform for that digest and find the exact error, the exact component, the exact line.

This is the intended workflow. Show the digest in your error UI. Train your support team to ask for it. Search for it in your observability platform when debugging.

typescript
// The digest is safe to display — it's a hash, not a stack trace <p>Reference: {error.digest}</p>

global-error.tsx — Root Layout Crashes

error.tsx has a blind spot: it cannot catch errors thrown by its sibling layout.tsx.

The segment hierarchy matters here. error.tsx at app/error.tsx wraps the page content but lives inside the root layout. If the root layout throws — database connection failure during session initialisation, a crash in your analytics provider — the root layout never finishes rendering. The error boundary inside the layout never gets mounted. Nothing catches the error.

global-error.tsx handles this case:

typescript
// app/global-error.tsx 'use client' export default function GlobalError({ error, reset, }: { error: Error & { digest?: string } reset: () => void }) { return ( // REQUIRED: global-error replaces the entire document // Must include html and body tags <html> <body> <div style={{ padding: '2rem', fontFamily: 'system-ui' }}> <h1>Application Error</h1> <p>Something went wrong at the application level.</p> {error.digest && ( <p>Reference: <code>{error.digest}</code></p> )} <button onClick={reset}>Try to recover</button> </div> </body> </html> ) }

global-error.tsx is the only Next.js component that must render <html> and <body> tags. It replaces the entire document — not a segment within the layout, but the full HTML output. Your root layout is not available. Your design system is not available. Your fonts are not loaded. This is a bare-bones document.

Keep global-error.tsx minimal. Its job is to tell the user something is catastrophically wrong and give them a reference number for support. It is not a full page — it's an emergency fallback.

In development, Next.js shows a full-page error overlay and ignores global-error.tsx. Test it by running a production build (next build && next start) and intentionally throwing from your root layout.


Error Propagation Through the RSC Tree

Errors propagate upward through the component tree until they hit an error boundary. The nearest ancestor error.tsx catches the error.

Understanding the exact propagation path prevents surprises:

app/
  layout.tsx         ← error NOT caught by app/error.tsx (sibling relationship)
  error.tsx          ← catches errors from app/page.tsx only
  global-error.tsx   ← catches errors from app/layout.tsx

  dashboard/
    layout.tsx       ← error NOT caught by dashboard/error.tsx (sibling)
    error.tsx        ← catches errors from dashboard/page.tsx and all children
    page.tsx         ← errors caught by dashboard/error.tsx
    settings/
      page.tsx       ← errors caught by dashboard/error.tsx (no settings/error.tsx)
      profile/
        page.tsx     ← errors caught by dashboard/error.tsx

The critical rule: a layout's errors are caught by the parent segment's error boundary, not its own segment's error boundary.

This means if you put all your data fetching in layout.tsx (a common pattern for sharing data between page and child routes), an error in that fetch takes down the nearest parent error boundary — potentially a large portion of your UI. It's a reason to prefer page-level data fetching or segment-specific error boundaries close to the data they protect.

Adding error.tsx files deeper in the tree narrows the blast radius of errors. A crash in dashboard/analytics/page.tsx with a dashboard/analytics/error.tsx in place only takes down the analytics section. Without it, the crash propagates to dashboard/error.tsx and takes down the entire dashboard.


Errors Inside Suspense Boundaries

A common misconception: Suspense boundaries are error boundaries.

They are not.

typescript
// This error passes THROUGH the Suspense boundary // and propagates to the nearest ancestor error.tsx export default function DashboardPage() { return ( <Suspense fallback={<AnalyticsSkeleton />}> <SlowAnalytics /> {/* throws during render */} </Suspense> ) }

<SlowAnalytics /> throws. The Suspense boundary's loading state disappears. The error propagates out of the Suspense boundary up to the nearest error.tsx — in this case dashboard/error.tsx or higher. The entire dashboard segment shows the error UI, not just the analytics section.

If you want per-component error recovery that doesn't take down the entire segment, you need a React error boundary wrapping the component inside the Suspense boundary. The react-error-boundary package provides a clean component for this:

typescript
// components/SafeComponent.tsx 'use client' import { ErrorBoundary } from 'react-error-boundary' function AnalyticsError() { return ( <div className="analytics-error"> <p>Analytics failed to load.</p> </div> ) } export function SafeAnalytics({ children }: { children: React.ReactNode }) { return ( <ErrorBoundary fallback={<AnalyticsError />}> {children} </ErrorBoundary> ) }
typescript
// Used in the page export default function DashboardPage() { return ( <Suspense fallback={<AnalyticsSkeleton />}> <SafeAnalytics> <SlowAnalytics /> {/* error now caught by ErrorBoundary, not error.tsx */} </SafeAnalytics> </Suspense> ) }

The <SafeAnalytics> component is Client Component (all React error boundaries are). The <SlowAnalytics> component can still be a Server Component — Client Components can render Server Components as children. The error boundary catches the error without converting SlowAnalytics to a Client Component.

When to use this pattern: any component that fetches non-critical data for a section of a page. Analytics, recommendations, secondary feeds. Errors in these should degrade the section, not the page.

When NOT to use this pattern: primary content. If the main content of a page fails to load, that IS a page error and error.tsx is the right mechanism.


Server Action Errors — Return, Don't Throw

Server Actions that throw unhandled exceptions trigger the nearest error.tsx. This is correct for unexpected failures. It is catastrophically wrong for expected business logic failures.

If a user submits a form with a duplicate email address and your Server Action throws, the user sees a full-page "Something went wrong" screen. Their form data is gone. They don't know what happened. This is not an acceptable user experience.

Expected failures should be returned as data:

typescript
// lib/actions/orders.ts 'use server' import { z } from 'zod' const orderSchema = z.object({ productId: z.string().uuid(), quantity: z.number().int().positive(), shippingAddress: z.string().min(10), }) type ActionResult = | { success: true; orderId: string } | { success: false; error: string; code: 'VALIDATION' | 'AUTH' | 'CONFLICT' | 'LIMIT_EXCEEDED' } export async function createOrder(formData: FormData): Promise<ActionResult> { const session = await auth() if (!session?.user) { return { success: false, error: 'You must be logged in to place an order', code: 'AUTH' } } const parsed = orderSchema.safeParse({ productId: formData.get('productId'), quantity: Number(formData.get('quantity')), shippingAddress: formData.get('shippingAddress'), }) if (!parsed.success) { return { success: false, error: parsed.error.issues[0].message, code: 'VALIDATION', } } // Check order limits const recentOrders = await db.orders.count({ where: { userId: session.user.id, createdAt: { gte: new Date(Date.now() - 86400000) } } }) if (recentOrders >= 10) { return { success: false, error: 'Maximum 10 orders per day', code: 'LIMIT_EXCEEDED' } } try { const order = await db.orders.create({ data: { ...parsed.data, userId: session.user.id } }) return { success: true, orderId: order.id } } catch (error) { if (isUniqueConstraintError(error)) { return { success: false, error: 'This order already exists', code: 'CONFLICT' } } // Unexpected database error — let it propagate to error.tsx throw error } }

The caller in the Client Component handles all returned failure states inline:

typescript
'use client' export function OrderForm() { const [error, setError] = useState<string | null>(null) const [success, setSuccess] = useState(false) const router = useRouter() async function handleSubmit(formData: FormData) { setError(null) const result = await createOrder(formData) if (!result.success) { if (result.code === 'AUTH') { router.push('/login') return } setError(result.error) // Show inline error return } setSuccess(true) router.push(`/orders/${result.orderId}`) } return ( <form action={handleSubmit}> {error && <p className="error">{error}</p>} {/* form fields */} <button type="submit">Place Order</button> </form> ) }

The pattern: return expected failure states (anything a user or external system can cause), throw unexpected ones (bugs, infrastructure failures). This keeps error.tsx for genuine crashes and keeps users informed for recoverable situations.


The reset() Function and When It Works

reset() in error.tsx triggers a re-render attempt of the error boundary's children. It does not reload the page. It does not clear React state outside the error boundary. It calls React's ErrorBoundary.reset() under the hood, which unmounts and remounts the failed subtree.

reset() works when the error was transient: a network request that timed out but would succeed on retry, a rate limit that has since cleared, a temporary dependency that has recovered.

reset() does not work when the error is persistent: a component with a null dereference bug, missing required data, a broken API contract. In these cases, reset() re-renders the failed component, which throws the same error immediately. The user sees the error UI reappear. After enough retries, it becomes obvious something structural is wrong.

Implement a retry counter that degrades to a page reload after multiple failures:

typescript
// app/dashboard/error.tsx 'use client' import { useEffect, useState } from 'react' export default function DashboardError({ error, reset, }: { error: Error & { digest?: string } reset: () => void }) { const [retries, setRetries] = useState(0) const maxRetries = 2 // Report the error to your observability platform useEffect(() => { console.error('Dashboard error:', error) // e.g., Sentry.captureException(error) if (error.digest) { // Track which error digests users are seeing // analytics.track('error_boundary_shown', { digest: error.digest }) } }, [error]) function handleReset() { if (retries >= maxRetries) { window.location.reload() return } setRetries(r => r + 1) reset() } return ( <div> <h2>Dashboard failed to load</h2> {error.digest && ( <p>Reference: <code>{error.digest}</code></p> )} <p> {retries > 0 ? `Retry ${retries} of ${maxRetries} failed.` : 'This is usually temporary.'} </p> <button onClick={handleReset}> {retries >= maxRetries ? 'Reload page' : 'Try again'} </button> </div> ) }

Two retries before reloading is a reasonable default. Transient errors (network timeouts) usually resolve on the first retry. If two retries have failed, the error is likely persistent and a full page reload is the appropriate recovery action.


not-found.tsx and the notFound() Function

notFound() is a special-case error. When any Server Component calls notFound(), Next.js stops rendering that component, returns HTTP 404, and renders the nearest not-found.tsx in the tree.

typescript
// app/products/[slug]/page.tsx import { notFound } from 'next/navigation' export default async function ProductPage({ params }: { params: { slug: string } }) { const product = await db.products.findUnique({ where: { slug: params.slug } }) if (!product) { notFound() // Returns 404, renders not-found.tsx } return <ProductDetail product={product} /> }
typescript
// app/products/not-found.tsx export default function ProductNotFound() { return ( <div> <h1>Product not found</h1> <p>This product may have been removed or the URL may be incorrect.</p> <a href="/products">Browse all products</a> </div> ) }

notFound() throws a special error internally. Like redirect(), it should not be inside a try/catch — the catch block would swallow it:

typescript
// WRONG — catches the notFound() throw try { const product = await getProduct(slug) if (!product) notFound() } catch (error) { // notFound() was caught here. The page continues rendering. // It will likely throw a null dereference error below. }
typescript
// CORRECT — notFound() outside the try block const product = await getProduct(slug) if (!product) notFound() // throws here, propagates correctly try { const enriched = await enrichProduct(product) return <ProductDetail product={enriched} /> } catch (error) { throw error // real unexpected error, goes to error.tsx }

The hierarchy for not-found.tsx is the same as error.tsx: the nearest ancestor not-found.tsx catches the notFound() call. Add a root-level app/not-found.tsx as the global fallback.


Connecting Errors to Your Observability Stack

The digest system gives you a correlation token. To make it useful, you need to capture errors server-side in a way that preserves the digest.

Next.js integrates with OpenTelemetry through instrumentation.ts. Sentry's Next.js SDK instruments this automatically:

typescript
// instrumentation.ts (Next.js 14.2+) import * as Sentry from '@sentry/nextjs' export async function register() { if (process.env.NEXT_RUNTIME === 'nodejs') { await import('./sentry.server.config') } if (process.env.NEXT_RUNTIME === 'edge') { await import('./sentry.edge.config') } } export const onRequestError = Sentry.captureRequestError

The onRequestError export is a Next.js hook introduced in Next.js 15 that fires for every request error before it reaches error.tsx. Sentry captures it here along with the digest, so when a user reports digest: "1234abcd", you can search Sentry and find the full stack trace, the request URL, the user's session, and the deployment version.

For manual error tracking without Sentry:

typescript
// instrumentation.ts export async function onRequestError( error: { digest?: string } & Error, request: { url: string; method: string }, context: { routerKind: string; routePath: string } ) { await fetch(process.env.ERROR_ENDPOINT!, { method: 'POST', body: JSON.stringify({ digest: error.digest, message: error.message, stack: error.stack, url: request.url, path: context.routePath, timestamp: new Date().toISOString(), }), }) }

This fires server-side before the error reaches the client. The digest in your error tracking database matches the digest shown to users.


The Complete Error Handling Decision Tree

When writing code that might fail, run through this:

  1. Is this failure caused by invalid user input? → Return { success: false, error: "message" } from the Server Action. Display inline.

  2. Is this failure caused by the user lacking access? → Return with code: 'AUTH', redirect to login. Or call forbidden() (Next.js 15+) for API-like 403 responses.

  3. Is this failure because a requested resource doesn't exist? → Call notFound(). This gives the correct 404 HTTP status and renders not-found.tsx.

  4. Is this failure something the user cannot resolve? → Let it throw. It propagates to the nearest error.tsx and shows a user-friendly fallback with a digest for support.

  5. Could this failure take down the root layout? → Make sure global-error.tsx exists and handles it gracefully.

Every error in your application falls into one of these categories. The category determines the mechanism. The mechanism determines the user experience.


Where We Go From Here

A-18 covers middleware architecture — the execution model, the limitations of the Edge Runtime in middleware, authentication patterns, and the problems that arise when middleware and Server Actions make conflicting auth decisions. With A-17's error propagation model understood, A-18's middleware error handling (middleware cannot use error.tsx) will make immediate sense.

Discussion