Back to Blog

Next.js Error Handling Deep Dive: Expected Errors, Uncaught Exceptions, and Recovery Patterns

March 31, 2026102 min read0 views

Next.js Error Handling Deep Dive: Expected Errors, Uncaught Exceptions, and Recovery Patterns

Introduction

Error handling in Next.js requires understanding the distinction between expected errors (validation failures, API errors) and uncaught exceptions (bugs, crashes). Expected errors should be returned as values and displayed gracefully; uncaught exceptions should be caught by error boundaries and trigger fallback UI.

This guide covers the complete error handling architecture in Next.js, including file conventions, programmatic error boundaries, recovery patterns, and production-ready implementations.

Error Architecture Overview

┌─────────────────────────────────────────────────────────────────────────────┐
│                      NEXT.JS ERROR HANDLING ARCHITECTURE                    │
├─────────────────────────────────────────────────────────────────────────────┤
│                                                                             │
│  ┌─────────────────────────────────────────────────────────────────────┐   │
│  │                         EXPECTED ERRORS                              │   │
│  │                    (Return values, not thrown)                       │   │
│  │                                                                      │   │
│  │  ┌──────────────────┐  ┌──────────────────┐  ┌──────────────────┐   │   │
│  │  │  Form Validation │  │  API Responses   │  │  Business Logic  │   │   │
│  │  │  (Zod, etc.)     │  │  (!res.ok)       │  │  (Invalid state) │   │   │
│  │  └────────┬─────────┘  └────────┬─────────┘  └────────┬─────────┘   │   │
│  │           │                     │                     │             │   │
│  │           └─────────────────────┼─────────────────────┘             │   │
│  │                                 ▼                                   │   │
│  │  ┌──────────────────────────────────────────────────────────────┐  │   │
│  │  │  Return { success: false, errors: [...] }                    │  │   │
│  │  │  Display via useActionState or conditional rendering         │  │   │
│  │  └──────────────────────────────────────────────────────────────┘  │   │
│  └─────────────────────────────────────────────────────────────────────┘   │
│                                                                             │
│  ┌─────────────────────────────────────────────────────────────────────┐   │
│  │                       UNCAUGHT EXCEPTIONS                            │   │
│  │                    (Thrown errors, caught by boundaries)             │   │
│  │                                                                      │   │
│  │  ┌──────────────────┐  ┌──────────────────┐  ┌──────────────────┐   │   │
│  │  │  Database Error  │  │  Network Failure │  │  Programming Bug │   │   │
│  │  │  (Connection)    │  │  (Timeout)       │  │  (TypeError)     │   │   │
│  │  └────────┬─────────┘  └────────┬─────────┘  └────────┬─────────┘   │   │
│  │           │                     │                     │             │   │
│  │           └─────────────────────┼─────────────────────┘             │   │
│  │                                 ▼                                   │   │
│  │                       ERROR BOUNDARY HIERARCHY                      │   │
│  │                                 │                                   │   │
│  │    ┌────────────────────────────┼────────────────────────────┐     │   │
│  │    ▼                            ▼                            ▼     │   │
│  │  ┌─────────────┐    ┌─────────────────┐    ┌──────────────────┐   │   │
│  │  │catchError() │    │   error.tsx     │    │  global-error.tsx│   │   │
│  │  │(Component)  │    │   (Route)       │    │  (Root Layout)   │   │   │
│  │  └─────────────┘    └─────────────────┘    └──────────────────┘   │   │
│  └─────────────────────────────────────────────────────────────────────┘   │
│                                                                             │
│  ┌─────────────────────────────────────────────────────────────────────┐   │
│  │                         NOT FOUND ERRORS                             │   │
│  │                                                                      │   │
│  │  ┌──────────────────────────────────────────────────────────────┐   │   │
│  │  │  notFound() function → not-found.tsx file                    │   │   │
│  │  │  Triggers 404 response with custom UI                        │   │   │
│  │  └──────────────────────────────────────────────────────────────┘   │   │
│  └─────────────────────────────────────────────────────────────────────┘   │
│                                                                             │
└─────────────────────────────────────────────────────────────────────────────┘

