Error Handling Pattern

Implement consistent, type-safe error handling across your application with custom error classes and handlers.

Overview#

Proper error handling improves debugging, user experience, and application reliability. This pattern provides a structured approach to creating, throwing, catching, and displaying errors consistently.

When to use:

  • API error responses
  • Client-side error handling
  • Error logging and monitoring
  • User-friendly error messages
  • Validation error display

Key features:

  • Custom error classes
  • Consistent error format
  • API error wrapper
  • Client error handling
  • Error logging
  • React error boundaries

Code Example#

Custom 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( 53 public retryAfter: number, 54 message = 'Too many requests' 55 ) { 56 super(message, 429, 'RATE_LIMIT_EXCEEDED') 57 this.name = 'RateLimitError' 58 } 59}

Error Response Format#

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

API 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, context?: any) => Promise<Response> 7 8export function withErrorHandler(handler: Handler): Handler { 9 return async (request: NextRequest, context?: any) => { 10 try { 11 return await handler(request, context) 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 { 33 error: { 34 message: 'Internal server error', 35 code: 'INTERNAL_ERROR' 36 } 37 }, 38 { status: 500 } 39 ) 40 } 41 } 42} 43 44function formatZodErrors(error: ZodError): Record<string, string[]> { 45 const errors: Record<string, string[]> = {} 46 47 for (const issue of error.issues) { 48 const path = issue.path.join('.') 49 if (!errors[path]) { 50 errors[path] = [] 51 } 52 errors[path].push(issue.message) 53 } 54 55 return errors 56}

Route Handler Usage#

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 '@/lib/auth' 6import { prisma } from '@/lib/db' 7 8export const GET = withErrorHandler(async ( 9 request: NextRequest, 10 { params }: { params: { id: string } } 11) => { 12 const { userId } = await auth() 13 14 if (!userId) { 15 throw new UnauthorizedError() 16 } 17 18 const user = await prisma.user.findUnique({ 19 where: { id: params.id } 20 }) 21 22 if (!user) { 23 throw new NotFoundError('User') 24 } 25 26 if (user.id !== userId) { 27 throw new ForbiddenError('Cannot access other users') 28 } 29 30 return NextResponse.json({ data: user }) 31})

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 39async function fetchUser(id: string) { 40 try { 41 const user = await apiRequest<User>(`/api/users/${id}`) 42 return user 43 } catch (error) { 44 if (error instanceof AppError) { 45 if (error.code === 'NOT_FOUND') { 46 // Handle not found 47 } 48 if (error.code === 'UNAUTHORIZED') { 49 // Redirect to login 50 } 51 } 52 throw error 53 } 54}

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 userAgent?: string 10} 11 12export function logError(error: Error, context: ErrorContext = {}) { 13 const isOperational = error instanceof AppError 14 15 const logData = { 16 message: error.message, 17 name: error.name, 18 stack: error.stack, 19 isOperational, 20 code: error instanceof AppError ? error.code : undefined, 21 ...context, 22 timestamp: new Date().toISOString() 23 } 24 25 if (isOperational) { 26 // Expected errors - warn level 27 console.warn('Operational error:', JSON.stringify(logData)) 28 } else { 29 // Unexpected errors - error level 30 console.error('System error:', JSON.stringify(logData)) 31 32 // Send to error tracking service in production 33 if (process.env.NODE_ENV === 'production') { 34 // sendToSentry(error, context) 35 // sendToDatadog(error, context) 36 } 37 } 38}

React Error Boundary#

1// components/ErrorBoundary.tsx 2'use client' 3 4import { Component, ReactNode } from 'react' 5 6interface Props { 7 children: ReactNode 8 fallback?: ReactNode 9} 10 11interface State { 12 hasError: boolean 13 error?: Error 14} 15 16export class ErrorBoundary extends Component<Props, State> { 17 constructor(props: Props) { 18 super(props) 19 this.state = { hasError: false } 20 } 21 22 static getDerivedStateFromError(error: Error): State { 23 return { hasError: true, error } 24 } 25 26 componentDidCatch(error: Error, errorInfo: React.ErrorInfo) { 27 console.error('Error caught by boundary:', error, errorInfo) 28 } 29 30 render() { 31 if (this.state.hasError) { 32 return this.props.fallback ?? ( 33 <div className="p-4 text-center"> 34 <h2 className="text-lg font-semibold text-red-600"> 35 Something went wrong 36 </h2> 37 <p className="text-gray-600"> 38 {this.state.error?.message ?? 'An unexpected error occurred'} 39 </p> 40 <button 41 onClick={() => this.setState({ hasError: false })} 42 className="mt-4 px-4 py-2 bg-blue-500 text-white rounded" 43 > 44 Try again 45 </button> 46 </div> 47 ) 48 } 49 50 return this.props.children 51 } 52}

Error Display Component#

1// components/ErrorMessage.tsx 2interface ErrorMessageProps { 3 error?: string | null 4 className?: string 5} 6 7export function ErrorMessage({ error, className }: ErrorMessageProps) { 8 if (!error) return null 9 10 return ( 11 <p 12 className={`text-sm text-red-600 ${className ?? ''}`} 13 role="alert" 14 > 15 {error} 16 </p> 17 ) 18} 19 20// Field error for forms 21interface FieldErrorProps { 22 name: string 23 errors?: Record<string, string> 24} 25 26export function FieldError({ name, errors }: FieldErrorProps) { 27 const error = errors?.[name] 28 if (!error) return null 29 30 return ( 31 <p 32 className="mt-1 text-sm text-red-600" 33 role="alert" 34 id={`${name}-error`} 35 > 36 {error} 37 </p> 38 ) 39}

Toast Notifications for Errors#

1// lib/toast.ts 2import { toast } from 'sonner' 3import { AppError } from './errors' 4 5export function showErrorToast(error: unknown) { 6 if (error instanceof AppError) { 7 toast.error(error.message, { 8 description: getErrorDescription(error.code) 9 }) 10 } else if (error instanceof Error) { 11 toast.error('An error occurred', { 12 description: error.message 13 }) 14 } else { 15 toast.error('An unexpected error occurred') 16 } 17} 18 19function getErrorDescription(code: string): string | undefined { 20 const descriptions: Record<string, string> = { 21 UNAUTHORIZED: 'Please sign in to continue', 22 FORBIDDEN: 'You do not have permission for this action', 23 NOT_FOUND: 'The requested resource was not found', 24 RATE_LIMIT_EXCEEDED: 'Please wait before trying again', 25 VALIDATION_ERROR: 'Please check your input and try again' 26 } 27 return descriptions[code] 28}

Usage Instructions#

  1. Create error classes: Define custom errors for each error type
  2. Wrap API handlers: Use withErrorHandler for consistent responses
  3. Throw specific errors: Use the right error class for each situation
  4. Handle on client: Catch errors and display appropriate messages
  5. Log appropriately: Track errors for debugging and monitoring

Best Practices#

  1. Use specific error classes - Create errors for each scenario
  2. Include error codes - Enable programmatic error handling
  3. Don't leak internal details - Sanitize messages in production
  4. Log with context - Include request ID, user ID, etc.
  5. Handle gracefully - Always show user-friendly messages
  6. Use error boundaries - Prevent component crashes from breaking the app
  7. Monitor in production - Use Sentry, Datadog, or similar
  8. Test error paths - Verify error handling works correctly