NextJS DOC
Part 9 of 15Next.js Error Handling Deep Dive: Expected Errors, Uncaught Exceptions, and Recovery Patterns
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
- Expected vs Uncaught: Return expected errors as values; let error boundaries catch unexpected exceptions
- useActionState for Forms: Handle form validation errors with
useActionStateand return error objects - notFound() for 404s: Use
notFound()function withnot-found.tsxfor missing resources - error.tsx Placement: Place
error.tsxat appropriate route levels; it catches children but not sibling layouts - global-error.tsx for Root: Use
global-error.tsxto catch root layout errors (must include<html>and<body>) - catchError for Components: Use
unstable_catchErrorfor component-level error boundaries outside route structure - unstable_retry Over reset: Use
unstable_retry()for recovery; it re-fetches data unlikereset() - Event Handler Errors: Handle errors in event handlers manually with try/catch and useState
- useTransition Bubbles: Errors in
startTransitioncallbacks bubble to error boundaries - Production Security: Never expose internal error details in production; use digests for debugging
References
What did you think?
Related Posts
April 4, 202697 min
Next.js Proxy Deep Dive: Edge-First Request Interception
April 4, 2026164 min
Next.js Route Handlers Deep Dive: Building Production APIs with Web Standards
April 4, 202691 min