Expected Errors: Return Values, Not Exceptions

Core Principle

Expected errors (validation failures, API errors, business rule violations) should be returned as values, not thrown. This makes them explicit, type-safe, and easier to handle.

// ❌ WRONG - Throwing expected errors
async function createUser(data: UserData) {
  'use server'
  if (!data.email.includes('@')) {
    throw new Error('Invalid email')  // Don't throw expected errors!
  }
  // ...
}

// ✅ CORRECT - Return expected errors
async function createUser(
  prevState: ActionState,
  formData: FormData
): Promise<ActionState> {
  'use server'
  const email = formData.get('email') as string

  if (!email.includes('@')) {
    return {
      success: false,
      errors: { email: ['Invalid email format'] },
    }
  }
  // ...
}

Server Action Error Pattern

// lib/types.ts
export type ActionState = {
  success: boolean
  message?: string
  errors?: Record<string, string[]>
  data?: unknown
}

// app/users/actions.ts
'use server'

import { z } from 'zod'

const CreateUserSchema = z.object({
  name: z.string().min(1, 'Name is required').max(100, 'Name too long'),
  email: z.string().email('Invalid email format'),
  age: z.coerce.number().min(18, 'Must be at least 18').optional(),
})

export async function createUser(
  prevState: ActionState,
  formData: FormData
): Promise<ActionState> {
  // 1. Validate input
  const validated = CreateUserSchema.safeParse({
    name: formData.get('name'),
    email: formData.get('email'),
    age: formData.get('age'),
  })

  if (!validated.success) {
    return {
      success: false,
      errors: validated.error.flatten().fieldErrors,
    }
  }

  // 2. Check business rules
  const existingUser = await db.users.findUnique({
    where: { email: validated.data.email },
  })

  if (existingUser) {
    return {
      success: false,
      errors: { email: ['Email already registered'] },
    }
  }

  // 3. Attempt database operation (might throw - that's an uncaught exception)
  const user = await db.users.create({
    data: validated.data,
  })

  return {
    success: true,
    message: 'User created successfully',
    data: { id: user.id },
  }
}

Client Component with useActionState

// app/users/create/page.tsx
'use client'

import { useActionState } from 'react'
import { createUser } from '../actions'
import type { ActionState } from '@/lib/types'

const initialState: ActionState = {
  success: false,
  message: '',
  errors: {},
}

export default function CreateUserForm() {
  const [state, formAction, isPending] = useActionState(createUser, initialState)

  return (
    <form action={formAction}>
      <div>
        <label htmlFor="name">Name</label>
        <input
          type="text"
          id="name"
          name="name"
          aria-describedby="name-error"
        />
        {state.errors?.name && (
          <p id="name-error" className="error" aria-live="polite">
            {state.errors.name.join(', ')}
          </p>
        )}
      </div>

      <div>
        <label htmlFor="email">Email</label>
        <input
          type="email"
          id="email"
          name="email"
          aria-describedby="email-error"
        />
        {state.errors?.email && (
          <p id="email-error" className="error" aria-live="polite">
            {state.errors.email.join(', ')}
          </p>
        )}
      </div>

      <div>
        <label htmlFor="age">Age (optional)</label>
        <input
          type="number"
          id="age"
          name="age"
          aria-describedby="age-error"
        />
        {state.errors?.age && (
          <p id="age-error" className="error" aria-live="polite">
            {state.errors.age.join(', ')}
          </p>
        )}
      </div>

      {/* General message (success or error) */}
      {state.message && (
        <p
          className={state.success ? 'success' : 'error'}
          aria-live="polite"
        >
          {state.message}
        </p>
      )}

      <button type="submit" disabled={isPending}>
        {isPending ? 'Creating...' : 'Create User'}
      </button>
    </form>
  )
}

