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.
'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>
);
}'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).
- 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.
export type ActionResult<T = void> =
| { success: true; data: T }
| { success: false; error: string; fieldErrors?: Record<string, string[]> };'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
'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
- 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.
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
'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;
}
}- 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
'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:
'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
- 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:
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
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
- 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.
