DEV Community

Error Handling in the Next.js App Router: How I Stopped One Throw From Blanking the Page

Key takeaways

  • An error.tsx file in a route segment creates a React error boundary that catches errors thrown during rendering of that segment's page and any nested layouts, pages, and components below it.
  • error.tsx must start with 'use client' because React error boundaries rely on class-component lifecycle methods that only run in the browser; the file receives two props, error and reset.
  • error.tsx cannot catch an error thrown by the layout in its own segment - that layout renders above the boundary, so you place an error.tsx in the parent segment to catch it.
  • global-error.tsx is the only boundary that catches errors in the root layout; it replaces the root layout when active, so it must render its own <html> and <body>, and it runs only in production.
  • In production, Next.js strips the message from server-side errors and replaces it with a digest hash so nothing leaks to the browser; the original error is logged on the server.

What does error.tsx actually catch in the App Router?

An error.tsx file catches errors thrown while rendering the route segment it lives in and everything nested below it. Drop it in a segment folder and Next.js automatically wraps that segment's page.tsx, its nested layouts and pages, and every Server and Client Component they render in a React error boundary.

What it does not catch is the part that trips people up:

  • Errors thrown in event handlers like onClick
  • Errors thrown in async code that runs after render
  • A notFound() call
  • An error thrown by the layout.tsx or template.tsx in the same segment (the one that cost me time)
// app/dashboard/error.tsx catches throws from
// app/dashboard/page.tsx and anything nested under /dashboard,
// but NOT from app/dashboard/layout.tsx.

Why must error.tsx be a Client Component?

error.tsx must be a Client Component because React error boundaries are implemented with class-component lifecycle methods - getDerivedStateFromError and componentDidCatch - that only execute in the browser. It receives exactly two props: error, an Error instance that may carry a digest string, and reset, a zero-argument function that re-renders the boundary.

'use client';

export default function Error({
  error,
  reset,
}: {
  error: Error & { digest?: string };
  reset: () => void;
}) {
  return (
    <div role="alert">
      <h2>Something went wrong</h2>
      <button onClick={() => reset()}>Try again</button>
    </div>
  );
}

When do I need global-error.tsx instead of error.tsx?

You need global-error.tsx when the error happens in the root layout or root template, which sit above every ordinary error.tsx. A regular error.tsx renders inside the layout it protects, so it can never replace a broken root layout. global-error.tsx replaces the entire root layout when triggered - which is why it must include its own <html> and <body> tags. It is active only in production; in development the Next.js error overlay takes over.

'use client';

export default function GlobalError({ error, reset }) {
  return (
    <html>
      <body>
        <h2>Application error</h2>
        <button onClick={() => reset()}>Reload</button>
      </body>
    </html>
  );
}

How do I handle "not found" separately from errors?

Call notFound() from next/navigation, which throws a dedicated control-flow error caught by the nearest not-found.tsx, not by error.tsx. A missing record is an expected outcome, not a crash, so it deserves its own boundary and a real HTTP 404.

import { notFound } from 'next/navigation';

export default async function Page({ params }) {
  const post = await getPost(params.slug);
  if (!post) notFound(); // renders the nearest not-found.tsx, sends HTTP 404
  return <Article post={post} />;
}

Why are my server error messages replaced with a digest in production?

Next.js deliberately strips the message and stack from errors thrown on the server in production and replaces them with a digest property - an auto-generated hash - so it never leaks internals or secrets to the browser. The full error is logged on the server, and the digest is what lets you match a user's report to that server log. In development you still see the real message. So inside a production error.tsx, error.message for a server error is generic - build your correlation around error.digest.

How do I recover without a reload - and what about event-handler errors?

Call the reset() prop to recover without a full page reload; it asks React to re-render the boundary's contents by re-attempting the failed segment. If the cause was transient, the segment comes back with no navigation.

For errors error.tsx never sees - event handlers and async callbacks - catch them yourself and drive the UI with state.

'use client';

import { useState } from 'react';

function SaveButton() {
  const [err, setErr] = useState<string>();

  async function onClick() {
    try {
      await save(); // a rejection here is NOT caught by error.tsx
    } catch {
      setErr('Save failed'); // surface it with local state instead
    }
  }

  return (
    <>
      {err && <p role="alert">{err}</p>}
      <button onClick={onClick}>Save</button>
    </>
  );
}

For expected failures in a Server Action, return a typed error value rather than throwing, so the form renders the message inline without tripping an error boundary.

The App Router error files at a glance

File What it catches Client Component? Notes
error.tsx Render errors in its segment and nested children Yes Not its own layout; receives error + reset
global-error.tsx Errors in the root layout or template Yes Replaces the root layout; must render <html>/<body>; production only
not-found.tsx notFound() calls and unmatched routes No Returns HTTP 404; not triggered by real crashes
Event handlers / async None of the above n/a Use try/catch and surface with state

FAQ

Q: Does error.tsx catch errors in its own layout.tsx?
<br>A: No. The layout renders above the boundary. To catch an error thrown by a segment's layout, put an error.tsx in the parent segment.

Q: Why does my error.tsx not appear in development?
<br>A: Next.js shows its development error overlay on top of your boundary, and global-error.tsx only takes over in a production build.

Q: Do error boundaries catch errors in onClick handlers?
<br>A: No. React error boundaries only catch errors thrown during rendering. Wrap event-handler and async logic in try/catch and surface the failure with state.

Q: What is the digest property on the error object?
<br>A: A hash Next.js generates for server-side errors in production, replacing the real message so nothing leaks to the client. Match it against your server logs.

Q: Does calling notFound() trigger error.tsx?
<br>A: No. notFound() throws a dedicated control-flow signal caught by the nearest not-found.tsx and returns a 404. error.tsx is only for unexpected exceptions.

Originally published on devya.dev. Also on eng-ahmed.com. Built by Devya Solutions.

Comments

No comments yet. Start the discussion.