
Next.js Error Handling
Every non-trivial application fails sometimes. A form submission hits a validation rule, an upstream API times out, a database query throws, or a component reads a property that turns out to be undefined at 2am in production. The question was never whether your app breaks — it's what the user sees when it does, and how much of that failure you can recover from gracefully instead of showing a blank white screen.
The App Router splits error handling into two deliberately different mental models, and mixing them up is the single most common mistake developers make when they move from a plain React app or the Pages Router. Some errors are part of your application's normal logic and should be handled like any other piece of state. Others are genuine bugs that should crash a component tree loudly enough that you notice them, while still keeping the rest of the page alive. This article walks through both models, the file conventions and hooks that back them, and a handful of gotchas the official docs mention only in passing (or not at all).
Two Kinds of Errors, Two Different Tools
Next.js draws a line between expected errors and uncaught exceptions, and that distinction is the entire foundation of how error handling works in the App Router.
Expected errors are the ones you already know can happen. A user submits a form with an email that's already taken. A third-party API returns a 429 because you're rate limited. A slug in the URL doesn't match any record in your database. None of these are bugs — they're outcomes your application needs to plan for, the same way a login form plans for "wrong password." The official guidance here is blunt: model these as return values, not thrown exceptions. You don't throw a "wrong password" error and hope something catches it three layers up; you return a value that says "this failed, here's why" and let the UI react to it.
Uncaught exceptions are the ones you didn't plan for — the null reference, the missing environment variable, the third-party library that changed its API without a major version bump. These are true bugs, and the correct response is to throw, let an error boundary catch it, and show a fallback UI instead of taking down the whole page. You want these to be loud in development and gracefully contained in production.
If you keep that split in your head — "did I anticipate this, or did something go wrong that shouldn't have" — the rest of this article is really just implementation detail.
Handling Expected Errors as Values
Inside Server Functions with useActionState
Server Functions (the App Router's mechanism for running server-side mutations directly from a form, often still called Server Actions) are the most common place you'll model expected errors. The pattern pairs a 'use server' function with React's useActionState hook on the client.
Here's a server function that tries to create a post and reports back if the request fails:
// app/actions.ts
"use server";
export async function createPost(prevState: any, formData: FormData) {
const title = formData.get("title");
const content = formData.get("content");
const res = await fetch("https://api.vercel.app/posts", {
method: "POST",
body: { title, content },
});
const json = await res.json();
if (!res.ok) {
return { message: "Failed to create post" };
}
}
Notice what's missing: there's no try/catch, no throw. If the request fails, the function simply returns an object describing what happened. That object becomes the new state in whatever client component called this action:
// app/ui/form.tsx
"use client";
import { useActionState } from "react";
import { createPost } from "@/app/actions";
const initialState = {
message: "",
};
export function Form() {
const [state, formAction, pending] = useActionState(createPost, initialState);
return (
<form action={formAction}>
<label htmlFor="title">Title</label>
<input type="text" id="title" name="title" required />
<label htmlFor="content">Content</label>
<textarea id="content" name="content" required />
{state?.message && <p aria-live="polite">{state.message}</p>}
<button disabled={pending}>Create Post</button>
</form>
);
}
A few things worth calling out that the docs gloss over. First, useActionState is a React hook, not a Next.js API — you'll see it referenced the same way if you ever work with a plain Vite + React app that has Server Functions wired up through a different framework. Second, the aria-live="polite" attribute on the message paragraph is doing real accessibility work: it tells screen readers to announce the error text as soon as it appears, without requiring the user to be focused on that exact element. If you skip it, sighted users see the error and screen reader users don't hear anything happened at all. It's a two-character addition that a lot of tutorials quietly drop.
Third — and this trips people up constantly — prevState is not optional even if you never read it. useActionState calls your action with the previous state as the first argument on every submission, so your action's signature has to accept it even if the only thing you do with it is ignore it. Forgetting this argument, or putting formData first, is a very common source of "why is my form data undefined" bugs.
Inside Server Components
Server Components fetch data directly during render, which means an expected failure (an API returning a non-2xx status, for example) happens synchronously in the middle of your JSX. You handle it the same way: check the response, and conditionally return different UI.
// app/page.tsx
export default async function Page() {
const res = await fetch(`https://...`);
const data = await res.json();
if (!res.ok) {
return "There was an error.";
}
return "...";
}
This example is deliberately minimal in the docs, but in practice you'll almost always want more than a bare string. A realistic version returns a properly styled component:
// app/dashboard/page.tsx
export default async function DashboardPage() {
const res = await fetch("https://api.example.com/dashboard", {
cache: "no-store",
});
if (!res.ok) {
return (
<div className="rounded-md border border-red-200 bg-red-50 p-4">
<p className="text-sm text-red-700">
We couldn't load your dashboard right now. Please refresh the page.
</p>
</div>
);
}
const data = await res.json();
return <Dashboard data={data} />;
}
The important architectural point here: this is a render-time decision, not a crash. The page renders successfully — it just renders a different tree than it would on the happy path. No error boundary is involved, nothing bubbles up, and the rest of the layout (navigation, sidebar, footer) keeps working exactly as if nothing happened. That containment is exactly why "expected errors as values" is worth the extra discipline: a failed fetch on one page doesn't take out your whole app shell.
You could also redirect from here using redirect if a failed condition means the user shouldn't be on this page at all — for instance, redirecting to a login screen if an auth check fails.
404s with notFound() and not-found.js
A very specific, very common expected error is "this thing doesn't exist." Rather than returning a generic error string, Next.js gives you a purpose-built function and file convention for this exact case.
// app/blog/[slug]/page.tsx
import { notFound } from "next/navigation";
import { getPostBySlug } from "@/lib/posts";
export default async function Page({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
const post = getPostBySlug(slug);
if (!post) {
notFound();
}
return <div>{post.title}</div>;
}
// app/blog/[slug]/not-found.tsx
export default function NotFound() {
return <div>404 - Page Not Found</div>;
}
Calling notFound() isn't a normal function call that returns and lets execution continue — under the hood it throws a special, internal error that Next.js's routing layer specifically recognizes and intercepts. That's why you don't need (and shouldn't add) a return before the call once it executes, and why any code after notFound() in that function is genuinely unreachable. It also means the HTTP status code changes to a real 404, which matters for SEO and for any monitoring tooling that checks status codes — you're not just showing 404-shaped content on a 200 response, which is a subtle but important difference from just rendering a "not found" string yourself.
One caveat that's easy to miss: not-found.tsx only catches calls to notFound() (and, in Pages Router terms, genuinely unmatched routes) — it is not a general-purpose error boundary. If a database query throws inside that same page.tsx, that error goes to error.tsx, not not-found.tsx. These are two separate mechanisms for two separate situations, and it's worth keeping a not-found.tsx at both the segment level (for "this specific post doesn't exist") and the root level (for genuinely unmatched URLs), since the file resolves the same way error.tsx does — the nearest one up the tree wins.
Handling Uncaught Exceptions with Error Boundaries
Now for the other half of the story: bugs. Things that throw because something actually went wrong, not because you designed for a known failure path.
The error.js File Convention
Add an error.tsx file inside any route segment, and Next.js automatically wraps that segment (and everything below it) in a React error boundary using that file as the fallback UI.
// app/dashboard/error.tsx
"use client"; // Error boundaries must be Client Components
import { useEffect } from "react";
export default function ErrorPage({
error,
retry,
}: {
error: Error & { digest?: string };
retry: () => void;
}) {
useEffect(() => {
// Log the error to an error reporting service
console.error(error);
}, [error]);
return (
<div>
<h2>Something went wrong!</h2>
<button onClick={() => retry()}>Try again</button>
</div>
);
}
A detail worth flagging loudly, because a lot of existing tutorials, Stack Overflow answers, and even AI assistants trained on older material will get this wrong: the second prop passed to your error.tsx component is called retry, not reset. Earlier versions of the App Router (and a large chunk of content written about them) used reset for this exact callback. If you're following an older guide and your error boundary silently does nothing when the button is clicked, check that you're calling the prop your version of Next.js actually gives you rather than copy-pasting a name from a two-year-old blog post. This is a good general reminder that framework-specific code you find online has a shelf life — always cross-check the prop names against the docs (or better, your editor's autocomplete) rather than assuming a snippet is still accurate.
The 'use client' directive at the top isn't optional styling — error boundaries have to be Client Components because they rely on React's componentDidCatch/getDerivedStateFromError lifecycle machinery under the hood, which only exists on the client. This means your fallback UI can use hooks, event handlers, and browser APIs freely, but it also means the error boundary itself adds to your client JavaScript bundle. For a route segment with a genuinely simple error state, keep the boundary lean — it doesn't need to import your entire design system, just enough to render a clear message and a retry button.
Errors bubble up to the nearest parent error boundary. If app/dashboard/settings/page.tsx throws and there's no error.tsx in app/dashboard/settings/, Next.js walks up the tree looking for one in app/dashboard/, then app/, and so on. This gives you granular control: you can put a broad, generic error boundary at the root and then override it with more specific, more helpful fallback UI at any level where you know enough about that segment to say something useful ("Couldn't load your invoices" is a lot more helpful than "Something went wrong").
What error.js Does Not Catch
This is the part the getting-started docs mention only briefly, and it's where most confusion comes from in practice.
It doesn't catch errors in the layout of the same segment. An error.tsx file placed in app/dashboard/ catches errors thrown by app/dashboard/page.tsx and anything nested below it, but it does not catch an error thrown inside app/dashboard/layout.tsx itself. That's because the error boundary component is rendered as a child of the layout, so if the layout itself is what's broken, the boundary that's supposed to catch it never even gets to render. If you need to guard against a layout throwing, the boundary has to live one level higher, in the parent segment.
It doesn't catch errors inside event handlers. Error boundaries only catch errors that occur during rendering — inside a component's function body as React is building the tree. A throw inside an onClick handler happens after rendering is complete, in response to a browser event, and React's error boundary machinery was never designed to intercept that. If you throw inside a click handler with an error.tsx sitting right above it in the tree, nothing happens — no fallback UI appears, and depending on your setup you might just see an unhandled error in the console.
To handle these cases, you catch the error yourself and store it in state:
"use client";
import { useState } from "react";
export function Button() {
const [error, setError] = useState<Error | null>(null);
const handleClick = () => {
try {
// do some work that might fail
throw new Error("Exception");
} catch (reason) {
setError(reason as Error);
}
};
if (error) {
return <p>Something went wrong: {error.message}</p>;
}
return (
<button type="button" onClick={handleClick}>
Click me
</button>
);
}
This is really just standard React state management applied to failure — no different from how you'd track a loading flag. The mental shift is realizing that error.tsx covers "my render blew up," not "any error anywhere in this file."
Async code follows the same rule, with one important exception. A rejected promise inside a plain async function (say, inside a useEffect) also won't be caught by an error boundary, for the same reason — it's not happening during render. But errors thrown inside startTransition (from React's useTransition hook) are the exception to the rule: those do bubble up to the nearest error boundary, because transitions are treated as part of React's rendering lifecycle even though they're triggered by an event.
"use client";
import { useTransition } from "react";
export function Button() {
const [pending, startTransition] = useTransition();
const handleClick = () =>
startTransition(() => {
throw new Error("Exception");
});
return (
<button type="button" onClick={handleClick}>
Click me
</button>
);
}
If you're building interactive UI with optimistic updates or pending states — the kind of thing useTransition is made for — this is genuinely useful, because it means you don't need a separate manual try/catch just for transition-wrapped work. It's one of the few places where "was this triggered by an event" doesn't automatically mean "the error boundary won't help you."
Component-Level Boundaries with catchError
error.tsx operates at the route-segment level — one boundary per folder in your app directory. Sometimes that granularity is too coarse. You might want an error boundary around one specific widget on a page — a chart, a comments section, a third-party embed — without wrapping the entire route in a fallback that would otherwise wipe out the rest of a perfectly functional page.
The catchError function (imported from next/error) lets you build one of these boundaries as a reusable component:
// app/custom-error-boundary.tsx
"use client";
import { catchError, type ErrorInfo } from "next/error";
function ErrorFallback(props: { title: string }, { error, retry }: ErrorInfo) {
return (
<div>
<h2>{props.title}</h2>
<p>{error.message}</p>
<button onClick={() => retry()}>Try again</button>
</div>
);
}
export default catchError(ErrorFallback);
// app/some-component.tsx
import ErrorBoundary from "./custom-error-boundary";
export default function Component({ children }: { children: React.ReactNode }) {
return <ErrorBoundary title="Dashboard Error">{children}</ErrorBoundary>;
}
Think of this as the difference between "if anything on this page breaks, show a full-page error" and "if this one chart component breaks, show a small inline error card right where the chart used to be, and let everything else keep working." For dashboards, feeds, and any page composed of several independent widgets fetching independent data, wrapping each widget in its own catchError boundary is a much better user experience than one route-level error.tsx that nukes the entire page because a single sidebar widget failed.
Use error.tsx for "this whole section of the app is broken." Use catchError-based boundaries for "this specific piece of UI might fail independently of everything around it."
Global Errors with global-error.js
Every error boundary you've read about so far assumes the root layout itself is intact — it's still rendering the <html> and <body> tags, the navigation, everything around the broken segment. But what if the root layout is what breaks?
For that rare case, there's global-error.tsx, placed directly in your root app/ directory:
// app/global-error.tsx
"use client"; // Error boundaries must be Client Components
export default function GlobalError({
error,
retry,
}: {
error: Error & { digest?: string };
retry: () => void;
}) {
return (
// global-error must include html and body tags
<html>
<body>
<h2>Something went wrong!</h2>
<button onClick={() => retry()}>Try again</button>
</body>
</html>
);
}
Notice this component defines its own <html> and <body> tags — that's not stylistic preference, it's required. Because global-error.tsx replaces the root layout entirely when it activates, and the root layout is what normally owns those tags, this file has to provide them itself or your page would render without a valid document shell. It's also worth noting this still works alongside internationalized routing setups — a global error doesn't need to know which locale it's rendering for, since by definition something has gone wrong badly enough that locale-specific layout can't be trusted to render.
In practice, most applications never need to write a global-error.tsx at all, because a well-organized route tree keeps genuine root-layout bugs to a minimum, and a solid error.tsx at app/error.tsx catches the vast majority of what would otherwise reach here. Treat this file as a last line of defense, not a first stop.
Production Behavior: What Users Actually See
A detail that trips people up the first time they deploy: error messages behave differently in development versus production. During local development, Next.js shows you the full error overlay — stack trace, source location, the works — because you're the one who needs that detail. In production, that same level of detail isn't shown to end users by default; instead they see whatever fallback UI your error.tsx renders, and the error object your component receives is deliberately less detailed than what you saw locally.
This is also where the digest property on the error object earns its keep. Rather than exposing the full error message and stack trace to anyone visiting your site, Next.js can attach a short digest hash you can display to the user ("Something went wrong. Reference: a1b2c3d4") and then use that same digest to search your server-side logs for the real, detailed error. It's a small thing, but it turns "a user emails me saying something broke" into "I can actually find out what broke," without ever leaking implementation details, database structure, or API keys to whoever's looking at the page.
The practical takeaway: don't design your error.tsx fallback around the assumption that error.message will always contain something useful and safe to show a stranger. Log the full error server-side (or client-side to a service like Sentry) for your own diagnosis, and keep what you actually render to the user generic, calm, and actionable — "Something went wrong, please try again" plus a retry button covers the vast majority of cases better than trying to explain what actually happened.
Common Mistakes to Avoid
Throwing inside a Server Function you expect to fail sometimes. If you already know a mutation can fail in a specific, anticipated way (validation, a conflict, a rate limit), don't throw. Return a value. Throwing turns a normal business-logic outcome into something that has to be caught by an error boundary, which is heavier-weight and gives you a worse UI for something that isn't actually exceptional.
Forgetting 'use client' on error.tsx or your catchError fallback. These components rely on React lifecycle methods that only run in the browser. Leave off the directive and you'll get a build error, not a silent failure — but it's still one of the most common first mistakes.
Assuming one error.tsx at the root covers everything gracefully. It technically does catch everything below it, but a single generic "Something went wrong" message for every possible failure across your entire app is a worse experience than a handful of targeted boundaries that can say something specific about the section that broke.
Relying on error.tsx to catch event handler or effect errors. As covered above, it won't. If your app does meaningful work inside click handlers or useEffect, you need manual try/catch and local state for those specific paths — the boundary is not a safety net for the entire component's lifecycle, only its render phase (with the startTransition exception).
Showing raw error messages to users in production. Treat error.message as potentially sensitive. Log details server-side, show something calm and generic client-side, and use the digest to connect the two when you need to investigate.
Key Takeaways
| Situation | Tool | Notes |
|---|---|---|
| Known failure in a Server Function | Return a value from the action | Pair with useActionState; avoid throw |
| Known failure in a Server Component | Conditional render or redirect | Happens during render, no boundary involved |
| Resource genuinely doesn't exist | notFound() + not-found.tsx | Sets a real 404 status; not a general error boundary |
| Unexpected render-time bug in a route segment | error.tsx | Must be a Client Component; prop is retry, not reset |
| Unexpected bug in one small widget | catchError from next/error | Component-level boundary, doesn't take down the whole page |
| Error inside an event handler or effect | Manual try/catch + state | Error boundaries don't cover this |
Error inside startTransition | Bubbles to nearest error.tsx automatically | The one exception to the event-handler rule |
| Root layout itself is broken | global-error.tsx | Must define its own <html> and <body>; rarely needed |
The App Router's error model rewards a small amount of upfront thinking: decide, for each piece of logic you write, whether failure is something you expect or something that would genuinely surprise you. Expected failures become values your UI reacts to. Unexpected failures become boundaries your UI falls back to. Once that split is second nature, the rest — which file goes where, which prop is called what — is just syntax.


