Next.js App Router Error Handling: error.tsx, not-found.tsx, and Global Error Boundaries

Learn how to handle errors in the Next.js App Router using error.tsx, not-found.tsx, and global-error.tsx. Covers Server Action handling with useActionState, Sentry integration, and common production pitfalls — all with working code examples.

Why Error Handling Matters in the App Router

Every production Next.js app hits errors eventually — a database query times out, a third-party API returns garbage, or a user navigates to a page that no longer exists. Without a solid error handling strategy, these failures either crash your entire application or leave users staring at cryptic white screens.

Not great for retention.

The App Router introduces a file-convention-based error handling system that maps directly to your route hierarchy. Instead of wrapping components in custom error boundary classes (remember doing that in the Pages Router?), you drop specially named files — error.tsx, not-found.tsx, and global-error.tsx — into the appropriate route segments. Next.js turns these files into React Error Boundaries automatically, giving you granular control over how failures affect different parts of your application.

This guide covers every layer of that system, from route-level error boundaries to Server Action error handling, with production-ready code you can use today.

The Error Handling Hierarchy Explained

Before writing any code, it's worth understanding how Next.js organizes error boundaries in the App Router. The hierarchy follows your file-system route structure:

app/
├── global-error.tsx      # Catches errors in root layout/template
├── error.tsx             # Catches errors in all routes under root layout
├── not-found.tsx         # Handles 404s globally + unmatched URLs
├── layout.tsx            # Root layout (NOT caught by app/error.tsx)
├── page.tsx
├── dashboard/
│   ├── error.tsx         # Dashboard-specific error boundary
│   ├── not-found.tsx     # Dashboard-specific 404
│   ├── layout.tsx
│   ├── page.tsx
│   └── settings/
│       ├── error.tsx     # Settings-specific error boundary
│       └── page.tsx
└── blog/
    ├── error.tsx         # Blog-specific error boundary
    └── [slug]/
        └── page.tsx

How Error Bubbling Works

When an error occurs in a component, it bubbles up to the nearest parent error.tsx boundary. If there's no error.tsx in the current route segment, the error keeps bubbling upward through the hierarchy until it finds one. If it reaches the root without hitting any error.tsx file, global-error.tsx takes over.

Here's the detail that trips people up: an error.tsx file does not catch errors from the layout.tsx in the same segment. The error boundary sits inside the layout component, so it can only catch errors from child components. To handle errors in a specific layout, you need to place the error.tsx file in the parent segment. This is honestly one of the most common sources of confusion I see in Next.js discussions.

Building Your First error.tsx

The error.tsx file must be a Client Component — that's a hard requirement because error boundaries rely on React's class-based componentDidCatch lifecycle under the hood. Next.js gives your component two props: the error object and a reset function.

// app/error.tsx
'use client';

import { useEffect } from 'react';

export default function Error({
  error,
  reset,
}: {
  error: Error & { digest?: string };
  reset: () => void;
}) {
  useEffect(() => {
    // Log the error to your error reporting service
    console.error('Application error:', error);
  }, [error]);

  return (
    <div className="flex min-h-[400px] flex-col items-center justify-center gap-4">
      <h2 className="text-2xl font-bold">Something went wrong</h2>
      <p className="text-gray-600">
        An unexpected error occurred. Please try again.
      </p>
      <button
        onClick={() => reset()}
        className="rounded-md bg-blue-600 px-4 py-2 text-white hover:bg-blue-700"
      >
        Try again
      </button>
    </div>
  );
}

Understanding the reset() Function

The reset() function attempts to re-render the error boundary's children. It works well for transient failures — a network timeout, a rate-limited API, or a database connection that momentarily dropped. When called, React tries to render the original component tree again. If it succeeds, the error fallback gets replaced with the normal content.

But here's the thing: reset() doesn't reload the page or refetch data from the server. If the error was caused by stale server state, you'll want to combine reset() with a router refresh:

// app/dashboard/error.tsx
'use client';

import { useEffect } from 'react';
import { useRouter } from 'next/navigation';

