All insights
Engineering7 min read

Error Handling in Next.js App Router: A Production Guide

The App Router has a layered error handling system that most developers underuse. Here's how to build resilient Next.js applications that fail gracefully.

NC

Nextcraft Engineering Team

The Error Handling Hierarchy

Next.js App Router has four distinct error handling mechanisms, each serving a different purpose:

  1. error.tsx — catches errors in route segments
  2. not-found.tsx — handles 404 states
  3. global-error.tsx — catches errors in the root layout
  4. middleware — intercepts requests before they reach pages

Understanding when each one fires — and what it can do — is the foundation of production-grade error handling.

error.tsx: The Workhorse

An error.tsx file in any route segment becomes the error boundary for that segment and its children. When a Server Component throws, or when a Client Component throws during render, control passes to the nearest error.tsx.

code
// app/dashboard/error.tsx
'use client'; // Error boundaries must be Client Components

import { useEffect } from 'react';

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

  return (
    <div className="error-container">
      <h2>Something went wrong loading your dashboard</h2>
      <p className="text-sm text-gray-500">
        Error ID: {error.digest}
      </p>
      <button onClick={reset}>Try again</button>
    </div>
  );
}

Key points:

  • error.tsx must be a Client Component (React error boundaries are inherently client-side)
  • The reset function re-renders the segment — useful for transient errors
  • error.digest is a server-generated hash that maps to your server logs without exposing internal details to the client
  • Place error.tsx files at different route levels to give users contextual error messages

not-found.tsx: Intentional 404s

notFound() is the correct way to return a 404 from a Server Component:

code
// app/blog/[slug]/page.tsx
import { notFound } from 'next/navigation';

export default async function BlogPost({ params }: { params: { slug: string } }) {
  const post = await getPost(params.slug);
  
  if (!post) {
    notFound(); // Throws internally, caught by nearest not-found.tsx
  }
  
  return <Article post={post} />;
}
code
// app/blog/not-found.tsx
export default function PostNotFound() {
  return (
    <div>
      <h1>Post not found</h1>
      <p>This article may have been moved or deleted.</p>
      <a href="/blog">Browse all posts</a>
    </div>
  );
}

Place not-found.tsx files in the same segments as your dynamic routes. The root app/not-found.tsx handles 404s that don't match any segment-specific file.

global-error.tsx: The Last Resort

global-error.tsx catches errors in your root app/layout.tsx. Because the layout has crashed, this component is responsible for rendering the entire page — including <html> and <body>:

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

export default function GlobalError({
  error,
  reset,
}: {
  error: Error & { digest?: string };
  reset: () => void;
}) {
  return (
    <html>
      <body>
        <div className="min-h-screen flex items-center justify-center">
          <div>
            <h1>Something went seriously wrong</h1>
            <button onClick={reset}>Reload application</button>
          </div>
        </div>
      </body>
    </html>
  );
}

This should be treated as a nuclear option — the user experience here is already bad, so focus on clarity and recovery options.

Error Boundaries in Async Server Components

Server Component errors don't work like client-side errors — they're thrown during the server render and surfaced to the nearest error.tsx. But you can also handle them more granularly:

code
// Wrap individual data fetches in try/catch for partial failures
export default async function Dashboard() {
  const [metrics, recentActivity] = await Promise.allSettled([
    getMetrics(),
    getRecentActivity(),
  ]);

  return (
    <div>
      {metrics.status === 'fulfilled' ? (
        <MetricsPanel data={metrics.value} />
      ) : (
        <MetricsError />
      )}
      {recentActivity.status === 'fulfilled' ? (
        <ActivityFeed data={recentActivity.value} />
      ) : (
        <ActivityFeedError />
      )}
    </div>
  );
}

Promise.allSettled instead of Promise.all lets partial page content succeed even when one data source fails — dramatically better UX than an error page for the whole dashboard.

Typed Errors for Better DX

Rather than catching generic Error objects, define typed error classes:

code
// lib/errors.ts
export class NotFoundError extends Error {
  constructor(resource: string) {
    super(`${resource} not found`);
    this.name = 'NotFoundError';
  }
}

export class UnauthorizedError extends Error {
  constructor() {
    super('Unauthorized');
    this.name = 'UnauthorizedError';
  }
}

export class ValidationError extends Error {
  constructor(public fields: Record<string, string>) {
    super('Validation failed');
    this.name = 'ValidationError';
  }
}

In your Server Actions and API routes:

code
export async function getProject(id: string) {
  const project = await db.projects.findUnique({ where: { id } });
  
  if (!project) throw new NotFoundError('Project');
  if (!canAccess(currentUser, project)) throw new UnauthorizedError();
  
  return project;
}

In error boundaries, check the error type to provide appropriate UI:

code
'use client';

import { NotFoundError, UnauthorizedError } from '@/lib/errors';

export default function ProjectError({ error }: { error: Error }) {
  if (error instanceof UnauthorizedError) {
    return <div>You don't have access to this project.</div>;
  }
  
  return <div>Failed to load project. <button>Retry</button></div>;
}

Logging Errors in Production

Client-side errors caught by error boundaries need to be logged to a monitoring service. The useEffect in an error boundary is the right place:

code
'use client';

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

export default function ErrorBoundary({ error }: { error: Error }) {
  useEffect(() => {
    Sentry.captureException(error, {
      extra: { digest: error.digest },
    });
  }, [error]);

  // ...
}

For Server Component errors, Next.js automatically includes them in server logs. If you're using Vercel, these appear in the Runtime Logs. Add structured logging with a library like pino for searchable, filterable error records.

The Production Error Checklist

  • error.tsx at every route segment that fetches data
  • not-found.tsx for every dynamic route
  • global-error.tsx as a catch-all
  • Error logging wired to a monitoring service (Sentry, Datadog)
  • Promise.allSettled for pages with multiple independent data sources
  • User-facing error messages that don't expose internal details
  • reset function wired to the retry button in all error boundaries
  • Error responses from Server Actions handled in the UI layer

Errors in production are inevitable. How your application handles them determines whether users recover gracefully or churn.

Stay Informed.

Join 1,200+ founders and engineers receiving our monthly deep dives on product engineering, design, and growth.

Insights once a month. No spam. Unsubscribe anytime.