Back to Insights
React 19Next.js 16Error Handling

Error Handling Architecture in React 19 and Next.js 16: From Chaos to Clarity

05/202613 min read
Share
Error Handling Architecture in React 19 and Next.js 16: From Chaos to Clarity

Why Error Handling Deserves Architecture

Most teams treat error handling as an afterthought. A try/catch here, a generic toast there, maybe a Sentry integration that floods your inbox with noise. But in production, unhandled errors are the number one source of user frustration — a blank screen, a form that silently fails, a page stuck in a loading state forever.

React 19 and Next.js 16 give you powerful primitives for structured error handling — Error Boundaries, typed Server Action results, streaming error recovery, and file-based error UIs. But these primitives only work if you design an architecture around them.

This insight covers a production-grade error handling architecture that answers five questions:

  • What happens when a Server Component throws?
  • How do you surface Server Action failures to the user?
  • Where do you catch errors — and where do you let them propagate?
  • How do you give users a way to recover without refreshing?
  • What gets logged, and how do you make logs actionable?

#1 The Error Boundary Hierarchy

Error Boundaries are React's mechanism for catching rendering errors. In Next.js 16, you define them with error.tsx files — but the placement strategy matters more than most teams realize.

Layered Error Boundaries

Don't put a single error boundary at the root and call it a day. Layer them so errors are caught at the most specific level possible — giving users context about what failed and the ability to retry just that section.