Server Component Conditional Rendering

// app/dashboard/page.tsx
export default async function DashboardPage() {
  const res = await fetch('https://api.example.com/dashboard', {
    headers: { Authorization: `Bearer ${await getToken()}` },
  })

  // Handle expected API errors
  if (!res.ok) {
    if (res.status === 401) {
      redirect('/login')
    }

    if (res.status === 403) {
      return (
        <div className="error-container">
          <h1>Access Denied</h1>
          <p>You don't have permission to view this dashboard.</p>
          <a href="/request-access">Request Access</a>
        </div>
      )
    }

    // Other expected errors
    return (
      <div className="error-container">
        <h1>Unable to Load Dashboard</h1>
        <p>Please try again later.</p>
      </div>
    )
  }

  const data = await res.json()
  return <Dashboard data={data} />
}

Not Found Handling

The notFound() Function

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

export default async function PostPage({
  params,
}: {
  params: Promise<{ slug: string }>
}) {
  const { slug } = await params
  const post = await db.posts.findUnique({
    where: { slug, status: 'published' },
  })

  if (!post) {
    notFound()  // Triggers not-found.tsx
  }

  return <Article post={post} />
}

The not-found.tsx File

// app/posts/[slug]/not-found.tsx
import Link from 'next/link'

export default function PostNotFound() {
  return (
    <div className="not-found">
      <h1>Post Not Found</h1>
      <p>The post you're looking for doesn't exist or has been removed.</p>
      <div className="actions">
        <Link href="/posts">Browse all posts</Link>
        <Link href="/">Go home</Link>
      </div>
    </div>
  )
}

// app/not-found.tsx (global fallback)
import Link from 'next/link'

export default function GlobalNotFound() {
  return (
    <div className="not-found">
      <h1>404 - Page Not Found</h1>
      <p>Sorry, we couldn't find the page you're looking for.</p>
      <Link href="/">Return Home</Link>
    </div>
  )
}

Error Boundaries: Uncaught Exceptions

Error Boundary Hierarchy

┌─────────────────────────────────────────────────────────────────────────────┐
│                         ERROR BOUNDARY HIERARCHY                            │
├─────────────────────────────────────────────────────────────────────────────┤
│                                                                             │
│  app/                                                                       │
│  ├── global-error.tsx ◀─── Catches errors in root layout                   │
│  ├── layout.tsx                                                             │
│  ├── error.tsx ◀─────────── Does NOT catch errors in this layout           │
│  │                          (error.tsx wraps page, not sibling layout)     │
│  ├── page.tsx                                                               │
│  │                                                                          │
│  └── dashboard/                                                             │
│      ├── layout.tsx ◀─────── Error in this layout bubbles to parent       │
│      ├── error.tsx ◀──────── Catches errors in page.tsx and below         │
│      ├── page.tsx                                                           │
│      │                                                                      │
│      └── settings/                                                          │
│          ├── error.tsx ◀──── Most specific boundary, catches first        │
│          └── page.tsx                                                       │
│                                                                             │
│  HIERARCHY RULES:                                                           │
│  ┌──────────────────────────────────────────────────────────────────────┐  │
│  │  1. error.tsx catches errors in:                                     │  │
│  │     ✓ page.tsx in same segment                                       │  │
│  │     ✓ loading.tsx in same segment                                    │  │
│  │     ✓ not-found.tsx in same segment                                  │  │
│  │     ✓ All nested route segments                                      │  │
│  │                                                                       │  │
│  │  2. error.tsx does NOT catch errors in:                              │  │
│  │     ✗ layout.tsx in SAME segment (use parent error.tsx)              │  │
│  │     ✗ template.tsx in SAME segment                                   │  │
│  │                                                                       │  │
│  │  3. Errors bubble up until caught by an error boundary               │  │
│  │                                                                       │  │
│  │  4. global-error.tsx catches errors in root layout.tsx               │  │
│  └──────────────────────────────────────────────────────────────────────┘  │
│                                                                             │
└─────────────────────────────────────────────────────────────────────────────┘

