Error Handling in Next.js 16: The Complete 2026 Guide
Ever shipped a Next.js app, then watched one bad API response take down the entire page — header, sidebar, footer, everything — just because one component threw an error? You're not alone. It's one of the most common "gotchas" for developers moving from pages/ to the App Router.
The good news: Next.js 16 gives you a proper, file-based system for handling errors at exactly the level you want — a single component, a whole route segment, or the entire app. No more wrapping every component in a manual try/catch and hoping for the best.
By the end of this guide, you'll know exactly which error file to use and when, how to handle errors in Server Actions and Route Handlers the right way, and how to avoid the mistakes that leave users staring at a blank white screen in production.
| Situation | Use |
|---|---|
| Rendering error | error.tsx |
| Root layout crash | global-error.tsx |
| Missing resource | not-found.tsx |
| Unknown URL | global-not-found.tsx |
| Form submission | Server Action |
| API endpoint | Route Handler |
Why This Is Tricky
In the old Pages Router, error handling meant a single _error.js file for your whole app — crude, but simple. The App Router splits rendering into Server Components, Client Components, Server Actions, and Route Handlers, and each one fails differently:
- A Server Component can throw during data fetching
- A Client Component can throw during rendering
- A Server Action can fail during a form submission
- A Route Handler (API route) can fail while processing a request
Each of these needs its own handling strategy — that's why Next.js gives you multiple tools instead of one. Let's go through them one by one.
💡 Prerequisites: This guide uses Next.js 16 (App Router), TypeScript in strict mode, React Compiler (enabled by default, so no manual
useMemo/useCallbackneeded), and Tailwind CSS for styling. Node.js 20.9+ is required.
Step 1: Catch Rendering Errors with error.tsx
An error boundary is a component that catches errors happening below it in the tree and shows a fallback UI instead of crashing everything. Next.js builds this in automatically — you just add a file called error.tsx to any route segment (a folder inside app/).
// app/dashboard/error.tsx
"use client"; // error.tsx must always be a Client Component
import { useEffect } from "react";
interface ErrorPageProps {
error: Error & { digest?: string };
reset: () => void;
}
const DashboardError = ({ error, reset }: ErrorPageProps) => {
useEffect(() => {
// Send the error to your monitoring tool (Sentry, Datadog, etc.)
console.error("Dashboard error:", error);
}, [error]);
return (
<div className="flex flex-col items-center justify-center gap-4 p-10 text-center">
<h2 className="text-xl font-bold">Something went wrong</h2>
<p className="text-sm text-gray-500">
We couldn't load your dashboard. This has been logged.
</p>
<button
onClick={() => reset()}
className="rounded-md bg-black px-4 py-2 text-white"
>
Try again
</button>
</div>
);
};
export default DashboardError;
What just happened:
- Next.js automatically wraps the route segment in a React error boundary and renders this file when a rendering error occurs
erroris the thrown error object. In production, Next.js strips sensitive details and only sends a generic message plus adigest— a hash you can match against your server logsreset()tells React to try rendering the segment again — useful for transient errors like a flaky network request- The
useEffectis where you report the error to a monitoring service, sinceerror.tsxwon't automatically show up in your logging tool
⚠️ Common Mistake: Putting
error.tsxat the root ofapp/and expecting it to catch everything. It can't — see Step 2.
Scoping Errors to the Right Level
Place error.tsx inside the specific folder you want to protect. A crash in one section won't take down the rest of the app.
Think of error.tsx like nested try/catch blocks. Next.js always looks for the nearest boundary first, then continues bubbling upward until it finds one.
app/
dashboard/
layout.tsx ← sidebar stays visible even if a child crashes
page.tsx
error.tsx ← catches errors from all dashboard routes
analytics/
page.tsx
error.tsx ← catches errors only in /dashboard/analytics
settings/
page.tsx ← no error.tsx here, so errors bubble up to dashboard/error.tsx
If /dashboard/analytics crashes, only that section shows the fallback — the sidebar in layout.tsx keeps working. If /dashboard/settings crashes, since it has no error.tsx of its own, the error bubbles up to dashboard/error.tsx.
Related: Next.js Rendering Strategies Explained — including Streaming with Suspense
Step 2: Handle Root Layout Errors with global-error.tsx
There's one blind spot error.tsx can't cover: errors thrown inside the root layout itself (app/layout.tsx). Since the root layout wraps your entire app — including every error.tsx boundary — it needs its own separate mechanism.
// app/global-error.tsx
"use client";
interface GlobalErrorProps {
error: Error & { digest?: string };
reset: () => void;
}
const GlobalError = ({ error, reset }: GlobalErrorProps) => {
return (
<html>
<body>
<div style={{ textAlign: "center", padding: "3rem" }}>
<h2>Something went seriously wrong</h2>
<p>We've been notified and are looking into it.</p>
<button onClick={() => reset()}>Try again</button>
</div>
</body>
</html>
);
};
export default GlobalError;
What just happened: Because global-error.tsx replaces the entire root layout when it triggers, it has to define its own <html> and <body> tags — your normal fonts, providers, and navigation won't be available. Keep this file as simple and dependency-free as possible, since if the root layout crashed, you can't assume anything else in your app still works.
💡 Tip: For most apps, you'll rarely see
global-error.tsxfire — root layout crashes are uncommon. But always keep the file in place as a safety net so users never see a completely broken, unstyled page.
Step 3: Handle "Not Found" Cases
A 404 happens when a user hits a URL that doesn't exist, or when you explicitly know a resource is missing (like a product ID that doesn't exist in your database). Next.js 16 gives you two files for this.
not-found.tsx for a Specific Segment
// app/blog/[slug]/not-found.tsx
const PostNotFound = () => {
return (
<div className="p-10 text-center">
<h2 className="text-2xl font-bold">Post not found</h2>
<p className="text-gray-500">This blog post doesn't exist or was removed.</p>
</div>
);
};
export default PostNotFound;
Trigger it explicitly by calling the notFound() function anywhere in a Server Component:
// app/blog/[slug]/page.tsx
import { notFound } from "next/navigation";
import { getPostBySlug } from "@/lib/posts";
interface Props {
params: Promise<{ slug: string }>; // params is always a Promise in Next.js 16
}
const BlogPostPage = async ({ params }: Props) => {
const { slug } = await params; // must be awaited
const post = await getPostBySlug(slug);
if (!post) {
notFound(); // renders the nearest not-found.tsx
}
return <article>{post.content}</article>;
};
export default BlogPostPage;
What just happened: notFound() throws a special signal that Next.js catches internally and renders the closest not-found.tsx up the tree — similar to how error.tsx catches thrown errors, but specifically for missing content. It also correctly returns a 404 HTTP status, which matters for SEO.
global-not-found.tsx for Unmatched URLs (New in Next.js 16)
If someone visits a URL that doesn't match any route at all — a typo, an old bookmarked link — Next.js needs a catch-all 404 page for your whole app. In most apps, a root-level app/not-found.tsx already handles this. But if your app has multiple root layouts (like separate (admin) and (shop) groups) or a root layout built on a dynamic segment like app/[country]/layout.tsx, there's no single layout to render a 404 through — which is exactly the gap global-not-found.tsx closes.
First, enable it in your config:
// next.config.ts
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
experimental: {
globalNotFound: true,
},
};
export default nextConfig;
Then create the file at the root of app/:
// app/global-not-found.tsx
const GlobalNotFound = () => {
return (
<html>
<body>
<div style={{ textAlign: "center", padding: "3rem" }}>
<h1>404 — Page not found</h1>
<p>The page you're looking for doesn't exist.</p>
</div>
</body>
</html>
);
};
export default GlobalNotFound;
⚠️ Common Mistake: Like
global-error.tsx, this file renders outside your normal root layout, so it needs its own<html>and<body>tags too.
Step 4: Handle Errors in Server Actions
Server Actions are trickier than page rendering — a thrown error there doesn't show your error.tsx UI cleanly, it just rejects the promise. The production-safe pattern is to avoid throwing for expected failures such as validation errors, and instead return a typed result the client can check.
// actions/posts.ts
"use server";
import { z } from "zod";
import { db } from "@/lib/db";
import { updateTag } from "next/cache";
const CreatePostSchema = z.object({
title: z.string().min(1, "Title is required"),
body: z.string().min(1, "Body is required"),
});
export const createPost = async (_prev: unknown, formData: FormData) => {
const parsed = CreatePostSchema.safeParse({
title: formData.get("title"),
body: formData.get("body"),
});
if (!parsed.success) {
return { error: parsed.error.flatten().fieldErrors };
}
try {
const post = await db.post.create({ data: parsed.data });
updateTag("posts"); // instantly reflects the new post
return { data: post };
} catch (err) {
console.error("Failed to create post:", err);
return { error: { _form: ["Something went wrong. Please try again."] } };
}
};
Then consume it on the client with useActionState:
// app/posts/_new-post-client.tsx
"use client";
import { useActionState } from "react";
import { createPost } from "@/actions/posts";
export const NewPostForm = () => {
const [state, formAction, isPending] = useActionState(createPost, null);
return (
<form action={formAction} className="flex flex-col gap-3">
<input name="title" placeholder="Title" className="border p-2" />
{state?.error && "title" in state.error && (
<p className="text-sm text-red-500">{state.error.title?.[0]}</p>
)}
<textarea name="body" placeholder="Body" className="border p-2" />
<button disabled={isPending} className="rounded-md bg-black px-4 py-2 text-white">
{isPending ? "Publishing..." : "Publish"}
</button>
</form>
);
};
What just happened: safeParse validates the form data with Zod before it ever touches your database — this catches bad input early and cheaply. The try/catch around the database call handles unexpected failures (a dropped connection, a constraint violation) and turns them into a friendly { error } object instead of an unhandled crash. useActionState gives you state (the last returned value), formAction (to bind to the form), and isPending (to disable the button while submitting) — all without extra useState boilerplate.
💡 Tip: The rule of thumb: validation errors and expected failures → return
{ error }. Truly unexpected, unrecoverable errors → let them throw so Next.js's error boundary can catch them upstream.
Step 5: Handle Errors in Route Handlers (API Routes)
Route Handlers don't have an error boundary at all — if you don't catch an error yourself, the client just gets a generic 500 response with no useful detail.
// app/api/posts/route.ts
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { db } from "@/lib/db";
const PostSchema = z.object({
title: z.string().min(1),
body: z.string().min(1),
});
export const POST = async (req: NextRequest) => {
try {
const body: unknown = await req.json();
const parsed = PostSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{ error: parsed.error.flatten() },
{ status: 400 },
);
}
const post = await db.post.create({ data: parsed.data });
return NextResponse.json(post, { status: 201 });
} catch (err) {
console.error("POST /api/posts failed:", err);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 },
);
}
};
What just happened: Every branch returns a proper NextResponse with an accurate status code — 400 for bad input the client sent, 500 for something that broke on your end. Wrapping the whole handler body in try/catch means a database outage returns a clean JSON error instead of the request just hanging or crashing the server process.
💡 Tip: Reserve 4xx responses for problems caused by the client (validation, authentication, missing resources) and 5xx responses for unexpected server failures. This distinction makes your API easier to debug and integrates better with monitoring systems.
⚠️ Common Mistake: Returning
err.messagedirectly to the client in the 500 response. Internal error messages can leak details about your database or file structure — log the real error server-side, and send the client a generic message.
Frequently Asked Questions
Does error.tsx catch errors in event handlers, like an onClick?
No. Error boundaries only catch errors thrown during rendering — not inside event handlers, setTimeout callbacks, or async code running after the render finished. Wrap those in a regular try/catch inside the handler itself.
Can error.tsx be a Server Component?
No, it must always start with "use client". React error boundaries are a client-side concept, and Next.js enforces this at build time.
What's the actual difference between error.tsx and not-found.tsx?
error.tsx catches unexpected thrown errors — bugs, failed fetches, crashes. not-found.tsx is for the expected case of "this specific thing doesn't exist," triggered on purpose by calling notFound(). Different problem, different UI, different HTTP status code.
Do I need global-error.tsx if I already have error.tsx everywhere?
Yes, keep it. error.tsx files can't catch errors thrown in the root layout itself (things like a provider that crashes on init), since the root layout wraps every error.tsx boundary you have.
💡 Tip: Think of it like a fuse box —
error.tsxprotects individual circuits (routes), whileglobal-error.tsxis the master breaker for the whole house.
Should I use Sentry or a similar tool instead of just console.error?
For production apps, yes. console.error inside error.tsx is a starting point, but without a monitoring service you won't know an error happened until a user complains. Most teams call Sentry.captureException(error) (or an equivalent) inside the useEffect in error.tsx, and wrap Server Actions with the framework's server-action instrumentation to link client and server error traces together.
Wrapping Up
You now know how to scope error boundaries with error.tsx, catch root-layout crashes with global-error.tsx, handle missing content with not-found.tsx and the new global-not-found.tsx, and return safe, typed errors from Server Actions and Route Handlers instead of letting them crash silently. Put these in place before you need them — retrofitting error handling after a production incident is a much worse day than setting it up now.
Next, it's worth pairing this with proper loading states — Next.js Rendering Strategies Explained covers Suspense streaming so your loading and error boundaries work together for a fully resilient UI.
Continue Learning
- Route Handlers Explained in Next.js 16 (Beginner's Guide) — goes deeper into building typed API endpoints, the same ones you're wrapping in
try/catchin Step 5. - Next.js Rendering Strategies Explained: SSR, SSG, CSR, ISR & Cache Components (2026) — pairs naturally with error boundaries since Suspense streaming and error handling are designed to work together.
- Next.js 16 Routing Explained: A Complete Beginner's Guide (2026) — covers how
error.tsx,not-found.tsx, and other special files fit into the App Router's file-system conventions. - Zod vs Yup in 2026: Which Validation Library Should You Use? — if you want a deeper look at the
safeParsevalidation pattern used in the Server Actions example above.
More in Next.js Fundamentals
View AllRoute Handlers Explained in Next.js 16 (Beginner's Guide)
Learn Route Handlers in Next.js 16 App Router — the route.ts file convention, HTTP methods, dynamic params, caching with Cache Components, and building a real API.
Image Optimization in Next.js 16: The Complete 2026 Guide
Learn image optimization in Next.js 16 with next/image — remotePatterns, qualities, preload, AVIF/WebP, and real code examples for beginners.
Next.js 16 Routing Explained: A Complete Beginner's Guide (2026)
Learn Next.js 16 App Router routing from scratch — dynamic routes, nested layouts, route groups, parallel routes, intercepting routes, and catch-all segments with examples.
Next.js Rendering Strategies Explained: SSR, SSG, CSR, ISR & Cache Components (2026)
A beginner-friendly guide to Next.js rendering strategies — SSR, SSG, CSR, ISR, and Cache Components (PPR) — with simple examples for each.