export default function DashboardError({
  error,
  reset,
}: {
  error: Error & { digest?: string };
  reset: () => void;
}) {
  const router = useRouter();

  useEffect(() => {
    console.error('Dashboard error:', error);
  }, [error]);

  return (
    <div className="rounded-lg border border-red-200 bg-red-50 p-6">
      <h2 className="text-lg font-semibold text-red-800">
        Dashboard Error
      </h2>
      <p className="mt-2 text-red-600">
        Failed to load dashboard data. This might be a temporary issue.
      </p>
      <div className="mt-4 flex gap-3">
        <button
          onClick={() => reset()}
          className="rounded bg-red-600 px-4 py-2 text-white"
        >
          Try again
        </button>
        <button
          onClick={() => {
            router.refresh();
            reset();
          }}
          className="rounded bg-gray-600 px-4 py-2 text-white"
        >
          Refresh & retry
        </button>
      </div>
    </div>
  );
}

Handling 404 Errors with not-found.tsx

The not-found.tsx file handles two scenarios: programmatic 404s triggered by calling notFound() from next/navigation, and unmatched URLs that don't correspond to any route in your app. Unlike error.tsx, the not-found.tsx file is a Server Component by default — which means it can fetch data asynchronously. That's actually a pretty useful distinction.

// app/not-found.tsx
import Link from 'next/link';

export default function NotFound() {
  return (
    <div className="flex min-h-[60vh] flex-col items-center justify-center gap-4">
      <h1 className="text-6xl font-bold text-gray-300">404</h1>
      <h2 className="text-xl font-semibold">Page Not Found</h2>
      <p className="text-gray-600">
        The page you are looking for does not exist or has been moved.
      </p>
      <div className="mt-4 flex gap-4">
        <Link
          href="/"
          className="rounded-md bg-blue-600 px-4 py-2 text-white"
        >
          Go home
        </Link>
        <Link
          href="/blog"
          className="rounded-md border border-gray-300 px-4 py-2"
        >
          Browse articles
        </Link>
      </div>
    </div>
  );
}

Triggering notFound() in Dynamic Routes

The most common use case for notFound() is when a dynamic route parameter doesn't match any record in your database:

// app/blog/[slug]/page.tsx
import { notFound } from 'next/navigation';
import { getPostBySlug } from '@/lib/posts';

export default async function BlogPost({
  params,
}: {
  params: Promise<{ slug: string }>;
}) {
  const { slug } = await params;
  const post = await getPostBySlug(slug);

  if (!post) {
    notFound(); // Renders the nearest not-found.tsx
  }

  return (
    <article>
      <h1>{post.title}</h1>
      <div dangerouslySetInnerHTML={{ __html: post.content }} />
    </article>
  );
}

Next.js automatically sets the HTTP status code to 404 when notFound() is called, which is essential for SEO. Search engines need that correct status code to remove deleted pages from their index.

Route-Specific not-found.tsx Files

You can create not-found.tsx files in specific route segments to provide contextual 404 pages. For example, an e-commerce site might show product recommendations on its product 404 page — way better than a generic "page not found" message:

// app/products/not-found.tsx
import Link from 'next/link';
import { getFeaturedProducts } from '@/lib/products';

export default async function ProductNotFound() {
  const featured = await getFeaturedProducts(4);

  return (
    <div className="py-12">
      <h2 className="text-2xl font-bold">Product Not Found</h2>
      <p className="mt-2 text-gray-600">
        This product may have been discontinued or the URL is incorrect.
      </p>
      <h3 className="mt-8 text-lg font-semibold">You might like these:</h3>
      <div className="mt-4 grid grid-cols-2 gap-4">
        {featured.map((product) => (
          <Link key={product.id} href={`/products/${product.slug}`}>
            {product.name}
          </Link>
        ))}
      </div>
    </div>
  );
}

global-error.tsx: The Last Line of Defense

The global-error.tsx file catches errors that occur in the root layout.tsx or template.tsx. Since these errors break the root layout itself, global-error.tsx has to render its own <html> and <body> tags — it completely replaces the document.

// app/global-error.tsx
'use client';

export default function GlobalError({
  error,
  reset,
}: {
  error: Error & { digest?: string };
  reset: () => void;
}) {
  return (
    <html lang="en">
      <body>
        <div className="flex min-h-screen flex-col items-center justify-center">
          <h1 className="text-3xl font-bold">Something went seriously wrong</h1>
          <p className="mt-4 text-gray-600">
            A critical error occurred. Please refresh the page.
          </p>
          <div className="mt-6 flex gap-4">
            <button
              onClick={() => reset()}
              className="rounded bg-blue-600 px-4 py-2 text-white"
            >
              Try again
            </button>
            <button
              onClick={() => window.location.reload()}
              className="rounded bg-gray-600 px-4 py-2 text-white"
            >
              Reload page
            </button>
          </div>
        </div>
      </body>
    </html>
  );
}