The error.tsx File Convention

// app/dashboard/error.tsx
'use client'  // REQUIRED - Error boundaries must be Client Components

import { useEffect } from 'react'

interface ErrorProps {
  error: Error & { digest?: string }
  unstable_retry: () => void  // Re-fetch and re-render
  reset: () => void           // Re-render without re-fetch (legacy)
}

export default function DashboardError({
  error,
  unstable_retry,
  reset,
}: ErrorProps) {
  useEffect(() => {
    // Log to error reporting service
    console.error('Dashboard error:', error)

    // Example: Sentry integration
    // Sentry.captureException(error, {
    //   tags: { segment: 'dashboard' },
    //   extra: { digest: error.digest },
    // })
  }, [error])

  return (
    <div className="error-container" role="alert">
      <h2>Dashboard Error</h2>

      {/* Only show message in development */}
      {process.env.NODE_ENV === 'development' && (
        <pre className="error-message">{error.message}</pre>
      )}

      {/* Show digest for support tickets */}
      {error.digest && (
        <p className="error-digest">
          Error ID: <code>{error.digest}</code>
        </p>
      )}

      <div className="error-actions">
        <button onClick={() => unstable_retry()}>
          Try Again
        </button>
        <button onClick={() => window.location.reload()}>
          Reload Page
        </button>
      </div>
    </div>
  )
}

unstable_retry vs reset

┌─────────────────────────────────────────────────────────────────────────────┐
│                       RECOVERY FUNCTION COMPARISON                          │
├─────────────────────────────────────────────────────────────────────────────┤
│                                                                             │
│  unstable_retry()                           reset()                         │
│  ┌─────────────────────────────┐           ┌─────────────────────────────┐ │
│  │  1. Re-fetches data         │           │  1. Clears error state      │ │
│  │  2. Re-renders segment      │           │  2. Re-renders segment      │ │
│  │  3. Server Components run   │           │  3. NO data re-fetch        │ │
│  │     again                   │           │                             │ │
│  └─────────────────────────────┘           └─────────────────────────────┘ │
│                                                                             │
│  USE CASE:                                  USE CASE:                       │
│  • Network failures                         • Client-only errors           │
│  • Database timeouts                        • Race conditions              │
│  • External API errors                      • State inconsistencies        │
│  • Server Component errors                  • (Rarely needed)              │
│                                                                             │
│  RECOMMENDATION: Use unstable_retry() by default                           │
│  reset() doesn't recover from Server Component errors                      │
│                                                                             │
└─────────────────────────────────────────────────────────────────────────────┘

Global Error Boundary

The global-error.tsx catches errors in the root layout. It must define its own <html> and <body> tags since it replaces the root layout.

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

import { useEffect } from 'react'

interface GlobalErrorProps {
  error: Error & { digest?: string }
  unstable_retry: () => void
}

export default function GlobalError({
  error,
  unstable_retry,
}: GlobalErrorProps) {
  useEffect(() => {
    // Log critical error
    console.error('Critical application error:', error)

    // Report to monitoring service
    // reportCriticalError(error)
  }, [error])

  return (
    <html lang="en">
      <head>
        <title>Application Error</title>
        <meta name="viewport" content="width=device-width, initial-scale=1" />
        {/* Include minimal critical styles */}
        <style>{`
          body {
            font-family: system-ui, sans-serif;
            display: flex;
            align-items: center;
            justify-content: center;
            min-height: 100vh;
            margin: 0;
            background: #f5f5f5;
          }
          .error-container {
            text-align: center;
            padding: 2rem;
            background: white;
            border-radius: 8px;
            box-shadow: 0 2px 10px rgba(0,0,0,0.1);
            max-width: 400px;
          }
          button {
            background: #0070f3;
            color: white;
            border: none;
            padding: 0.75rem 1.5rem;
            border-radius: 4px;
            cursor: pointer;
            margin-top: 1rem;
          }
          button:hover {
            background: #0051a8;
          }
        `}</style>
      </head>
      <body>
        <div className="error-container">
          <h1>Something went wrong</h1>
          <p>We apologize for the inconvenience. Please try again.</p>
          {error.digest && (
            <p>
              <small>Error ID: {error.digest}</small>
            </p>
          )}
          <button onClick={() => unstable_retry()}>
            Try again
          </button>
        </div>
      </body>
    </html>
  )
}

