Error Handling Pattern

Build consistent, type-safe API error responses with custom error classes, error handlers, and client-side error parsing.

Overview#

Consistent error handling is crucial for API usability and debugging. This pattern provides a structured approach to creating, throwing, and handling errors across your API routes.

When to use:

  • Building REST APIs that need consistent error formats
  • Implementing error boundaries for API routes
  • Creating reusable error handling utilities
  • Integrating with error tracking services

Key features:

  • Custom error classes with status codes
  • Consistent JSON error response format
  • Zod validation error handling
  • Error logging and tracking
  • Client-side error parsing

Code Example#

Error Classes#

1// lib/errors.ts 2export class AppError extends Error { 3 constructor( 4 message: string, 5 public statusCode: number = 500, 6 public code: string = 'INTERNAL_ERROR' 7 ) { 8 super(message) 9 this.name = 'AppError' 10 } 11} 12 13export class NotFoundError extends AppError { 14 constructor(resource: string) { 15 super(`${resource} not found`, 404, 'NOT_FOUND') 16 this.name = 'NotFoundError' 17 } 18} 19 20export class UnauthorizedError extends AppError { 21 constructor(message = 'Unauthorized') { 22 super(message, 401, 'UNAUTHORIZED') 23 this.name = 'UnauthorizedError' 24 } 25} 26 27export class ForbiddenError extends AppError { 28 constructor(message = 'Forbidden') { 29 super(message, 403, 'FORBIDDEN') 30 this.name = 'ForbiddenError' 31 } 32} 33 34export class ValidationError extends AppError { 35 constructor( 36 message: string, 37 public errors: Record<string, string[]> = {} 38 ) { 39 super(message, 400, 'VALIDATION_ERROR') 40 this.name = 'ValidationError' 41 } 42} 43 44export class ConflictError extends AppError { 45 constructor(message: string) { 46 super(message, 409, 'CONFLICT') 47 this.name = 'ConflictError' 48 } 49} 50 51export class RateLimitError extends AppError { 52 constructor(retryAfter: number) { 53 super('Too many requests', 429, 'RATE_LIMIT_EXCEEDED') 54 this.name = 'RateLimitError' 55 } 56}

Error Response Format#

1// lib/api-response.ts 2interface ErrorResponse { 3 error: { 4 message: string 5 code: string 6 details?: Record<string, unknown> 7 } 8} 9 10interface SuccessResponse<T> { 11 data: T 12} 13 14export function errorResponse( 15 error: AppError | Error 16): { body: ErrorResponse; status: number } { 17 if (error instanceof AppError) { 18 return { 19 body: { 20 error: { 21 message: error.message, 22 code: error.code, 23 details: error instanceof ValidationError ? { errors: error.errors } : undefined 24 } 25 }, 26 status: error.statusCode 27 } 28 } 29 30 // Unknown error - don't expose details 31 console.error('Unhandled error:', error) 32 return { 33 body: { 34 error: { 35 message: 'An unexpected error occurred', 36 code: 'INTERNAL_ERROR' 37 } 38 }, 39 status: 500 40 } 41} 42 43export function successResponse<T>(data: T): SuccessResponse<T> { 44 return { data } 45}

Error Handler Wrapper#

1// lib/api-handler.ts 2import { NextRequest, NextResponse } from 'next/server' 3import { AppError, ValidationError, errorResponse } from '@/lib/errors' 4import { ZodError } from 'zod' 5 6type Handler = (request: NextRequest) => Promise<Response> 7 8export function withErrorHandler(handler: Handler): Handler { 9 return async (request: NextRequest) => { 10 try { 11 return await handler(request) 12 } catch (error) { 13 // Handle Zod validation errors 14 if (error instanceof ZodError) { 15 const validationError = new ValidationError( 16 'Validation failed', 17 formatZodErrors(error) 18 ) 19 const { body, status } = errorResponse(validationError) 20 return NextResponse.json(body, { status }) 21 } 22 23 // Handle app errors 24 if (error instanceof AppError) { 25 const { body, status } = errorResponse(error) 26 return NextResponse.json(body, { status }) 27 } 28 29 // Handle unknown errors 30 console.error('Unhandled API error:', error) 31 return NextResponse.json( 32 { error: { message: 'Internal server error', code: 'INTERNAL_ERROR' } }, 33 { status: 500 } 34 ) 35 } 36 } 37} 38 39function formatZodErrors(error: ZodError): Record<string, string[]> { 40 const errors: Record<string, string[]> = {} 41 42 for (const issue of error.issues) { 43 const path = issue.path.join('.') 44 if (!errors[path]) { 45 errors[path] = [] 46 } 47 errors[path].push(issue.message) 48 } 49 50 return errors 51}