Key Constraints of global-error.tsx

There are a few important limitations you should know about:

  • CSS Modules don't work — Next.js ignores CSS module imports in global-error.tsx. You'll need to style this component using inline styles or classes from your global CSS file.
  • No metadata exports — Since this is a Client Component, you can't use metadata or generateMetadata. Use the React <title> component instead.
  • Rarely triggered in practice — Root layouts and templates tend to be pretty stable. Most errors get caught by route-level error.tsx files first. Still, having global-error.tsx in place is essential for completeness.

Server Component Error Handling

React Server Components execute on the server, so their errors behave a bit differently from client-side ones. When a Server Component throws, Next.js catches the error during rendering and forwards it to the nearest error.tsx boundary. However — and this is important — the error details are sanitized in production. The client only receives a generic error message with a digest identifier that maps to detailed server-side logs.

This is a deliberate security measure. Server Components often interact with databases, secret keys, and internal APIs. Leaking stack traces or error messages to the client would expose implementation details you definitely don't want public.

Handling Errors in Data Fetching

For Server Components that fetch data, you've got two approaches: let the error propagate to the error boundary, or handle it gracefully with conditional rendering. Which one you pick depends on whether the failure is expected or not.

// Approach 1: Let errors propagate to error.tsx (recommended for unexpected errors)
// app/dashboard/page.tsx
import { getAnalytics } from '@/lib/analytics';

export default async function Dashboard() {
  const data = await getAnalytics(); // Throws on failure → caught by error.tsx
  return <AnalyticsChart data={data} />;
}

// Approach 2: Handle errors gracefully (for expected failures)
// app/dashboard/page.tsx
import { getAnalytics } from '@/lib/analytics';

export default async function Dashboard() {
  let data = null;
  try {
    data = await getAnalytics();
  } catch (err) {
    console.error('Failed to fetch analytics:', err);
  }

  if (!data) {
    return (
      <div className="rounded border border-yellow-200 bg-yellow-50 p-4">
        <p>Analytics data is temporarily unavailable.</p>
      </div>
    );
  }

  return <AnalyticsChart data={data} />;
}

Isolating Failures with Suspense Boundaries

You can combine error.tsx with <Suspense> to isolate failures to specific parts of a page. This is one of those patterns that's genuinely game-changing once you start using it. Wrap independent sections so that one failing component doesn't bring down the entire page:

// app/dashboard/page.tsx
import { Suspense } from 'react';
import { ErrorBoundary } from 'react-error-boundary';
import { RevenueChart } from './revenue-chart';
import { RecentOrders } from './recent-orders';
import { UserStats } from './user-stats';

export default function DashboardPage() {
  return (
    <div className="grid grid-cols-2 gap-6">
      <Suspense fallback={<div>Loading revenue...</div>}>
        <RevenueChart />
      </Suspense>

      <Suspense fallback={<div>Loading orders...</div>}>
        <RecentOrders />
      </Suspense>

      <Suspense fallback={<div>Loading stats...</div>}>
        <UserStats />
      </Suspense>
    </div>
  );
}

If RevenueChart throws an error, only that section falls back to the nearest error.tsx while RecentOrders and UserStats keep working just fine.

Server Action Error Handling with useActionState

Server Actions need a different error handling approach than rendering errors. Next.js and React distinguish between two types:

  • Expected errors — validation failures, duplicate entries, permission denials. Return these as values using useActionState, don't throw them.
  • Unexpected errors — database crashes, unhandled exceptions. Let these propagate to the nearest error.tsx boundary.

So, let's look at a real-world example with a contact form.

// app/contact/actions.ts
'use server';

import { z } from 'zod';

const contactSchema = z.object({
  name: z.string().min(2, 'Name must be at least 2 characters'),
  email: z.string().email('Invalid email address'),
  message: z.string().min(10, 'Message must be at least 10 characters'),
});

export type ContactFormState = {
  success: boolean;
  message: string;
  errors?: Record<string, string[]>;
};

export async function submitContact(
  prevState: ContactFormState,
  formData: FormData
): Promise<ContactFormState> {
  const raw = {
    name: formData.get('name'),
    email: formData.get('email'),
    message: formData.get('message'),
  };

  const result = contactSchema.safeParse(raw);

  if (!result.success) {
    return {
      success: false,
      message: 'Please fix the errors below.',
      errors: result.error.flatten().fieldErrors,
    };
  }

  // Save to database — unexpected errors (DB down) will throw
  // and propagate to error.tsx automatically
  await saveContactMessage(result.data);

  return {
    success: true,
    message: 'Thank you! We will get back to you soon.',
  };
}
// app/contact/contact-form.tsx
'use client';