Programmatic Error Boundaries with catchError

Creating Reusable Error Boundaries

// components/ErrorBoundary.tsx
'use client'

import { unstable_catchError as catchError, type ErrorInfo } from 'next/error'
import { useEffect } from 'react'

interface ErrorFallbackProps {
  title?: string
  showRetry?: boolean
  onError?: (error: Error, errorInfo: ErrorInfo) => void
}

function ErrorFallback(
  props: ErrorFallbackProps,
  { error, unstable_retry: retry }: ErrorInfo
) {
  const { title = 'Something went wrong', showRetry = true, onError } = props

  useEffect(() => {
    if (onError) {
      onError(error, { error, unstable_retry: retry, reset: () => {} })
    }
  }, [error, onError, retry])

  return (
    <div className="error-fallback" role="alert">
      <h3>{title}</h3>
      <p className="error-message">{error.message}</p>
      {showRetry && (
        <button onClick={() => retry()}>Try Again</button>
      )}
    </div>
  )
}

export const ErrorBoundary = catchError(ErrorFallback)

Using Component-Level Error Boundaries

// app/dashboard/page.tsx
import { ErrorBoundary } from '@/components/ErrorBoundary'
import { reportError } from '@/lib/monitoring'

export default function DashboardPage() {
  return (
    <div className="dashboard">
      <h1>Dashboard</h1>

      {/* Each widget has its own error boundary */}
      <div className="widgets">
        <ErrorBoundary
          title="Analytics Error"
          onError={(error) => reportError(error, { widget: 'analytics' })}
        >
          <AnalyticsWidget />
        </ErrorBoundary>

        <ErrorBoundary
          title="Revenue Error"
          onError={(error) => reportError(error, { widget: 'revenue' })}
        >
          <RevenueWidget />
        </ErrorBoundary>

        <ErrorBoundary
          title="Users Error"
          showRetry={false}  // No retry for this widget
        >
          <UsersWidget />
        </ErrorBoundary>
      </div>
    </div>
  )
}

Server-Rendered Error Fallback

Pass pre-rendered fallback content for data-driven error UI:

// components/DataDrivenErrorBoundary.tsx
'use client'

import { unstable_catchError as catchError, type ErrorInfo } from 'next/error'

interface FallbackProps {
  fallback: React.ReactNode
}

function ErrorFallback(props: FallbackProps, errorInfo: ErrorInfo) {
  return props.fallback
}

export const DataDrivenErrorBoundary = catchError(ErrorFallback)
// app/products/page.tsx
import { DataDrivenErrorBoundary } from '@/components/DataDrivenErrorBoundary'

// Server Component for error fallback
async function ProductsErrorFallback() {
  const alternatives = await db.products.findMany({
    where: { featured: true },
    take: 4,
  })

  return (
    <div className="error-fallback">
      <h2>Unable to load products</h2>
      <p>Check out these featured items instead:</p>
      <ProductGrid products={alternatives} />
    </div>
  )
}

export default function ProductsPage() {
  return (
    <DataDrivenErrorBoundary fallback={<ProductsErrorFallback />}>
      <ProductCatalog />
    </DataDrivenErrorBoundary>
  )
}

Error Handling in Event Handlers

Error boundaries only catch errors during rendering. Errors in event handlers or async code must be handled manually:

'use client'

import { useState } from 'react'

