For a while, every unhandled error in my Next.js apps meant one thing: the whole page went white.
One failed fetch, one bad database query, and the user got a blank screen or a generic Next.js error page — no matter how small the broken piece actually was.
The App Router gives you file-based error boundaries that fix this properly. Here's how I use them now.
1. error.tsx — Route-Level Error Boundaries
Any folder in app/ can have an error.tsx. It automatically wraps everything below it in an error boundary — no manual <ErrorBoundary> wrapping required:
// app/dashboard/error.tsx
'use client';
export default function DashboardError({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
return (
<div className="p-8 text-center">
<h2 className="text-lg font-semibold">Something went wrong</h2>
<p className="text-sm text-slate-500 mt-2">{error.message}</p>
<button
onClick={reset}
className="mt-4 px-4 py-2 rounded-lg bg-brand-500 text-white"
>
Try again
</button>
</div>
);
}
error.tsx must be a Client Component — it needs reset(), which re-renders the segment and attempts recovery. If /dashboard/analytics throws, only that segment shows this fallback. The rest of the dashboard — sidebar, nav, other pages — keeps working.
2. Nested Error Boundaries — Isolate the Blast Radius
This is the part people miss: error boundaries nest. Put one closer to the risky code, and a failure there won't take out the parent route at all.
app/
├── dashboard/
│ ├── layout.tsx
│ ├── error.tsx ← catches errors anywhere in dashboard
│ └── analytics/
│ ├── page.tsx
│ └── error.tsx ← catches errors only in analytics
// app/dashboard/analytics/error.tsx
'use client';
export default function AnalyticsError({ reset }: { reset: () => void }) {
return (
<div className="rounded-lg border border-red-500/20 p-4">
<p>Analytics failed to load.</p>
<button onClick={reset}>Retry</button>
</div>
);
}
If the analytics panel throws, the sidebar and the rest of the dashboard stay fully interactive — only the analytics section shows the fallback. Without this, one flaky third-party analytics API can take down a page that has nothing to do with it.
3. global-error.tsx — The Last Resort
For errors in the root layout itself — where even your normal error boundaries can't help, since they live inside that layout:
// app/global-error.tsx
'use client';
export default function GlobalError({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
return (
<html>
<body>
<div className="p-8 text-center">
<h1>Something went wrong</h1>
<button onClick={reset}>Try again</button>
</div>
</body>
</html>
);
}
This one replaces the entire root layout, including <html> and <body> — it's the only case where that's necessary, and it's the fallback of last resort, not something you reach for often.
4. not-found.tsx and notFound()
For missing data — a blog post that doesn't exist, a user ID that isn't in the database — that's not really an "error," it's a 404:
// app/blog/[slug]/page.tsx
import { notFound } from 'next/navigation';
export default async function BlogPost({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
const post = await getPost(slug);
if (!post) {
notFound();
}
return <article>{post.content}</article>;
}
// app/blog/[slug]/not-found.tsx
export default function PostNotFound() {
return (
<div className="text-center py-20">
<h2>Post not found</h2>
<p>The post you're looking for doesn't exist or was removed.</p>
</div>
);
}
notFound() isn't an error — it's a signal. It stops rendering and shows the nearest not-found.tsx, with a proper 404 status code, instead of you having to manually build that logic into every page that fetches by ID or slug.
5. loading.tsx — Instant Loading States for Free
Pair with error boundaries for the full picture. Any segment can have a loading.tsx, and Next.js shows it automatically while the async Server Component fetches:
// app/dashboard/analytics/loading.tsx
export default function AnalyticsLoading() {
return (
<div className="animate-pulse space-y-4">
<div className="h-8 w-48 bg-surface-700 rounded" />
<div className="h-64 bg-surface-700 rounded-lg" />
</div>
);
}
No useState, no manual isLoading flag — Next.js wraps the segment in Suspense for you and swaps loading.tsx in the instant navigation starts, then swaps the real content in once the data resolves.
6. try/catch Still Has a Job — Inside the Fetch, Not Around It
error.tsx catches what escapes the render. It won't catch a fetch that fails silently and returns undefined instead of throwing. Handle that at the source:
// lib/queries/posts.ts
export async function getPost(slug: string) {
try {
const res = await fetch(`https://api.example.com/posts/${slug}`);
if (!res.ok) return null;
return res.json();
} catch (err) {
console.error('Failed to fetch post:', err);
return null;
}
}
Returning null on failure and handling it explicitly (if (!post) notFound()) gives you control over whether a fetch failure means "404" or "error page" — throwing indiscriminately from inside a query helper takes that decision away from the caller.
7. Server Action Errors Don't Need error.tsx at All
Server Actions that return a typed result instead of throwing keep failures in the UI, not in an error boundary:
// actions/posts.ts
'use server';
export async function deletePost(id: string) {
try {
await db.posts.delete({ where: { id } });
revalidatePath('/dashboard/posts');
return { success: true };
} catch {
return { success: false, message: 'Could not delete post' };
}
}
A failed delete shouldn't send the user to a full error boundary — it should show an inline "couldn't delete that, try again" message right next to the button they clicked. Catch it, return it, render it.
Summary
| File / Pattern | Handles |
|---|---|
error.tsx |
Unexpected render/fetch errors in a route segment |
Nested error.tsx
|
Isolates failures to the smallest segment possible |
global-error.tsx |
Errors in the root layout itself (rare, last resort) |
not-found.tsx + notFound()
|
Missing data — not an error, a 404 |
loading.tsx |
Automatic loading UI while a segment fetches |
| try/catch in query helpers | Turning a failed fetch into a controlled null, not a crash |
| Typed return in Server Actions | Inline failure messages instead of a full error boundary |
The shift that matters most: stop thinking of "error handling" as one big try/catch around everything, and start thinking about which segment should fail without taking the rest of the page with it.
I use this exact error/loading/not-found setup across every route in my dashboards and templates.
See it in a real codebase:
Get the templates: https://pixelanas.gumroad.com
Do you lean on error.tsx or handle most failures with try/catch and typed returns? Drop it below 👇
Anas — full-stack Next.js developer building SaaS products and premium templates. X: @ASheikh69751
Top comments (0)