In distributed web applications, failures are inevitable: microservice network timeouts, database connection pool exhaustion, invalid client payloads, or queries against nonexistent records (404s). The absence of a systematic error-handling architecture causes catastrophic failures: entire screens crash to blank states ("white screen of death"), raw internal server stack traces leak into client DOM trees, and uncommitted user inputs are wiped out.
In classic React SPAs, localized errors were trapped using class-based React Error Boundaries. The Next.js App Router promotes this pattern to a core architectural standard, mapping error boundaries directly onto file-system routes. Dedicated error.tsx and global-error.tsx boundary files achieve Fault Isolation: an unexpected exception in an analytics dashboard widget leaves the surrounding layout, sidebar, and primary navigation completely interactive.
In Next.js 15+/16 and React 19, failure management is bifurcated into two distinct classes:
useActionState.In Next.js App Router, specialized route files assemble an explicit nesting hierarchy. Each directory level wraps its child components from the outside in:
┌─────────────────────────────────────────────────────────┐
│ app/global-error.tsx │
│ (Catches crashes inside the root app/layout.tsx) │
│ Mandatory <html> and <body> tags │
└────────────────────────────┬────────────────────────────┘
│ wraps
┌────────────────────────────▼────────────────────────────┐
│ app/layout.tsx │
│ (Root server layout application shell) │
└────────────────────────────┬────────────────────────────┘
│ wraps
┌────────────────────────────▼────────────────────────────┐
│ app/error.tsx │
│ (Client-side React Error Boundary layer) │
└────────────────────────────┬────────────────────────────┘
│ wraps
┌────────────────────────────▼────────────────────────────┐
│ app/not-found.tsx │
│ (Triggered whenever notFound() is invoked) │
└────────────────────────────┬────────────────────────────┘
│ wraps
┌────────────────────────────▼────────────────────────────┐
│ app/loading.tsx │
│ (Suspense Fallback for streaming SSR chunks) │
└────────────────────────────┬────────────────────────────┘
│ wraps
┌────────────────────────────▼────────────────────────────┐
│ app/page.tsx │
│ (Server Component page with domain business logic)│
└─────────────────────────────────────────────────────────┘A fundamental architectural rule applies: an error.tsx boundary intercepts exceptions thrown only in its nested children (page.tsx and descendant route segments), but CANNOT catch an error thrown inside the layout.tsx of its own level. To catch errors thrown in a specific layout.tsx, the error.tsx file must reside in its parent directory.
To catch fatal errors thrown in the outermost root layout (app/layout.tsx), Next.js provides the ultimate failsafe gateway: global-error.tsx.
An unhandled exception occurs inside a Server Component, data fetch, or Server Action.
Next.js calculates a cryptographic digest, logs the full trace to server stdout, and scrubs credentials.
The RSC Flight Protocol streams the sanitized chunk containing the error digest to the client browser.
The nearest error.tsx boundary in the route hierarchy catches the chunk and mounts its fallback UI.
The user triggers router.refresh() paired with reset(), invalidating cached RSC state and requesting fresh data.
An unhandled database query exception occurs inside app/(dashboard)/layout.tsx. The page crashes with a white screen, but the sibling file app/(dashboard)/error.tsx never activates. In the production browser console, the client only sees 'Digest: 18392019'. Why did error.tsx fail to trigger, and what does the Digest represent?
error.tsx Boundary and Reliable Retry Architecture#An error.tsx boundary must be marked with 'use client'. It receives the thrown error object and a reset() callback. To prevent re-rendering stale, cached server error payloads, combine reset() with cache revalidation via router.refresh():
'use client';
import { useEffect, useTransition } from 'react';
import { useRouter } from 'next/navigation';
interface ErrorBoundaryProps {
error: Error & { digest?: string };
reset: () => void;
}
export default function DashboardErrorBoundary({ error, reset }: ErrorBoundaryProps) {
const router = useRouter();
const [isPending, startTransition] = useTransition();
useEffect(() => {
// Dispatch telemetry to client monitoring suites (Sentry / Datadog)
console.error('Captured dashboard segment error:', error);
}, [error]);
const handleRetry = () => {
startTransition(() => {
// 1. Invalidate client Router Cache and request fresh RSC Flight Payload
router.refresh();
// 2. Reset the React Error Boundary
reset();
});
};
return (
<div className="flex flex-col items-center justify-center p-8 rounded-xl border border-red-500/20 bg-red-950/10 text-center">
<div className="w-12 h-12 rounded-full bg-red-500/10 flex items-center justify-center mb-4 text-red-400 font-bold">
!
</div>
<h2 className="text-xl font-semibold text-text-primary mb-2">Unable to Load Data</h2>
<p className="text-sm text-text-secondary max-w-md mb-6">
A technical error occurred while generating this report. The engineering team has been notified.
</p>
{error.digest && (
<code className="text-xs text-text-muted bg-bg-surface px-3 py-1 rounded border border-border mb-6">
Error Reference: {error.digest}
</code>
)}
<button
onClick={handleRetry}
disabled={isPending}
className="px-5 py-2.5 rounded-lg bg-accent-violet text-white font-medium hover:bg-accent-violet/90 transition-colors disabled:opacity-50"
>
{isPending ? 'Refreshing data...' : 'Try Again'}
</button>
</div>
);
}global-error.tsx#global-error.tsx executes only if an error is thrown inside the root app/layout.tsx. Because the root layout unmounts during a crash, global-error.tsx must define its own <html> and <body> tags:
'use client';
interface GlobalErrorProps {
error: Error & { digest?: string };
reset: () => void;
}
export default function GlobalError({ error, reset }: GlobalErrorProps) {
return (
<html lang="en">
<body className="bg-bg-primary text-text-primary min-h-screen flex items-center justify-center p-6">
<div className="max-w-lg w-full bg-bg-surface border border-red-500/30 rounded-2xl p-8 shadow-2xl text-center">
<h1 className="text-2xl font-bold text-red-400 mb-3">Critical Application Failure</h1>
<p className="text-text-secondary text-sm mb-6">
The root platform infrastructure encountered an unrecoverable error.
</p>
{error.digest && (
<div className="text-xs font-mono text-text-muted bg-bg-primary p-2 rounded mb-6">
Digest: {error.digest}
</div>
)}
<button
onClick={() => reset()}
className="w-full py-3 bg-red-600 hover:bg-red-700 text-white rounded-xl font-medium transition-colors"
>
Reload Platform
</button>
</div>
</body>
</html>
);
}notFound() and not-found.tsx#Unlike error.tsx, not-found.tsx is a Server Component. Invoking notFound() terminates segment rendering and emits an HTTP 404 Not Found response header:
import { notFound } from 'next/navigation';
import { db } from '@/shared/lib/db';
interface UserPageProps {
params: Promise<{ id: string }>;
}
export default async function UserProfilePage({ params }: UserPageProps) {
const { id } = await params;
const user = await db.user.findUnique({ where: { id } });
if (!user) {
// Immediately halts execution and renders the nearest not-found.tsx with a 404 status code
notFound();
}
return (
<main className="p-6">
<h1 className="text-2xl font-bold">{user.name}</h1>
<p className="text-text-secondary">{user.email}</p>
</main>
);
}Throwing unhandled exceptions (throw new Error) for expected user mistakes is an anti-pattern. Server Actions should return strongly typed discriminated unions:
'use server';
import { z } from 'zod';
import { db } from '@/shared/lib/db';
import { revalidatePath } from 'next/cache';
const UpdateProfileSchema = z.object({
name: z.string().min(2, 'Name must be at least 2 characters').max(50),
bio: z.string().max(200, 'Bio cannot exceed 200 characters').optional(),
});
export type ActionState =
| { status: 'idle' }
| { status: 'success'; message: string }
| { status: 'error'; message: string; fieldErrors?: Record<string, string[]> };
export async function updateProfileAction(
_prevState: ActionState,
formData: FormData
): Promise<ActionState> {
const rawData = {
name: formData.get('name'),
bio: formData.get('bio'),
};
const validation = UpdateProfileSchema.safeParse(rawData);
if (!validation.success) {
return {
status: 'error',
message: 'Validation failed for submitted fields',
fieldErrors: validation.error.flatten().fieldErrors,
};
}
try {
await db.user.update({
where: { id: 'current-user-id' },
data: validation.data,
});
revalidatePath('/profile');
return { status: 'success', message: 'Profile updated successfully' };
} catch (err: unknown) {
const errorMsg = err instanceof Error ? err.message : 'Database mutation error';
console.error('[UpdateProfile DB Error]:', errorMsg);
return {
status: 'error',
message: 'Unable to save profile changes. Please try again later.',
};
}
}useActionState#React 19's useActionState orchestrates mutation state, field validation errors, and Progressive Enhancement:
'use client';
import { useActionState } from 'react';
import { updateProfileAction, type ActionState } from '../server/actions';
const initialState: ActionState = { status: 'idle' };
export function ProfileForm() {
const [state, formAction, isPending] = useActionState(updateProfileAction, initialState);
return (
<form action={formAction} className="flex flex-col gap-5 max-w-md">
{state.status === 'error' && (
<div className="p-3 text-sm text-red-400 bg-red-950/40 border border-red-800 rounded-lg">
{state.message}
</div>
)}
{state.status === 'success' && (
<div className="p-3 text-sm text-emerald-400 bg-emerald-950/40 border border-emerald-800 rounded-lg">
{state.message}
</div>
)}
<div className="flex flex-col gap-1.5">
<label htmlFor="name" className="text-sm font-medium text-text-secondary">Name</label>
<input
id="name"
name="name"
required
disabled={isPending}
className="px-3.5 py-2 rounded-lg bg-bg-surface border border-border focus:border-accent-violet outline-none"
/>
{state.status === 'error' && state.fieldErrors?.name && (
<p className="text-xs text-red-400">{state.fieldErrors.name[0]}</p>
)}
</div>
<button
type="submit"
disabled={isPending}
className="py-2.5 px-4 rounded-lg bg-accent-violet text-white font-medium hover:bg-accent-violet/90 transition-colors disabled:opacity-50"
>
{isPending ? 'Saving...' : 'Save Profile'}
</button>
</form>
);
}A developer authors a Server Action and wraps the entire logic in a try/catch block. Upon successful mutation, redirect('/dashboard') is called inside the try block. In production, form submission freezes, redirection fails, and the server logs 'Error: NEXT_REDIRECT'. What caused this failure?
Functions like redirect() and notFound() in Next.js operate by throwing internal exceptions (NEXT_REDIRECT, NEXT_NOT_FOUND). If you wrap your logic in a naive try { ... } catch (err) block, navigation exceptions are accidentally intercepted, freezing user transitions.
Solution: Invoke redirect() outside try/catch, or re-throw using isRedirectError:
import { redirect } from 'next/navigation';
import { isRedirectError } from 'next/dist/client/components/redirect-error';
try {
await performMutation();
redirect('/dashboard');
} catch (error) {
if (isRedirectError(error)) {
throw error; // Re-throw to allow Next.js router to process redirection!
}
return { error: 'Operation failed' };
}Calling reset() alone only re-renders the client Error Boundary tree. If the exception originated during Server Component rendering (e.g. database timeout), the client simply re-renders the identical cached error Flight chunk and crashes again.
Solution: Always pair reset() with router.refresh() inside React's startTransition:
const router = useRouter();
function handleRetry() {
startTransition(() => {
router.refresh(); // Fetches fresh RSC Flight Payload from the server
reset(); // Resets the client Error Boundary
});
}global-error.tsx triggers only when an exception occurs inside root app/layout.tsx. In this scenario, the root layout unmounts completely. Returning a generic <div> produces an invalid HTML document without document scaffolding, causing styling breaks and blank screens.
global-error.tsx must render its own <html> and <body> tags.
In production, Next.js scrubs all unhandled exceptions thrown in Server Components or Actions for security, substituting them with a generic message and an opaque digest.
Never throw exceptions to communicate user validation errors! Always use the Result Pattern (returning structured { status: 'error', message: '...' } objects).