export function DeleteButton({ itemId }: { itemId: string }) {
  const [error, setError] = useState<string | null>(null)
  const [isDeleting, setIsDeleting] = useState(false)

  async function handleDelete() {
    setError(null)
    setIsDeleting(true)

    try {
      const response = await fetch(`/api/items/${itemId}`, {
        method: 'DELETE',
      })

      if (!response.ok) {
        const data = await response.json()
        throw new Error(data.message || 'Failed to delete')
      }

      // Success - redirect or update UI
    } catch (err) {
      setError(err instanceof Error ? err.message : 'An error occurred')
    } finally {
      setIsDeleting(false)
    }
  }

  return (
    <div>
      <button
        onClick={handleDelete}
        disabled={isDeleting}
      >
        {isDeleting ? 'Deleting...' : 'Delete'}
      </button>

      {error && (
        <p className="error" role="alert">
          {error}
        </p>
      )}
    </div>
  )
}

useTransition Errors Bubble to Boundaries

Errors inside startTransition from useTransition do bubble to error boundaries:

'use client'

import { useTransition } from 'react'
import { deleteItem } from './actions'

export function DeleteButtonWithTransition({ itemId }: { itemId: string }) {
  const [isPending, startTransition] = useTransition()

  function handleClick() {
    startTransition(async () => {
      // If this throws, it bubbles to nearest error boundary
      await deleteItem(itemId)
    })
  }

  return (
    <button onClick={handleClick} disabled={isPending}>
      {isPending ? 'Deleting...' : 'Delete'}
    </button>
  )
}

Production Error Patterns

Pattern 1: Comprehensive Form Validation

// lib/validation.ts
import { z } from 'zod'

export const ProductSchema = z.object({
  name: z.string()
    .min(1, 'Name is required')
    .max(100, 'Name must be 100 characters or less'),
  description: z.string()
    .min(10, 'Description must be at least 10 characters')
    .max(1000, 'Description must be 1000 characters or less'),
  price: z.coerce.number()
    .positive('Price must be positive')
    .multipleOf(0.01, 'Price can have at most 2 decimal places'),
  category: z.enum(['electronics', 'clothing', 'food', 'other'], {
    errorMap: () => ({ message: 'Please select a valid category' }),
  }),
  stock: z.coerce.number()
    .int('Stock must be a whole number')
    .min(0, 'Stock cannot be negative'),
  images: z.array(z.string().url('Invalid image URL'))
    .min(1, 'At least one image is required')
    .max(5, 'Maximum 5 images allowed'),
})

export type ProductInput = z.infer<typeof ProductSchema>

// app/products/actions.ts
'use server'

import { ProductSchema, type ProductInput } from '@/lib/validation'
import { revalidatePath } from 'next/cache'

type FormState = {
  success: boolean
  message?: string
  errors?: z.ZodError<ProductInput>['formErrors']['fieldErrors']
}

export async function createProduct(
  prevState: FormState,
  formData: FormData
): Promise<FormState> {
  // Parse array fields
  const images = formData.getAll('images') as string[]

  const validated = ProductSchema.safeParse({
    name: formData.get('name'),
    description: formData.get('description'),
    price: formData.get('price'),
    category: formData.get('category'),
    stock: formData.get('stock'),
    images,
  })

  if (!validated.success) {
    return {
      success: false,
      errors: validated.error.flatten().fieldErrors,
    }
  }

  // Check for duplicate name
  const existing = await db.products.findFirst({
    where: { name: validated.data.name },
  })

  if (existing) {
    return {
      success: false,
      errors: { name: ['A product with this name already exists'] },
    }
  }

  // Create product
  await db.products.create({ data: validated.data })

  revalidatePath('/products')

  return {
    success: true,
    message: 'Product created successfully',
  }
}

Pattern 2: Graceful Degradation Error Boundary

Preserve the last working UI when errors occur:

// components/GracefulErrorBoundary.tsx
'use client'

import { Component, type ReactNode, type ErrorInfo, createRef } from 'react'

interface Props {
  children: ReactNode
  onError?: (error: Error, errorInfo: ErrorInfo) => void
}