Route Handler with Error Handling#

1// app/api/users/[id]/route.ts 2import { NextRequest, NextResponse } from 'next/server' 3import { withErrorHandler } from '@/lib/api-handler' 4import { NotFoundError, ForbiddenError, UnauthorizedError } from '@/lib/errors' 5import { auth } from '@/auth' 6 7export const GET = withErrorHandler(async ( 8 request: NextRequest, 9 { params }: { params: { id: string } } 10) => { 11 const session = await auth() 12 13 if (!session) { 14 throw new UnauthorizedError() 15 } 16 17 const user = await prisma.user.findUnique({ 18 where: { id: params.id } 19 }) 20 21 if (!user) { 22 throw new NotFoundError('User') 23 } 24 25 if (user.id !== session.user.id && session.user.role !== 'ADMIN') { 26 throw new ForbiddenError('Cannot access other users') 27 } 28 29 return NextResponse.json({ data: user }) 30})

Error Logging#

1// lib/error-logger.ts 2import { AppError } from './errors' 3 4interface ErrorContext { 5 userId?: string 6 requestId?: string 7 path?: string 8 method?: string 9} 10 11export function logError(error: Error, context: ErrorContext = {}) { 12 const isOperational = error instanceof AppError 13 14 const logData = { 15 message: error.message, 16 stack: error.stack, 17 isOperational, 18 ...context, 19 timestamp: new Date().toISOString() 20 } 21 22 if (isOperational) { 23 console.warn('Operational error:', logData) 24 } else { 25 console.error('System error:', logData) 26 // Send to error tracking service 27 // await sendToSentry(error, context) 28 } 29}

Client-Side Error Handling#

1// lib/api-client.ts 2import { AppError } from './errors' 3 4interface ApiError { 5 error: { 6 message: string 7 code: string 8 details?: Record<string, unknown> 9 } 10} 11 12export async function apiRequest<T>( 13 url: string, 14 options?: RequestInit 15): Promise<T> { 16 const response = await fetch(url, { 17 ...options, 18 headers: { 19 'Content-Type': 'application/json', 20 ...options?.headers 21 } 22 }) 23 24 const data = await response.json() 25 26 if (!response.ok) { 27 const apiError = data as ApiError 28 throw new AppError( 29 apiError.error.message, 30 response.status, 31 apiError.error.code 32 ) 33 } 34 35 return data.data as T 36} 37 38// Usage 39try { 40 const user = await apiRequest<User>('/api/users/123') 41} catch (error) { 42 if (error instanceof AppError && error.code === 'NOT_FOUND') { 43 // Handle not found 44 } 45}

Usage Instructions#

  1. Create error classes: Define custom errors extending AppError
  2. Wrap route handlers: Use withErrorHandler to catch and format errors
  3. Throw specific errors: Use appropriate error classes in your handlers
  4. Handle on client: Parse error responses using the API client
  5. Log appropriately: Distinguish between operational and system errors

Best Practices#

  1. Use specific error classes - Create errors for each failure type
  2. Include error codes - Machine-readable codes for client handling
  3. Hide internal details - Never expose stack traces or sensitive info in responses
  4. Log system errors - Track unexpected errors for debugging
  5. Distinguish error types - Operational errors (expected) vs system errors (bugs)
  6. Validate early - Catch validation errors at the boundary
  7. Use consistent format - Same structure for all error responses