Error handling in the App Router
One of the biggest mistakes I see in production Next.js apps is letting a single component error crash the entire page. When an error bubbles up uncaught, users see a blank screen or a cryptic error message—and your whole app becomes unusable until they refresh. Error handling in the App Router gives you granular control: you can isolate failures to the component that broke, show a friendly fallback UI, and log what went wrong without taking down the entire application.
Over my 5+ years building platforms, I've learned that good error boundaries are the difference between a user experiencing a temporary hiccup and abandoning your app entirely. In this post, I'll walk you through the four pillars of Next.js error handling: segment-level error boundaries, custom 404 pages, root layout protection, and error logging.
error.tsx — A client component boundary per segment, with a reset function
The error.tsx file is your first line of defense. Every folder in your app directory can have one, and it acts as a React error boundary for that segment and all its children. The key insight: errors are isolated at the segment level, not the entire app.
Here's a real example from a ride-hailing platform I built. A driver's dashboard has a trips list, earnings chart, and ratings panel. If the chart component throws an error, I don't want the whole dashboard to crash—just the chart should show an error state.
// app/dashboard/trips/_components/trips-list.tsx
export async function TripsList() {
const trips = await fetchTrips();
// Simulating an error scenario
if (!trips) {
throw new Error('Failed to load trips');
}
return (
<div>
{trips.map((trip) => (
<div key={trip.id}>{trip.destination}</div>
))}
</div>
);
}Now, I create an error.tsx at the dashboard level to catch this:
// app/dashboard/error.tsx
'use client';
import { useEffect } from 'react';
export default function DashboardError({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
useEffect(() => {
// Log to Sentry or your error service
console.error('Dashboard error:', error);
}, [error]);
return (
<div style={{ padding: '2rem', textAlign: 'center' }}>
<h2>Something went wrong</h2>
<p>{error.message}</p>
<button onClick={() => reset()}>
Try again
</button>
</div>
);
}The reset function re-renders the segment from scratch. It doesn't reload the page—just that part of the app. This is crucial for UX: a user can fix their input and try again without losing their session.
Nested boundaries: You can have multiple error.tsx files at different levels. A chart component can have its own boundary, the dashboard can have another, and the root layout has its own. Errors propagate up until they hit a boundary.
// app/dashboard/earnings-chart/error.tsx
'use client';
export default function ChartError() {
return (
<div style={{ padding: '1rem', border: '1px solid red' }}>
<p>Chart unavailable. Please refresh.</p>
</div>
);
}In production, the earnings chart fails silently while the rest of the dashboard (trips list, ratings) remains functional. That's the power of segment-level error boundaries.
not-found.tsx — Custom 404 UI triggered by notFound()
A 404 isn't really an "error" in the traditional sense, but it's an error state you need to handle. Next.js's not-found.tsx file lets you render a custom 404 page per segment, and it's triggered by calling the notFound() function.
On an e-commerce platform I worked on, when a product doesn't exist, we want to show a friendly message instead of a generic 404:
// app/products/[id]/page.tsx
import { notFound } from 'next/navigation';
export default async function ProductPage({
params,
}: {
params: { id: string };
}) {
const product = await fetchProduct(params.id);
if (!product) {
notFound();
}
return (
<div>
<h1>{product.name}</h1>
<p>${product.price}</p>
</div>
);
}Then create a not-found.tsx in the same folder:
// app/products/[id]/not-found.tsx
import Link from 'next/link';
export default function ProductNotFound() {
return (
<div style={{ padding: '2rem', textAlign: 'center' }}>
<h2>Product not found</h2>
<p>The product you're looking for doesn't exist.</p>
<Link href="/products">Back to products</Link>
</div>
);
}Key difference from error.tsx: not-found.tsx returns a 404 status code, which search engines and analytics tools respect. Calling notFound() doesn't throw an error; it's a clean, intentional redirect to this UI. Use it for resource-not-found scenarios, not for actual exceptions.
global-error.tsx — Catches errors in the root layout itself
Here's a scenario that caught me off guard early in my Next.js journey: what if the error happens in your root layout? If your root layout throws, no other error.tsx will catch it because there's no segment above it.
That's where global-error.tsx comes in. It must be a client component and typically lives at the root of your app directory:
// app/global-error.tsx
'use client';
export default function GlobalError({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
return (
<html>
<body>
<h1>Critical Error</h1>
<p>{error.message}</p>
<button onClick={() => reset()}>Reload</button>
</body>
</html>
);
}Important: global-error.tsx requires you to render <html> and <body> tags because it replaces the entire document. This is typically a last-resort UI. On a CRM platform, if the root layout's database connection fails, we show this fallback so users know the system is down, not their browser.
In production, you rarely hit this unless your root layout logic is fragile. Keep your root layout lightweight: offload heavy logic to nested segments.
Log every error — Report to Sentry or your logger from the boundary
Catching errors is only half the battle. You need to know when they happen. I always wire up error logging from day one, and I strongly recommend you do the same.
Sentry is my go-to for production apps. Here's how I integrate it:
// lib/sentry.ts
import * as Sentry from "@sentry/nextjs";
export function initSentry() {
Sentry.init({
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
environment: process.env.NODE_ENV,
tracesSampleRate: 1.0,
});
}
export function captureException(error: Error, context?: Record<string, any>) {
Sentry.captureException(error, {
contexts: { custom: context },
});
}Then in your error.tsx:
// app/dashboard/error.tsx
'use client';
import { useEffect } from 'react';
import { captureException } from '@/lib/sentry';
export default function DashboardError({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
useEffect(() => {
captureException(error, {
segment: 'dashboard',
digest: error.digest,
});
}, [error]);
return (
<div>
<h2>Dashboard Error</h2>
<button onClick={reset}>Retry</button>
</div>
);
}The digest: Next.js gives each error a digest—a unique identifier that matches the server-side logs. Use this to correlate client and server errors in your logger. When a user reports "something broke," you can ask for the digest and instantly find the exact error in Sentry.
For simpler setups without Sentry, log to your own backend:
useEffect(() => {
fetch('/api/errors', {
method: 'POST',
body: JSON.stringify({
message: error.message,
stack: error.stack,
segment: 'dashboard',
timestamp: new Date(),
}),
});
}, [error]);Common mistakes
- Catching errors too high: A root-level error.tsx will catch everything, but you lose granularity. Instead, place error.tsx at the segment where the risk is—a chart component should have its own boundary, not just the page.
- Forgetting to call reset(): If your error.tsx doesn't provide a retry button, users are stuck. Always include a reset function or a link to navigate away.
- Not logging errors: An error boundary that doesn't log is useless in production. You'll never know what broke.
- Throwing in error.tsx itself: If your error boundary throws, it crashes the app. Keep error.tsx simple and safe.
- Using notFound() for actual errors:
notFound()is for missing resources (404), not runtime errors. Usethrow new Error()for exceptions and let error.tsx catch them.
Key takeaways
- Use
error.tsxat strategic segments to isolate failures and show fallback UI without crashing the whole app. - The
reset()function re-renders the segment—use it to let users retry after fixing input or waiting for a service to recover. - Call
notFound()for genuinely missing resources; it returns a 404 status and rendersnot-found.tsx. global-error.tsxis your safety net for root layout errors. Render<html>and<body>tags and keep it minimal.- Always log errors to Sentry, LogRocket, or your own backend. Use the error digest to correlate client and server logs.
- Place error boundaries close to the risk. A dashboard's earnings chart doesn't need to crash the trips list.






Comments
Loading comments…