interface State {
  hasError: boolean
  preservedHTML: string
}

export class GracefulErrorBoundary extends Component<Props, State> {
  private contentRef = createRef<HTMLDivElement>()

  constructor(props: Props) {
    super(props)
    this.state = {
      hasError: false,
      preservedHTML: '',
    }
  }

  static getDerivedStateFromError(): Partial<State> {
    return { hasError: true }
  }

  componentDidCatch(error: Error, errorInfo: ErrorInfo) {
    // Preserve current HTML before state update
    if (this.contentRef.current) {
      this.setState({
        preservedHTML: this.contentRef.current.innerHTML,
      })
    }

    this.props.onError?.(error, errorInfo)
  }

  render() {
    if (this.state.hasError) {
      return (
        <>
          {/* Render preserved HTML (non-interactive) */}
          <div
            suppressHydrationWarning
            dangerouslySetInnerHTML={{ __html: this.state.preservedHTML }}
            style={{ opacity: 0.7, pointerEvents: 'none' }}
          />

          {/* Error notification bar */}
          <div className="error-bar">
            <p>An error occurred. The page may not be fully functional.</p>
            <button onClick={() => window.location.reload()}>
              Reload Page
            </button>
          </div>
        </>
      )
    }

    return <div ref={this.contentRef}>{this.props.children}</div>
  }
}

Pattern 3: Error Logging Integration

// lib/error-reporting.ts
interface ErrorContext {
  digest?: string
  route?: string
  userId?: string
  sessionId?: string
  component?: string
  extra?: Record<string, unknown>
}

export function reportError(error: Error, context: ErrorContext = {}) {
  // Development logging
  if (process.env.NODE_ENV === 'development') {
    console.error('Error reported:', { error, context })
    return
  }

  // Production error reporting
  // Example: Sentry
  // Sentry.captureException(error, {
  //   tags: {
  //     route: context.route,
  //     component: context.component,
  //   },
  //   user: {
  //     id: context.userId,
  //     session_id: context.sessionId,
  //   },
  //   extra: context.extra,
  // })

  // Or send to custom endpoint
  fetch('/api/errors', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      message: error.message,
      stack: error.stack,
      digest: context.digest,
      ...context,
      timestamp: new Date().toISOString(),
    }),
  }).catch(console.error)
}

// Usage in error.tsx
// app/dashboard/error.tsx
'use client'

import { useEffect } from 'react'
import { usePathname } from 'next/navigation'
import { reportError } from '@/lib/error-reporting'

export default function DashboardError({
  error,
  unstable_retry,
}: {
  error: Error & { digest?: string }
  unstable_retry: () => void
}) {
  const pathname = usePathname()

  useEffect(() => {
    reportError(error, {
      digest: error.digest,
      route: pathname,
      component: 'Dashboard',
    })
  }, [error, pathname])

  return (
    <div className="error-container">
      <h2>Dashboard Error</h2>
      {error.digest && <p>Error ID: {error.digest}</p>}
      <button onClick={() => unstable_retry()}>Try Again</button>
    </div>
  )
}

Pattern 4: API Route Error Handling

// app/api/products/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'

const ProductQuerySchema = z.object({
  category: z.string().optional(),
  page: z.coerce.number().int().positive().default(1),
  limit: z.coerce.number().int().min(1).max(100).default(20),
})