import { useActionState } from 'react';
import { submitContact, type ContactFormState } from './actions';

const initialState: ContactFormState = {
  success: false,
  message: '',
};

export function ContactForm() {
  const [state, formAction, isPending] = useActionState(
    submitContact,
    initialState
  );

  return (
    <form action={formAction} className="space-y-4">
      {state.message && (
        <div
          className={`rounded p-3 ${
            state.success
              ? 'bg-green-50 text-green-800'
              : 'bg-red-50 text-red-800'
          }`}
        >
          {state.message}
        </div>
      )}

      <div>
        <label htmlFor="name">Name</label>
        <input id="name" name="name" type="text" required />
        {state.errors?.name && (
          <p className="text-sm text-red-600">{state.errors.name[0]}</p>
        )}
      </div>

      <div>
        <label htmlFor="email">Email</label>
        <input id="email" name="email" type="email" required />
        {state.errors?.email && (
          <p className="text-sm text-red-600">{state.errors.email[0]}</p>
        )}
      </div>

      <div>
        <label htmlFor="message">Message</label>
        <textarea id="message" name="message" rows={4} required />
        {state.errors?.message && (
          <p className="text-sm text-red-600">{state.errors.message[0]}</p>
        )}
      </div>

      <button
        type="submit"
        disabled={isPending}
        className="rounded bg-blue-600 px-4 py-2 text-white disabled:opacity-50"
      >
        {isPending ? 'Sending...' : 'Send message'}
      </button>
    </form>
  );
}

The redirect() Gotcha

This one bites almost everyone at least once. The redirect() function from next/navigation works by throwing a special error internally. If you call it inside a try/catch block, the catch statement swallows the redirect and nothing happens. Always call redirect() outside of try/catch:

// WRONG — redirect will be caught by the catch block
export async function createPost(prevState: FormState, formData: FormData) {
  try {
    const post = await db.posts.create({ data: parseFormData(formData) });
    redirect(`/blog/${post.slug}`); // This throws — caught below!
  } catch (error) {
    return { success: false, message: 'Failed to create post' };
  }
}

// CORRECT — redirect is called after try/catch
export async function createPost(prevState: FormState, formData: FormData) {
  let post;
  try {
    post = await db.posts.create({ data: parseFormData(formData) });
  } catch (error) {
    return { success: false, message: 'Failed to create post' };
  }
  redirect(`/blog/${post.slug}`);
}

Production Error Logging with Sentry

Error boundaries prevent crashes, but they also intercept errors before they reach global error handlers. Without explicit logging, production errors just... disappear silently. That's a problem. Integrating Sentry (or a similar service) means you capture errors at each boundary level.

Setting Up Sentry

Install the Sentry SDK and run the setup wizard:

npx @sentry/wizard@latest -i nextjs

This creates configuration files for both client and server, and generates a global-error.tsx file with Sentry integration pre-configured. Pretty nice that it handles the boilerplate for you.

Capturing Errors in error.tsx

Add Sentry.captureException to every error.tsx file so errors caught by boundaries still reach your monitoring dashboard:

// app/error.tsx
'use client';

import { useEffect } from 'react';
import * as Sentry from '@sentry/nextjs';

export default function Error({
  error,
  reset,
}: {
  error: Error & { digest?: string };
  reset: () => void;
}) {
  useEffect(() => {
    Sentry.captureException(error);
  }, [error]);

  return (
    <div>
      <h2>Something went wrong</h2>
      <button onClick={() => reset()}>Try again</button>
    </div>
  );
}

Instrumenting Server Actions

Wrap Server Actions with Sentry.withServerActionInstrumentation to capture server-side errors and connect client-server traces:

'use server';

import * as Sentry from '@sentry/nextjs';

export async function deleteAccount(formData: FormData) {
  return Sentry.withServerActionInstrumentation(
    'deleteAccount',
    { recordResponse: true },
    async () => {
      // Your server action logic
      await db.users.delete({ where: { id: getCurrentUserId() } });
      return { success: true };
    }
  );
}

Common Mistakes and How to Avoid Them

1. Missing global-error.tsx

A lot of developers add error.tsx to their root app/ directory and assume they're covered. But app/error.tsx doesn't catch errors from app/layout.tsx. Always add global-error.tsx as a safety net, even if your root layout seems rock solid.