app/error.tsx
'use client'; import { useEffect } from 'react'; export default function GlobalError({ error, reset, }: { error: Error & { digest?: string }; reset: () => void; }) { useEffect(() => { // Log to your error tracking service reportError(error); }, [error]); return ( <div className="flex min-h-[50vh] flex-col items-center justify-center gap-4"> <h2 className="text-2xl font-bold">Something went wrong</h2> <p className="text-foreground/60"> We've been notified and are looking into it. </p> <button onClick={reset} className="rounded-lg bg-primary px-4 py-2 text-white" > Try again </button> </div> ); }
app/dashboard/error.tsx
'use client'; export default function DashboardError({ error, reset, }: { error: Error & { digest?: string }; reset: () => void; }) { return ( <div className="rounded-xl border border-red-200 bg-red-50 p-6 dark:border-red-900/30 dark:bg-red-950/20"> <h3 className="font-semibold text-red-800 dark:text-red-200"> Dashboard failed to load </h3> <p className="mt-2 text-sm text-red-600 dark:text-red-300"> {error.message || 'Unable to fetch dashboard data.'} </p> <button onClick={reset} className="mt-4 text-sm font-medium text-red-700 underline dark:text-red-300" > Retry </button> </div> ); }

Placement Strategy

  • app/error.tsx — catches anything that falls through. Generic UI, last resort.
  • app/[feature]/error.tsx — catches errors within a feature. Can show contextual recovery.
  • Inline Error Boundaries — wrap risky third-party widgets or experimental components.
  • app/global-error.tsx — catches root layout errors (very rare, full-page replacement).
Result
  • Errors are caught at the most specific level possible
  • Users see contextual recovery options, not generic error screens
  • The rest of the page remains functional when one section fails

#2 Server Action Error Patterns

Server Actions can fail for dozens of reasons — validation, auth expiry, rate limits, database conflicts, network timeouts. The pattern you choose for returning errors determines your entire UX.

Result Pattern: Never Throw from Actions

Instead of throwing errors that crash the boundary, return a typed result object. This gives you full control over how failures are presented.

lib/action-result.ts
export type ActionResult<T = void> = | { success: true; data: T } | { success: false; error: string; fieldErrors?: Record<string, string[]> };
app/actions/subscribe.ts
'use server'; import { z } from 'zod'; import type { ActionResult } from '@/lib/action-result'; const schema = z.object({ email: z.string().email('Please enter a valid email'), }); export async function subscribe(formData: FormData): Promise<ActionResult> { const parsed = schema.safeParse({ email: formData.get('email') }); if (!parsed.success) { return { success: false, error: 'Validation failed', fieldErrors: parsed.error.flatten().fieldErrors, }; } try { await db.subscriber.create({ data: { email: parsed.data.email } }); return { success: true, data: undefined }; } catch (err) { if (isUniqueConstraintError(err)) { return { success: false, error: 'This email is already subscribed.' }; } // Unexpected errors — log and return generic message console.error('Subscribe action failed:', err); return { success: false, error: 'Something went wrong. Please try again.' }; } }

Consuming Results with useActionState

components/SubscribeForm.tsx
'use client'; import { useActionState } from 'react'; import { subscribe } from '@/app/actions/subscribe'; import type { ActionResult } from '@/lib/action-result'; export function SubscribeForm() { const [state, action, isPending] = useActionState<ActionResult | null, FormData>( async (_prev, formData) => subscribe(formData), null ); return ( <form action={action}> <div> <input name="email" type="email" placeholder="[email protected]" disabled={isPending} aria-invalid={state?.success === false ? 'true' : undefined} aria-describedby={state?.success === false ? 'email-error' : undefined} /> {state?.success === false && state.fieldErrors?.email && ( <p id="email-error" role="alert" className="text-sm text-red-600"> {state.fieldErrors.email[0]} </p> )} </div> <button type="submit" disabled={isPending}> {isPending ? 'Subscribing...' : 'Subscribe'} </button> {state?.success === false && !state.fieldErrors && ( <p role="alert" className="text-sm text-red-600">{state.error}</p> )} {state?.success === true && ( <p role="status" className="text-sm text-emerald-600">Subscribed!</p> )} </form> ); }

When to Throw vs When to Return

  • Return: validation errors, business logic failures, known edge cases — things the user can fix
  • Throw: unexpected crashes, infrastructure failures — things only you can fix
  • Thrown errors propagate to the nearest error.tsx boundary
  • Returned errors stay in the component for inline display
Result
  • Type-safe error handling — no unknown thrown values
  • Inline field errors without page-level disruption
  • Clear separation between user-fixable and infrastructure errors

#3 Suspense Fallbacks and Streaming Error Recovery

With streaming SSR in Next.js 16, errors can occur mid-stream. A component wrapped in Suspense might fail after the shell has already been sent to the client. Handling this gracefully requires understanding how Error Boundaries interact with streaming.

Error Boundary Inside Suspense

When a streamed component throws, the nearest Error Boundary catches it — but the surrounding page remains intact. This is the key advantage of granular Suspense boundaries.

app/dashboard/page.tsx
import { Suspense } from 'react'; import { ErrorBoundary } from '@/components/ErrorBoundary'; export default function DashboardPage() { return ( <div className="grid grid-cols-3 gap-6"> {/* Each widget can fail independently */} <Suspense fallback={<WidgetSkeleton />}> <ErrorBoundary fallback={<WidgetError name="Revenue" />}> <RevenueWidget /> </ErrorBoundary> </Suspense> <Suspense fallback={<WidgetSkeleton />}> <ErrorBoundary fallback={<WidgetError name="Users" />}> <UsersWidget /> </ErrorBoundary> </Suspense> <Suspense fallback={<WidgetSkeleton />}> <ErrorBoundary fallback={<WidgetError name="Orders" />}> <OrdersWidget /> </ErrorBoundary> </Suspense> </div> ); }

Reusable Error Boundary Component

components/ErrorBoundary.tsx
'use client'; import { Component, type ReactNode } from 'react'; interface Props { children: ReactNode; fallback: ReactNode; onError?: (error: Error) => void; } interface State { hasError: boolean; } export class ErrorBoundary extends Component<Props, State> { state: State = { hasError: false }; static getDerivedStateFromError(): State { return { hasError: true }; } componentDidCatch(error: Error) { this.props.onError?.(error); } render() { if (this.state.hasError) return this.props.fallback; return this.props.children; } }
Result
  • One failing widget doesn't take down the entire dashboard
  • Users see exactly what failed and can interact with the rest of the page
  • Streaming continues for other Suspense boundaries even when one fails

#4 Retry Strategies That Respect the User

A 'Try Again' button is the minimum. But production retry logic needs more nuance — automatic retries for transient failures, backoff for rate limits, and clear communication about what's happening.

Smart Retry Hook

hooks/useRetry.ts
'use client'; import { useState, useCallback } from 'react'; interface UseRetryOptions { maxRetries?: number; baseDelay?: number; } interface RetryState<T> { data: T | null; error: string | null; isLoading: boolean; retryCount: number; retry: () => void; } export function useRetry<T>( fn: () => Promise<T>, { maxRetries = 3, baseDelay = 1000 }: UseRetryOptions = {} ): RetryState<T> { const [data, setData] = useState<T | null>(null); const [error, setError] = useState<string | null>(null); const [isLoading, setIsLoading] = useState(false); const [retryCount, setRetryCount] = useState(0); const execute = useCallback(async () => { setIsLoading(true); setError(null); for (let attempt = 0; attempt <= maxRetries; attempt++) { try { const result = await fn(); setData(result); setRetryCount(attempt); setIsLoading(false); return; } catch (err) { if (attempt === maxRetries) { setError(err instanceof Error ? err.message : 'Request failed'); setRetryCount(attempt); setIsLoading(false); return; } // Exponential backoff between retries await new Promise((r) => setTimeout(r, baseDelay * 2 ** attempt)); } } }, [fn, maxRetries, baseDelay]); const retry = useCallback(() => { execute(); }, [execute]); return { data, error, isLoading, retryCount, retry }; }

Communicating Retry State to Users

Don't hide retries from users. A simple progress indicator builds trust:

components/RetryableWidget.tsx
'use client'; import { useRetry } from '@/hooks/useRetry'; export function RetryableWidget() { const { data, error, isLoading, retryCount, retry } = useRetry( () => fetch('/api/metrics').then(r => r.json()), { maxRetries: 3 } ); if (isLoading) { return ( <div aria-busy="true" aria-live="polite"> {retryCount > 0 ? `Retrying... (attempt ${retryCount + 1}/4)` : 'Loading...'} </div> ); } if (error) { return ( <div role="alert"> <p>Failed to load metrics: {error}</p> <button onClick={retry}>Try again</button> </div> ); } return <MetricsDisplay data={data} />; }

Retry Guidelines

  • Auto-retry for network errors and 5xx responses (transient)
  • Never auto-retry 4xx errors — those require user action
  • Cap retries at 3 and surface the error after that
  • Use exponential backoff (1s → 2s → 4s) between attempts
  • Always show the user what's happening — silent retries feel like lag
Result
  • Transient failures recover automatically without user intervention
  • Users see clear progress and know the app is working for them
  • After max retries, a manual retry button gives back control

#5 Structured Error Logging

Catching errors is only half the battle. If your logs are a wall of unstructured stack traces, you'll never find the signal in the noise. Production error handling needs structured, searchable, actionable logs.

Error Classification

Not all errors are equal. Classify them to route alerts correctly:

lib/errors.ts
export enum ErrorSeverity { LOW = 'low', // UI glitch, non-critical feature degraded MEDIUM = 'medium', // Feature broken but workaround exists HIGH = 'high', // Core feature broken, users affected CRITICAL = 'critical', // App unusable, data loss possible } export interface StructuredError { message: string; code: string; severity: ErrorSeverity; context: Record<string, unknown>; userId?: string; timestamp: number; digest?: string; // Next.js error digest for correlation } export function createError( message: string, code: string, severity: ErrorSeverity, context: Record<string, unknown> = {} ): StructuredError { return { message, code, severity, context, timestamp: Date.now(), }; }

Centralized Error Reporter

lib/report-error.ts
import { type StructuredError, ErrorSeverity } from './errors'; export async function reportError(error: StructuredError) { // Always log structured data — never raw Error objects console.error(JSON.stringify({ level: 'error', ...error, })); // Send to your error tracking service if (process.env.NODE_ENV === 'production') { await fetch('/api/errors', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(error), // Fire-and-forget — don't let error reporting fail the request }).catch(() => {}); } // Alert immediately for critical errors if (error.severity === ErrorSeverity.CRITICAL) { // Trigger PagerDuty, Slack alert, etc. } }

Logging Best Practices

  • Always include: error code, severity, user context, and timestamp
  • Use the Next.js error digest to correlate client-side errors with server logs
  • Never log sensitive data (passwords, tokens, PII)
  • Group related errors — 1000 instances of the same error should be 1 alert, not 1000
  • Set up severity-based routing: LOW → log only, HIGH → Slack, CRITICAL → PagerDuty
Result
  • Errors are structured, searchable, and actionable
  • Severity-based routing prevents alert fatigue
  • Error digests correlate client and server failures

The Complete Error Architecture

Putting it all together, here's how errors flow through a well-architected Next.js 16 application:

  • Server Component throws → caught by nearest error.tsx → logged with digest → user sees contextual recovery UI
  • Server Action fails (expected) → returns ActionResult with error → displayed inline in the form
  • Server Action fails (unexpected) → throws → caught by error.tsx → logged as HIGH severity
  • Client Component throws → caught by Error Boundary → partial page remains functional
  • Network request fails → auto-retried with backoff → surfaced to user after max retries
  • All errors → classified by severity → routed to appropriate alerting channel

Anti-Patterns to Avoid

  • Swallowing errors silently (empty catch blocks) — the worst sin in production code
  • Showing raw error messages to users — 'TypeError: Cannot read property of undefined' helps nobody
  • Putting a single error boundary at the root — one failure takes down the entire page
  • Retrying non-idempotent mutations — you might charge a customer twice
  • Logging without context — a stack trace without user ID, request path, or timestamp is useless
  • Alert fatigue — if everything is critical, nothing is critical

Key Takeaways

  • 1Layer Error Boundaries: global → feature → widget. Catch at the most specific level.
  • 2Use the Result pattern for Server Actions. Only throw for truly unexpected failures.
  • 3Wrap Suspense boundaries with Error Boundaries for graceful streaming error recovery.
  • 4Auto-retry transient failures with exponential backoff — but never retry 4xx errors.
  • 5Classify errors by severity and route alerts accordingly. Structure your logs.
  • 6Always give users a path to recovery — 'Try Again' is the minimum, context is better.

Related Reading