Error handling in Next.js App Router works through a file-based convention — specific files at specific locations in your route structure automatically catch and handle errors at different levels. Understanding the hierarchy prevents the most common error handling gaps.
Here's the complete error handling system, including the patterns I use for user-facing tools like the free image generation tool for teachers where error UX directly affects how much users trust the product.
The Error Handling File Hierarchy
Next.js App Router provides four files for error handling:
app/
├── error.tsx ← Catches errors in route segments
├── not-found.tsx ← Handles 404s (notFound() calls)
├── global-error.tsx ← Catches errors in root layout
└── loading.tsx ← Loading UI (not error, but related)
Each serves a different purpose and operates at a different level of the rendering tree.
error.tsx — The Main Error Boundary
error.tsx wraps a route segment in a React Error Boundary. When any component in that segment throws, error.tsx catches it and renders instead.
// app/dashboard/error.tsx
'use client'; // error.tsx must be a Client Component
import { useEffect } from 'react';
type ErrorProps = {
error: Error & { digest?: string };
reset: () => void;
};
export default function DashboardError({ error, reset }: ErrorProps) {
useEffect(() => {
// Log to your error reporting service
console.error('Dashboard error:', error);
}, [error]);
return (
<div className="flex flex-col items-center justify-center min-h-[400px] gap-4">
<div className="text-center">
<h2 className="text-xl font-semibold text-neutral-800">
Something went wrong
</h2>
<p className="text-neutral-500 mt-1 text-sm">
{error.digest
? `Error ID: ${error.digest}`
: 'An unexpected error occurred'}
</p>
</div>
<button
onClick={reset}
className="px-4 py-2 bg-orange-500 text-white rounded-full text-sm"
>
Try again
</button>
</div>
);
}
Key points:
- Must be a Client Component (
'use client') - Receives
errorobject andresetfunction -
reset()re-renders the segment, giving the user a retry path -
error.digestis a hash Next.js generates for server-side errors — matches the log entry in your server logs
Nesting error.tsx Files
error.tsx only catches errors in its sibling directory and below. You can nest them:
app/
├── error.tsx ← Catches errors in all routes
├── dashboard/
│ ├── error.tsx ← Catches errors in /dashboard routes
│ └── settings/
│ └── error.tsx ← Catches errors in /dashboard/settings
The most specific error.tsx catches first. Errors bubble up if no closer handler exists.
This allows granular error UI: /dashboard/settings might show an inline error with specific settings-context help, while a top-level error shows a generic fallback.
not-found.tsx — Handling Missing Resources
not-found.tsx renders when notFound() is called from a Server Component. This is for expected 404s — resource doesn't exist, ID not found in database.
// 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(); // Triggers not-found.tsx
}
return <Article post={post} />;
}
// app/blog/not-found.tsx
import Link from 'next/link';
export default function BlogNotFound() {
return (
<div className="flex flex-col items-center justify-center min-h-[400px] gap-4 text-center">
<h1 className="text-2xl font-semibold">Post not found</h1>
<p className="text-neutral-500">
This post may have been moved or removed.
</p>
<Link
href="/blog"
className="px-4 py-2 bg-neutral-900 text-white rounded-full text-sm"
>
Browse all posts
</Link>
</div>
);
}
not-found.tsx can be Server or Client — unlike error.tsx. It doesn't receive props.
global-error.tsx — Catching Root Layout Errors
error.tsx doesn't catch errors thrown in the root layout.tsx — the error boundary is siblings with the layout, not wrapping it. global-error.tsx handles this case.
// app/global-error.tsx
'use client';
export default function GlobalError({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
return (
// global-error replaces the root layout entirely
// Must include html and body tags
<html>
<body>
<div style={{
minHeight: '100vh',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
fontFamily: 'system-ui, sans-serif'
}}>
<h2>Something went wrong</h2>
<button onClick={reset}>Try again</button>
</div>
</body>
</html>
);
}
global-error.tsx replaces the entire page — including the root layout — so it must include <html> and <body> tags. It only fires in production (not in development, where the error overlay shows instead).
Error Reporting Integration
The error.tsx useEffect is where you connect to error reporting services:
'use client';
import { useEffect } from 'react';
export default function Error({ error, reset }: ErrorProps) {
useEffect(() => {
// Sentry
Sentry.captureException(error);
// Or any other error reporting service
reportError({
message: error.message,
digest: error.digest,
stack: error.stack,
});
}, [error]);
return (/* ... */);
}
The error.digest is critical for server-side errors: the actual error message is not sent to the client (for security), but the digest lets you match the client error to the full server-side error in your logs.
Typed Error Responses
For API routes, return typed error responses rather than throwing:
// app/api/generate/route.ts
export async function POST(request: Request) {
try {
const data = await processRequest(request);
return Response.json({ success: true, data });
} catch (error) {
// Return structured error, don't throw
return Response.json(
{
success: false,
error: 'Generation failed',
code: 'GENERATION_ERROR'
},
{ status: 500 }
);
}
}
Client-side, check response.ok before using data:
const response = await fetch('/api/generate', { method: 'POST', body });
if (!response.ok) {
const error = await response.json();
throw new Error(error.error ?? 'Request failed');
}
const data = await response.json();
The Common Mistake
The most common error handling mistake: not providing a reset path. An error component that just shows "something went wrong" with no way forward forces users to refresh the entire page, which loses their context and feels like the app broke.
Always provide: a clear error message, what to try, and a reset() button that retries the failed segment. Where appropriate, also provide navigation away (home page link, search) in case retry fails too.
Testing Error Boundaries
Testing that error boundaries actually catch errors requires deliberately throwing:
// components/ErrorThrower.tsx (test utility)
'use client';
import { useState } from 'react';
export function ErrorThrower() {
const [shouldThrow, setShouldThrow] = useState(false);
if (shouldThrow) {
throw new Error('Test error from ErrorThrower');
}
return (
<button onClick={() => setShouldThrow(true)}>
Trigger test error
</button>
);
}
Place this in a route surrounded by your error.tsx to verify the error boundary renders correctly. Remove before production.
For automated testing of error states, React Testing Library's console.error mocking prevents test pollution from expected error outputs:
test('error boundary shows error UI', () => {
const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
render(<ComponentThatThrows />);
expect(screen.getByText('Something went wrong')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Try again' })).toBeInTheDocument();
consoleSpy.mockRestore();
});
Summary
The App Router error handling hierarchy: error.tsx for route-segment errors, not-found.tsx for missing resources via notFound(), global-error.tsx for root layout errors. Nest error.tsx at the granularity you need — more specific error UI for more specific routes.
Always include a reset() retry path. Always log errors in useEffect. Use error.digest to correlate client errors with server logs.
Quick Reference
| File | Catches | Must be client? | Receives props? |
|---|---|---|---|
error.tsx |
Segment errors | Yes |
error, reset
|
not-found.tsx |
notFound() calls |
No | None |
global-error.tsx |
Root layout errors | Yes |
error, reset
|
Place each file as a sibling to the page.tsx you want it to protect. global-error.tsx goes directly in the app/ directory.
Top comments (0)