2. Forgetting the 'use client' Directive

Both error.tsx and global-error.tsx must be Client Components. Forget the 'use client' directive and Next.js will throw a build error. The not-found.tsx file, on the other hand, can be either a Server or Client Component — it's more flexible that way.

3. Not Handling Event Handler Errors

Error boundaries only catch rendering errors. Errors thrown inside onClick, onChange, and other event handlers won't be caught by any error.tsx file. You need to handle these manually with try/catch and update state accordingly:

'use client';

import { useState } from 'react';

export function DeleteButton({ id }: { id: string }) {
  const [error, setError] = useState<string | null>(null);

  async function handleDelete() {
    try {
      setError(null);
      const res = await fetch(`/api/items/${id}`, { method: 'DELETE' });
      if (!res.ok) throw new Error('Delete failed');
    } catch (err) {
      setError(err instanceof Error ? err.message : 'An error occurred');
    }
  }

  return (
    <div>
      {error && <p className="text-red-600">{error}</p>}
      <button onClick={handleDelete}>Delete</button>
    </div>
  );
}

4. Returning 200 Status for 404 Pages

If you render a custom "not found" UI without calling notFound(), the HTTP status code stays at 200. Search engines will index the page as valid content instead of removing it from their index. Always use the notFound() function from next/navigation to trigger the correct 404 status code.

5. Putting redirect() Inside try/catch

As I mentioned earlier, redirect() throws internally. Wrapping it in try/catch silently swallows the redirect. This is genuinely one of the most reported bugs in Next.js GitHub discussions — and it's not really a bug, it's a design decision that catches people off guard.

A Complete Error Handling Checklist

Here's a quick checklist to make sure your Next.js application handles errors properly across the board:

  1. Root-level app/error.tsx — catches unexpected errors across the application
  2. Root-level app/not-found.tsx — handles unmatched URLs and programmatic 404s
  3. Root-level app/global-error.tsx — catches root layout/template errors
  4. Route-specific error.tsx — for sections needing custom error UIs (dashboards, checkout flows)
  5. Route-specific not-found.tsx — for sections needing contextual 404 pages (products, blog posts)
  6. Server Action error handling — use useActionState for expected errors, let unexpected errors propagate
  7. Event handler error handling — use try/catch with state for non-rendering errors
  8. Error logging integration — capture errors in every boundary with Sentry or similar
  9. notFound() for missing resources — never return a 200 status for something that doesn't exist
  10. Keep redirect() outside try/catch — prevent silently swallowed redirects

Frequently Asked Questions

What is the difference between error.tsx and global-error.tsx in Next.js?

error.tsx creates an error boundary for a specific route segment and its children. It renders inside the parent layout, so navigation and shared UI stay visible. global-error.tsx catches errors in the root layout.tsx and template.tsx — the components that error.tsx can't reach. Since it replaces the entire document, it must include its own <html> and <body> tags. In practice, error.tsx handles the vast majority of errors while global-error.tsx serves as a last-resort safety net.

Why does error.tsx need to be a Client Component?

React error boundaries rely on the componentDidCatch and getDerivedStateFromError lifecycle methods, which only exist on class components. Next.js wraps your error.tsx export in a class-based error boundary internally, but the fallback UI you define needs to run on the client to support interactive features like the reset() function and event handlers. Server Components can't use state or effects, and those are essential for error recovery flows.

How do I handle errors in Server Actions without crashing the page?

Use the useActionState hook from React to handle expected errors (validation failures, permission denials) as return values rather than thrown exceptions. For unexpected errors like database failures, let them propagate to the nearest error.tsx boundary. And remember — keep redirect() calls outside of try/catch blocks to prevent them from being caught as errors.

Does error.tsx catch errors from the layout in the same folder?

No, it doesn't. An error.tsx file only catches errors from components rendered below it in the component tree — the page.tsx and its children. Because the error boundary is nested inside the layout, errors in layout.tsx within the same segment bubble up to the parent segment's error.tsx or ultimately to global-error.tsx.

How do I log errors from error boundaries in production?

Add a useEffect inside each error.tsx file that calls your error reporting service (like Sentry.captureException(error)). Error boundaries intercept errors before they reach global handlers, so without explicit logging in each boundary, production errors are basically invisible to your monitoring tools. For Server Actions, wrap them with Sentry.withServerActionInstrumentation to capture server-side errors and link them to client traces.

About the Author Editorial Team

Our team of expert writers and editors.