export async function GET(request: NextRequest) {
  try {
    const searchParams = request.nextUrl.searchParams
    const query = ProductQuerySchema.safeParse({
      category: searchParams.get('category'),
      page: searchParams.get('page'),
      limit: searchParams.get('limit'),
    })

    if (!query.success) {
      return NextResponse.json(
        {
          error: {
            code: 'VALIDATION_ERROR',
            message: 'Invalid query parameters',
            details: query.error.flatten().fieldErrors,
          },
        },
        { status: 400 }
      )
    }

    const { category, page, limit } = query.data
    const skip = (page - 1) * limit

    const [products, total] = await Promise.all([
      db.products.findMany({
        where: category ? { category } : undefined,
        skip,
        take: limit,
        orderBy: { createdAt: 'desc' },
      }),
      db.products.count({
        where: category ? { category } : undefined,
      }),
    ])

    return NextResponse.json({
      data: products,
      meta: {
        page,
        limit,
        total,
        totalPages: Math.ceil(total / limit),
      },
    })
  } catch (error) {
    console.error('Products API error:', error)

    // Don't expose internal errors
    return NextResponse.json(
      {
        error: {
          code: 'INTERNAL_ERROR',
          message: 'An unexpected error occurred',
        },
      },
      { status: 500 }
    )
  }
}

Error Security Considerations

┌─────────────────────────────────────────────────────────────────────────────┐
│                       ERROR SECURITY CONSIDERATIONS                         │
├─────────────────────────────────────────────────────────────────────────────┤
│                                                                             │
│  CLIENT VS SERVER ERROR EXPOSURE                                            │
│  ┌──────────────────────────────────────────────────────────────────────┐  │
│  │  DEVELOPMENT                         PRODUCTION                      │  │
│  │  • Full error message visible        • Generic message only          │  │
│  │  • Stack trace available             • No stack trace                │  │
│  │  • Debugging info shown              • Only digest (hash) shown      │  │
│  │                                                                       │  │
│  │  Server Component errors are ALWAYS sanitized in production:         │  │
│  │  - Original error logged server-side with digest                     │  │
│  │  - Generic message sent to client                                    │  │
│  │  - Digest can match server logs                                      │  │
│  └──────────────────────────────────────────────────────────────────────┘  │
│                                                                             │
│  WHAT NOT TO EXPOSE                                                         │
│  ┌──────────────────────────────────────────────────────────────────────┐  │
│  │  ✗ Database connection strings                                       │  │
│  │  ✗ Internal IP addresses                                             │  │
│  │  ✗ File system paths                                                 │  │
│  │  ✗ SQL queries or database schema                                    │  │
│  │  ✗ API keys or tokens                                                │  │
│  │  ✗ User data from other users                                        │  │
│  │  ✗ Internal service names/architecture                               │  │
│  └──────────────────────────────────────────────────────────────────────┘  │
│                                                                             │
│  SAFE ERROR RESPONSE PATTERN                                                │
│  ┌──────────────────────────────────────────────────────────────────────┐  │
│  │  {                                                                    │  │
│  │    "error": {                                                         │  │
│  │      "code": "VALIDATION_ERROR",      // Safe, generic code          │  │
│  │      "message": "Invalid input",      // Safe, user-friendly         │  │
│  │      "digest": "abc123"               // For support/debugging       │  │
│  │    }                                                                  │  │
│  │  }                                                                    │  │
│  └──────────────────────────────────────────────────────────────────────┘  │
│                                                                             │
└─────────────────────────────────────────────────────────────────────────────┘

Key Takeaways

  1. Expected vs Uncaught: Return expected errors as values; let error boundaries catch unexpected exceptions
  2. useActionState for Forms: Handle form validation errors with useActionState and return error objects
  3. notFound() for 404s: Use notFound() function with not-found.tsx for missing resources
  4. error.tsx Placement: Place error.tsx at appropriate route levels; it catches children but not sibling layouts
  5. global-error.tsx for Root: Use global-error.tsx to catch root layout errors (must include <html> and <body>)
  6. catchError for Components: Use unstable_catchError for component-level error boundaries outside route structure
  7. unstable_retry Over reset: Use unstable_retry() for recovery; it re-fetches data unlike reset()
  8. Event Handler Errors: Handle errors in event handlers manually with try/catch and useState
  9. useTransition Bubbles: Errors in startTransition callbacks bubble to error boundaries
  10. Production Security: Never expose internal error details in production; use digests for debugging

References

What did you think?

Related Posts

© 2026 Vidhya Sagar Thakur. All rights reserved.