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#
- Create error classes: Define custom errors extending
AppError - Wrap route handlers: Use
withErrorHandlerto catch and format errors - Throw specific errors: Use appropriate error classes in your handlers
- Handle on client: Parse error responses using the API client
- Log appropriately: Distinguish between operational and system errors
Best Practices#
- Use specific error classes - Create errors for each failure type
- Include error codes - Machine-readable codes for client handling
- Hide internal details - Never expose stack traces or sensitive info in responses
- Log system errors - Track unexpected errors for debugging
- Distinguish error types - Operational errors (expected) vs system errors (bugs)
- Validate early - Catch validation errors at the boundary
- Use consistent format - Same structure for all error responses
Related Patterns#
- Route Handler - API endpoint implementation
- Middleware - Request preprocessing
- Validation - Input validation with Zod
- Logging - Structured logging setup