Error Handling in Next.js App Router: A Production Guide
The App Router has a layered error handling system that most developers underuse. Here's how to build resilient Next.js applications that fail gracefully.
Nextcraft Engineering Team
The Error Handling Hierarchy
Next.js App Router has four distinct error handling mechanisms, each serving a different purpose:
error.tsx— catches errors in route segmentsnot-found.tsx— handles 404 statesglobal-error.tsx— catches errors in the root layoutmiddleware— intercepts requests before they reach pages
Understanding when each one fires — and what it can do — is the foundation of production-grade error handling.
error.tsx: The Workhorse
An error.tsx file in any route segment becomes the error boundary for that segment and its children. When a Server Component throws, or when a Client Component throws during render, control passes to the nearest error.tsx.
// app/dashboard/error.tsx
'use client'; // Error boundaries must be Client Components
import { useEffect } from 'react';
export default function DashboardError({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
useEffect(() => {
// Log to your error reporting service
reportError(error);
}, [error]);
return (
<div className="error-container">
<h2>Something went wrong loading your dashboard</h2>
<p className="text-sm text-gray-500">
Error ID: {error.digest}
</p>
<button onClick={reset}>Try again</button>
</div>
);
}
Key points:
error.tsxmust be a Client Component (React error boundaries are inherently client-side)- The
resetfunction re-renders the segment — useful for transient errors error.digestis a server-generated hash that maps to your server logs without exposing internal details to the client- Place
error.tsxfiles at different route levels to give users contextual error messages
not-found.tsx: Intentional 404s
notFound() is the correct way to return a 404 from a Server Component:
// app/blog/[slug]/page.tsx
import { notFound } from 'next/navigation';
export default async function BlogPost({ params }: { params: { slug: string } }) {
const post = await getPost(params.slug);
if (!post) {
notFound(); // Throws internally, caught by nearest not-found.tsx
}
return <Article post={post} />;
}
// app/blog/not-found.tsx
export default function PostNotFound() {
return (
<div>
<h1>Post not found</h1>
<p>This article may have been moved or deleted.</p>
<a href="/blog">Browse all posts</a>
</div>
);
}
Place not-found.tsx files in the same segments as your dynamic routes. The root app/not-found.tsx handles 404s that don't match any segment-specific file.
global-error.tsx: The Last Resort
global-error.tsx catches errors in your root app/layout.tsx. Because the layout has crashed, this component is responsible for rendering the entire page — including <html> and <body>:
// app/global-error.tsx
'use client';
export default function GlobalError({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
return (
<html>
<body>
<div className="min-h-screen flex items-center justify-center">
<div>
<h1>Something went seriously wrong</h1>
<button onClick={reset}>Reload application</button>
</div>
</div>
</body>
</html>
);
}
This should be treated as a nuclear option — the user experience here is already bad, so focus on clarity and recovery options.
Error Boundaries in Async Server Components
Server Component errors don't work like client-side errors — they're thrown during the server render and surfaced to the nearest error.tsx. But you can also handle them more granularly:
// Wrap individual data fetches in try/catch for partial failures
export default async function Dashboard() {
const [metrics, recentActivity] = await Promise.allSettled([
getMetrics(),
getRecentActivity(),
]);
return (
<div>
{metrics.status === 'fulfilled' ? (
<MetricsPanel data={metrics.value} />
) : (
<MetricsError />
)}
{recentActivity.status === 'fulfilled' ? (
<ActivityFeed data={recentActivity.value} />
) : (
<ActivityFeedError />
)}
</div>
);
}
Promise.allSettled instead of Promise.all lets partial page content succeed even when one data source fails — dramatically better UX than an error page for the whole dashboard.
Typed Errors for Better DX
Rather than catching generic Error objects, define typed error classes:
// lib/errors.ts
export class NotFoundError extends Error {
constructor(resource: string) {
super(`${resource} not found`);
this.name = 'NotFoundError';
}
}
export class UnauthorizedError extends Error {
constructor() {
super('Unauthorized');
this.name = 'UnauthorizedError';
}
}
export class ValidationError extends Error {
constructor(public fields: Record<string, string>) {
super('Validation failed');
this.name = 'ValidationError';
}
}
In your Server Actions and API routes:
export async function getProject(id: string) {
const project = await db.projects.findUnique({ where: { id } });
if (!project) throw new NotFoundError('Project');
if (!canAccess(currentUser, project)) throw new UnauthorizedError();
return project;
}
In error boundaries, check the error type to provide appropriate UI:
'use client';
import { NotFoundError, UnauthorizedError } from '@/lib/errors';
export default function ProjectError({ error }: { error: Error }) {
if (error instanceof UnauthorizedError) {
return <div>You don't have access to this project.</div>;
}
return <div>Failed to load project. <button>Retry</button></div>;
}
Logging Errors in Production
Client-side errors caught by error boundaries need to be logged to a monitoring service. The useEffect in an error boundary is the right place:
'use client';
import * as Sentry from '@sentry/nextjs';
export default function ErrorBoundary({ error }: { error: Error }) {
useEffect(() => {
Sentry.captureException(error, {
extra: { digest: error.digest },
});
}, [error]);
// ...
}
For Server Component errors, Next.js automatically includes them in server logs. If you're using Vercel, these appear in the Runtime Logs. Add structured logging with a library like pino for searchable, filterable error records.
The Production Error Checklist
-
error.tsxat every route segment that fetches data -
not-found.tsxfor every dynamic route -
global-error.tsxas a catch-all - Error logging wired to a monitoring service (Sentry, Datadog)
-
Promise.allSettledfor pages with multiple independent data sources - User-facing error messages that don't expose internal details
-
resetfunction wired to the retry button in all error boundaries - Error responses from Server Actions handled in the UI layer
Errors in production are inevitable. How your application handles them determines whether users recover gracefully or churn.
Continue reading
Related articles
Why Next.js App Router Is Better for SEO Than Pages Router
The App Router isn't just a new file-system convention — it fundamentally changes how search engines crawl and index your Next.js application.
EngineeringServer Components vs Client Components: Making the Right Call
The boundary between Server and Client Components is the most consequential architectural decision you make in a Next.js application. Here's how to draw it correctly.
EngineeringBuilding High-Performance Next.js Applications for Scale
A deep dive into how we utilize App Router and React Server Components to scale our client builds effectively.
Stay Informed.
Join 1,200+ founders and engineers receiving our monthly deep dives on product engineering, design, and growth.