A single uncaught JavaScript error can turn a fully functional page into a white screen. Error boundaries are React's defense against this, but most teams stop at the bare minimum: one boundary wrapping the entire app with a "Something went wrong" fallback. That is the error boundary equivalent of pulling the fire alarm for a burnt piece of toast.
After shipping high-traffic Next.js applications at WEIRDSOFT, we have developed a comprehensive error boundary strategy that covers placement, recovery patterns, React 19's new concurrency features, Server Component error handling, testing, and integration with error reporting services. This post covers the full spectrum.
Error Boundary Placement Strategy
The granularity of your error boundaries determines how resilient your application feels. A single app-wide boundary creates an all-or-nothing experience. Too many boundaries add complexity with diminishing returns. The sweet spot is contextual: wrap each independently useful feature or section.
The Layout-Aisle-Feature Model
We use a three-tier placement strategy inspired by the physical layout of an application:
Layout boundaries wrap persistent chrome — navigation, sidebars, footers. If the sidebar crashes, the main content should remain interactive. If the header crashes, the rest of the page should still render. These boundaries ensure that a failure in shared UI does not take down the entire experience.
// app/layout.tsx — Each major section gets its own boundary
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html>
<body>
<ErrorBoundary fallback={<SidebarFallback />} name="Sidebar">
<Sidebar />
</ErrorBoundary>
<main>
<ErrorBoundary fallback={<PageFallback />} name="Page Content">
{children}
</ErrorBoundary>
</main>
<ErrorBoundary fallback={null} name="Footer">
<Footer />
</ErrorBoundary>
</body>
</html>
);
}
Aisle boundaries wrap groups of related features — a dashboard section, a product detail page's information panel. If the "Related Products" carousel crashes, the product description, reviews, and purchase button should all remain functional.
// app/products/[id]/page.tsx
export default function ProductPage() {
return (
<div className="grid grid-cols-3 gap-8">
<div className="col-span-2">
<ErrorBoundary fallback={<ImageGalleryFallback />}>
<ProductImages />
</ErrorBoundary>
<ErrorBoundary fallback={<p>Description unavailable</p>}>
<ProductDescription />
</ErrorBoundary>
</div>
<aside>
<ErrorBoundary fallback={<PurchaseBarFallback />}>
<PurchaseBar />
</ErrorBoundary>
</aside>
</div>
);
}
Feature boundaries wrap individual widgets — a comments section, a search autocomplete, a chart. These are the most granular and most forgiving. A crashing chart widget should not affect the rest of the analytics dashboard.
<ErrorBoundary
fallback={<p className="text-red-500 text-sm">Comments temporarily unavailable</p>}
name="Comments"
>
<CommentSection postId={post.id} />
</ErrorBoundary>
Boundary Naming Convention
Every boundary gets a name prop that is forwarded to your error reporting service. This is critical for triage — you can see at a glance that "Comments" boundary is catching 200 errors while "PurchaseBar" has caught zero, telling you something is wrong with the comments feature specifically:
interface ErrorBoundaryProps {
children: React.ReactNode;
fallback: React.ReactNode | ((error: Error, retry: () => void) => React.ReactNode);
name: string;
onError?: (error: Error, errorInfo: React.ErrorInfo) => void;
}
Building a Production-Grade Error Boundary
React's class component API for error boundaries has not changed in React 19, but how we compose and use them has evolved significantly. Here is our production boundary:
// components/error-boundary.tsx
import React from 'react';
interface ErrorBoundaryState {
error: Error | null;
attempt: number;
}
interface ErrorBoundaryProps {
children: React.ReactNode;
fallback: React.ReactNode | ((error: Error, retry: () => void) => React.ReactNode);
name: string;
onError?: (error: Error, errorInfo: React.ErrorInfo) => void;
resetKeys?: unknown[];
maxRetries?: number;
}
export class ProductionErrorBoundary extends React.Component<
ErrorBoundaryProps,
ErrorBoundaryState
> {
constructor(props: ErrorBoundaryProps) {
super(props);
this.state = { error: null, attempt: 0 };
}
static getDerivedStateFromError(error: Error): Partial<ErrorBoundaryState> {
return { error };
}
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
// Send to error reporting service
this.props.onError?.(error, errorInfo);
// Always log to console in development
if (process.env.NODE_ENV === 'development') {
console.group(`[ErrorBoundary: ${this.props.name}]`);
console.error('Error:', error);
console.error('Component Stack:', errorInfo.componentStack);
console.groupEnd();
}
// Report to production error service
reportErrorToService({
error: {
message: error.message,
stack: error.stack,
name: error.name,
},
boundary: this.props.name,
componentStack: errorInfo.componentStack,
attempt: this.state.attempt,
url: typeof window !== 'undefined' ? window.location.href : undefined,
});
}
componentDidUpdate(prevProps: ErrorBoundaryProps) {
// Reset boundary when resetKeys change
if (this.state.error && this.props.resetKeys) {
const prevKey = JSON.stringify(prevProps.resetKeys);
const currentKey = JSON.stringify(this.props.resetKeys);
if (prevKey !== currentKey) {
this.reset();
}
}
}
reset = () => {
this.setState((prev) => ({
error: null,
attempt: prev.attempt + 1,
}));
};
render() {
if (this.state.error) {
if (typeof this.props.fallback === 'function') {
const canRetry = this.props.maxRetries
? this.state.attempt < this.props.maxRetries
: true;
return this.props.fallback(this.state.error, canRetry ? this.reset : undefined);
}
return this.props.fallback;
}
return this.props.children;
}
}
Key Design Decisions
- reset() with attempt tracking: Each reset increments
attempt. Combined withmaxRetries, this prevents infinite retry loops when a component is fundamentally broken rather than temporarily failed. - resetKeys pattern: Mirrors React's
keyprop for controlled resets. When the parent changes the data being rendered (e.g., a different post ID), the boundary automatically resets rather than showing stale error state. - Fallback as render prop: Passing a function as
fallbackgives the error access to the retry function. This enables richer fallback UIs with inline retry buttons.
Recovery Patterns That Feel Intentional
The difference between amateur and professional error handling is how the recovery experience feels. Users tolerate errors when the system handles them gracefully and offers a clear path forward.
Pattern 1: Retry with Exponential Backoff
For transient failures (network timeouts, rate limiting), automatic retry with backoff is better than forcing the user to click a button:
function RetryOnError({ children, maxRetries = 3 }: { children: React.ReactNode; maxRetries?: number }) {
return (
<ProductionErrorBoundary
name="AutoRetry"
maxRetries={maxRetries}
fallback={(error, retry) => (
<div className="flex flex-col items-center gap-4 p-8">
<p className="text-gray-500">This section failed to load</p>
<button
onClick={retry}
className="rounded-md bg-brand-primary px-4 py-2 text-white"
>
Try again
</button>
</div>
)}
>
{children}
</ProductionErrorBoundary>
);
}
For automatic retry, pair the boundary with a useEffect that calls retry after a delay:
function AutoRetryFallback({ error, retry }: { error: Error; retry?: () => void }) {
const [countdown, setCountdown] = useState(5);
useEffect(() => {
if (!retry) return;
const timer = setInterval(() => {
setCountdown((c) => {
if (c <= 1) {
clearInterval(timer);
retry();
return 0;
}
return c - 1;
});
}, 1000);
return () => clearInterval(timer);
}, [retry]);
return (
<p className="text-gray-400">
Connection lost. Retrying in {countdown} seconds...
</p>
);
}
Pattern 2: Fallback Data from Cache
When data fetching fails, show stale cached data rather than an error message. This is especially powerful with React 19's use(promise) combined with a client-side cache:
function CachedDataFallback({ error, retry, cacheKey }: {
error: Error;
retry?: () => void;
cacheKey: string;
}) {
const cachedData = readFromLocalCache(cacheKey);
if (cachedData) {
return (
<div className="opacity-60">
{cachedData}
<p className="text-sm text-amber-600">
Showing cached data. <button onClick={retry}>Refresh</button>
</p>
</div>
);
}
return (
<div className="flex flex-col items-center gap-4 p-8">
<p>Failed to load data</p>
{retry && <button onClick={retry}>Retry</button>}
</div>
);
}
Pattern 3: Partial Degradation
The most elegant recovery pattern is to degrade only the specific feature that failed. This is where granular boundary placement pays off. Instead of replacing the entire right sidebar with an error, you replace the single widget that crashed:
// Before: coarse boundary
<ErrorBoundary name="Sidebar" fallback={<SidebarError />}>
<Sidebar>
<UserProfile />
<RecentActivity />
<TrendingTopics />
<SponsoredContent />
</Sidebar>
</ErrorBoundary>
// After: granular boundaries within the sidebar
<Sidebar>
<ErrorBoundary name="UserProfile" fallback={<LoginPrompt />}>
<UserProfile />
</ErrorBoundary>
<ErrorBoundary name="RecentActivity" fallback={null}>
<RecentActivity />
</ErrorBoundary>
<ErrorBoundary name="TrendingTopics" fallback={<p>Trending unavailable</p>}>
<TrendingTopics />
</ErrorBoundary>
<ErrorBoundary name="SponsoredContent" fallback={null}>
<SponsoredContent />
</ErrorBoundary>
</Sidebar>
Note the different fallback strategies: UserProfile gets a prominent CTA (log in), TrendingTopics gets a muted text fallback, and RecentActivity and SponsoredContent hide entirely because their absence does not harm the UX.
React 19 Integration: use(promise) and Async Error Catching
React 19 introduces use as a new API for reading resources during render. When used with promises, use integrates deeply with Suspense and error boundaries:
import { use, Suspense } from 'react';
function Comments({ commentsPromise }: { commentsPromise: Promise<Comment[]> }) {
const comments = use(commentsPromise);
return <CommentList comments={comments} />;
}
export function PostPage({ postId }: { postId: string }) {
const commentsPromise = fetchComments(postId);
return (
<ErrorBoundary
name="Comments"
fallback={(error, retry) => (
<div className="rounded-md border border-red-200 bg-red-50 p-4">
<p className="text-red-800">Failed to load comments</p>
<button onClick={retry} className="text-red-600 underline">
Try again
</button>
</div>
)}
>
<Suspense fallback={<CommentSkeleton />}>
<Comments commentsPromise={commentsPromise} />
</Suspense>
</ErrorBoundary>
);
}
When the promise rejects, it throws and is caught by the nearest error boundary. The Suspense boundary handles the pending state; the error boundary handles the rejected state. This clean separation means you never need to manage loading and error states manually in the component.
Why This Pattern Matters
Prior to React 19, you needed useEffect with manual loading/error state management or a data fetching library to achieve the same result:
// Pre-React 19 approach
function CommentsLegacy({ postId }: { postId: string }) {
const [comments, setComments] = useState<Comment[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
setIsLoading(true);
setError(null);
fetchComments(postId)
.then(setComments)
.catch(setError)
.finally(() => setIsLoading(false));
}, [postId]);
if (isLoading) return <CommentSkeleton />;
if (error) throw error; // Caught by parent ErrorBoundary
return <CommentList comments={comments} />;
}
The use(promise) approach eliminates all three state variables and the useEffect. The error boundary integration is inherent rather than bolted on.
Catching Async Errors Outside of Render
Not all async errors happen during render. Event handlers, useEffect cleanup, and timeouts all need error handling. For these, error boundaries are not sufficient — you need manual error catching combined with telemetry:
function useAsyncErrorCatcher() {
const [, setError] = useState<Error | null>(null);
const catchAsync = useCallback(async <T,>(promise: Promise<T>): Promise<T | undefined> => {
try {
return await promise;
} catch (error) {
// This triggers a re-render that the error boundary can catch
setError(() => {
throw error;
});
}
}, []);
return { catchAsync };
}
// Usage in an event handler
function SubmitButton() {
const { catchAsync } = useAsyncErrorCatcher();
const handleSubmit = async () => {
await catchAsync(submitForm());
};
return <button onClick={handleSubmit}>Submit</button>;
}
This pattern works because React will re-render after setError, and during that render, throw error propagates to the nearest error boundary. It is a hack, but it is a reliable one until React provides a first-class API for this.
Server Component Error Handling
Server Components in Next.js App Router have their own error propagation model. Errors in Server Components cannot be caught by client-side error boundaries directly — they bubble up through the server rendering pipeline.
error.tsx Conventions
Next.js provides error.tsx as the Server Component equivalent of an error boundary:
// app/products/[id]/error.tsx
'use client';
export default function ProductError({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
useEffect(() => {
// Report to error service
reportError({
error,
context: 'ProductPage',
digest: error.digest,
});
}, [error]);
return (
<div className="flex flex-col items-center justify-center min-h-[400px] gap-4">
<h2 className="text-xl font-semibold">Product unavailable</h2>
<p className="text-gray-500">
This product page encountered an error. Our team has been notified.
</p>
<button
onClick={reset}
className="rounded-md bg-brand-primary px-4 py-2 text-white"
>
Try again
</button>
</div>
);
}
Key points about error.tsx:
- It must be a Client Component (
'use client') - It receives
errorandresetprops automatically reset()re-renders the segment, not the whole pageerror.digestis a server-generated hash for correlating errors in logs- It catches errors from Server Components, Client Components, and data fetching in that segment
Error Boundary Hierarchy in App Router
Next.js 14+ supports nested error boundaries via the filesystem:
app/
error.tsx ← App-wide error boundary
products/
error.tsx ← /products/* error boundary
[id]/
error.tsx ← /products/[id] error boundary
page.tsx
Errors propagate up the tree. If products/[id]/error.tsx itself throws, products/error.tsx catches it. If that throws, the root error.tsx catches it. This hierarchy mirrors the layout nesting and gives you precise control over recovery at each level.
Global Not Found Page
For 404s specifically, Next.js provides not-found.tsx which pairs with notFound():
// app/products/[id]/page.tsx
import { notFound } from 'next/navigation';
async function ProductPage({ params }: { params: { id: string } }) {
const product = await getProduct(params.id);
if (!product) {
notFound(); // Renders the nearest not-found.tsx
}
return <ProductDetail product={product} />;
}
// app/products/[id]/not-found.tsx
export default function ProductNotFound() {
return (
<div className="flex flex-col items-center gap-4 py-20">
<h2 className="text-2xl font-bold">Product not found</h2>
<p>The product you are looking for does not exist or has been removed.</p>
<a href="/products" className="text-brand-primary underline">
Browse all products
</a>
</div>
);
}
Error Reporting Services Integration
Capturing the error is only half the battle. You need to send actionable data to your error reporting service so your team can diagnose and fix issues fast.
Structured Error Payload
Every error report should include structured context, not just a stack trace:
interface ErrorReportPayload {
error: {
message: string;
name: string;
stack?: string;
digest?: string;
};
boundary: string;
componentStack?: string;
metadata: {
url: string;
userAgent: string;
timestamp: string;
attempt: number;
buildId: string;
};
context?: Record<string, unknown>;
}
Integration with Sentry
// lib/error-reporting.ts
import * as Sentry from '@sentry/nextjs';
export function reportErrorToService(payload: Omit<ErrorReportPayload, 'metadata'>) {
const metadata = {
url: typeof window !== 'undefined' ? window.location.href : '',
userAgent: typeof navigator !== 'undefined' ? navigator.userAgent : '',
timestamp: new Date().toISOString(),
attempt: payload.attempt ?? 0,
buildId: process.env.NEXT_PUBLIC_BUILD_ID ?? 'unknown',
};
Sentry.withScope((scope) => {
scope.setTag('boundary', payload.boundary);
scope.setTag('attempt', String(payload.attempt ?? 0));
scope.setExtra('componentStack', payload.componentStack);
scope.setExtra('metadata', metadata);
if (payload.context) {
Object.entries(payload.context).forEach(([key, value]) => {
scope.setExtra(key, value);
});
}
Sentry.captureException(payload.error);
});
}
Breadcrumb Trail for Error Context
Before an error boundary catches a crash, instrument the app to leave breadcrumbs. This gives you a trail of user actions leading up to the error:
// lib/breadcrumbs.ts
type Breadcrumb = {
category: string;
message: string;
timestamp: number;
data?: Record<string, unknown>;
};
const MAX_BREADCRUMBS = 50;
const breadcrumbs: Breadcrumb[] = [];
export function leaveBreadcrumb(
category: string,
message: string,
data?: Record<string, unknown>
) {
breadcrumbs.push({ category, message, timestamp: Date.now(), data });
if (breadcrumbs.length > MAX_BREADCRUMBS) {
breadcrumbs.shift();
}
}
export function getBreadcrumbs(): Breadcrumb[] {
return [...breadcrumbs];
}
Then include breadcrumbs in your error report payload:
scope.setExtra('breadcrumbs', getBreadcrumbs());
Testing Error Boundaries
Error boundaries introduce alternate rendering paths that are easy to neglect. Every boundary should have tests verifying three states: normal render, error state, and recovery.
Unit Testing
// components/__tests__/error-boundary.test.tsx
import { render, screen, fireEvent } from '@testing-library/react';
import { ProductionErrorBoundary } from '../error-boundary';
// A component that throws on render
function BuggyComponent({ shouldThrow = false }: { shouldThrow?: boolean }) {
if (shouldThrow) {
throw new Error('Intentional test error');
}
return <div>Working component</div>;
}
describe('ProductionErrorBoundary', () => {
it('renders children when there is no error', () => {
render(
<ProductionErrorBoundary name="test" fallback={<div>Error</div>}>
<div>Content</div>
</ProductionErrorBoundary>
);
expect(screen.getByText('Content')).toBeInTheDocument();
expect(screen.queryByText('Error')).not.toBeInTheDocument();
});
it('renders fallback when child throws', () => {
// Suppress console.error from React for expected errors
jest.spyOn(console, 'error').mockImplementation(() => {});
render(
<ProductionErrorBoundary name="test" fallback={<div>Something went wrong</div>}>
<BuggyComponent shouldThrow />
</ProductionErrorBoundary>
);
expect(screen.getByText('Something went wrong')).toBeInTheDocument();
});
it('resets when resetKeys change', () => {
jest.spyOn(console, 'error').mockImplementation(() => {});
const { rerender } = render(
<ProductionErrorBoundary
name="test"
fallback={<div>Error state</div>}
resetKeys={['a']}
>
<BuggyComponent shouldThrow />
</ProductionErrorBoundary>
);
expect(screen.getByText('Error state')).toBeInTheDocument();
// Rerender with different resetKeys — boundary resets and child re-renders
rerender(
<ProductionErrorBoundary
name="test"
fallback={<div>Error state</div>}
resetKeys={['b']}
>
<BuggyComponent shouldThrow={false} />
</ProductionErrorBoundary>
);
expect(screen.getByText('Working component')).toBeInTheDocument();
});
it('calls onError when an error is caught', () => {
jest.spyOn(console, 'error').mockImplementation(() => {});
const onError = jest.fn();
render(
<ProductionErrorBoundary name="test" fallback={<div>Error</div>} onError={onError}>
<BuggyComponent shouldThrow />
</ProductionErrorBoundary>
);
expect(onError).toHaveBeenCalledTimes(1);
expect(onError).toHaveBeenCalledWith(
expect.objectContaining({ message: 'Intentional test error' }),
expect.any(Object)
);
});
});
E2E Testing Recovery Paths
Playwright tests should verify that error boundaries render correctly and that recovery flows work end-to-end:
// e2e/error-boundary.spec.ts
import { test, expect } from '@playwright/test';
test.describe('Error boundary recovery', () => {
test('shows fallback when data fetch fails and retry recovers', async ({ page }) => {
// Intercept the API call and make it fail
await page.route('**/api/comments**', (route) => {
route.abort('connectionfailed');
});
await page.goto('/post/1');
await expect(page.getByText('Comments section failed to load')).toBeVisible();
// Stop intercepting and retry
await page.unroute('**/api/comments**');
await page.getByRole('button', { name: 'Try again' }).click();
await expect(page.getByText('Comments')).toBeVisible();
});
});
Boundary Composition Patterns
Advanced applications benefit from composing multiple error boundaries together into resilient UI patterns.
The Sheltered Layout Pattern
Some sections are more critical than others. Compose boundaries to express this priority:
<ErrorBoundary name="PageShell" fallback={<FullPageError />}>
<Header />
<main>
<ErrorBoundary name="PrimaryContent" fallback={<ContentUnavailable />}>
<ArticleContent />
</ErrorBoundary>
<aside>
<ErrorBoundary name="SecondaryContent" fallback={null}>
<RelatedArticles />
<ErrorBoundary name="WidgetArea" fallback={<p>Widget unavailable</p>}>
<NewsletterSignup />
<TrendingTopics />
</ErrorBoundary>
</ErrorBoundary>
</aside>
</main>
<Footer />
</ErrorBoundary>
In this composition:
- A crash in
NewsletterSignupshows "Widget unavailable" butTrendingTopicsis never attempted to render (same boundary) - A crash in
RelatedArticleshides the entire aside silently (fallback={null}) - A crash in
ArticleContentshows "Content unavailable" but header and footer remain intact - A crash in anything still shows
FullPageErroras the last resort
The Data-Fetching Resilience Pattern
For data-dependent UIs, compose Suspense with error boundaries to handle both the pending and error states:
function AsyncWidget({ title, fetchFn, children }: AsyncWidgetProps) {
return (
<div className="widget">
<h3>{title}</h3>
<ErrorBoundary
name={title}
fallback={(error, retry) => (
<WidgetError message={error.message} onRetry={retry} />
)}
>
<Suspense fallback={<WidgetSkeleton />}>
{children}
</Suspense>
</ErrorBoundary>
</div>
);
}
This pattern ensures that every async section of your page independently handles its loading and error states, creating a page that progressively fills in rather than appearing all at once or crashing entirely.
Crash Gracefully, Not Silently
The goal of error boundaries is not to hide errors from users or developers. It is to contain damage, provide a path to recovery, and surface diagnostic information to the team that can fix the underlying cause.
A well-instrumented error boundary strategy means:
- Users see functional pages even when subsystems fail
- Recovery actions are one click away
- Every error is captured with enough context to debug
- Your team knows immediately when a new error class emerges
- Regressions are caught in CI before reaching production
Start with the Layout-Aisle-Feature model. Layer in recovery patterns based on the criticality of each feature. Instrument everything. Test every path. Your application — and your users — will be more resilient for it.