// LOADING
// LOADING
// LOADING_ARTICLE
Most codebases start with try/catch blocks scattered across every route handler, each formatting its own error response slightly differently. This works until you need to change something — update an error format for a client, add request IDs to all errors, log structured error data — and find yourself touching dozens of files.
Centralized error handling is one of those investments that pays off the first time you need to change something. This post covers the patterns that make Node.js APIs maintainable: custom error classes, a single error-formatting layer, and safe handling of unexpected errors.
For the API design context that surrounds error handling, How to Design APIs for Next.js Applications covers the broader response shape conventions.
The foundation is a hierarchy of error classes that carry HTTP semantics:
typescript// lib/errors.ts export class AppError extends Error { constructor( public statusCode: number, public code: string, message: string, public details?: unknown ) { super(message); this.name = 'AppError'; // Maintains proper prototype chain in transpiled code Object.setPrototypeOf(this, new.target.prototype); } } export class NotFoundError extends AppError { constructor(resource: string, id?: string) { super( 404, 'NOT_FOUND', id ? `${resource} with id '${id}' not found` : `${resource} not found` ); this.name = 'NotFoundError'; } } export class ValidationError extends AppError { constructor(message: string, details?: unknown) { super(400, 'VALIDATION_ERROR', message, details); this.name = 'ValidationError'; } } export class UnauthorizedError extends AppError { constructor(message = 'Unauthorized') { super(401, 'UNAUTHORIZED', message); this.name = 'UnauthorizedError'; } } export class ForbiddenError extends AppError { constructor(message = 'Forbidden') { super(403, 'FORBIDDEN', message); this.name = 'ForbiddenError'; } } export class ConflictError extends AppError { constructor(message: string) { super(409, 'CONFLICT', message); this.name = 'ConflictError'; } }
With this hierarchy you can throw semantically meaningful errors anywhere in your service layer and handle them uniformly at the boundary.
Express has a dedicated four-argument error middleware signature:
typescript// middleware/errorHandler.ts import { Request, Response, NextFunction } from 'express'; import { AppError } from '@/lib/errors'; import { ZodError } from 'zod'; export function errorHandler( err: unknown, req: Request, res: Response, _next: NextFunction ): void { // Zod validation errors if (err instanceof ZodError) { res.status(400).json({ error: { code: 'VALIDATION_ERROR', message: 'Invalid request data', details: err.flatten().fieldErrors, }, }); return; } // Known application errors if (err instanceof AppError) { res.status(err.statusCode).json({ error: { code: err.code, message: err.message, ...(err.details ? { details: err.details } : {}), }, }); return; } // Unknown errors — log fully, respond minimally console.error('Unhandled error:', err); res.status(500).json({ error: { code: 'INTERNAL_ERROR', message: 'An unexpected error occurred', }, }); }
Register it last, after all routes:
typescriptapp.use('/api', routes); app.use(errorHandler); // must be last
Next.js Route Handlers don't have Express middleware, but you can achieve the same result with a higher-order wrapper:
typescript// lib/withErrorHandling.ts import { NextRequest, NextResponse } from 'next/server'; import { AppError } from './errors'; import { ZodError } from 'zod'; type Handler = (req: NextRequest, ctx?: unknown) => Promise<NextResponse>; export function withErrorHandling(handler: Handler): Handler { return async (req, ctx) => { try { return await handler(req, ctx); } catch (err) { if (err instanceof ZodError) { return NextResponse.json( { error: { code: 'VALIDATION_ERROR', message: 'Invalid request data', details: err.flatten().fieldErrors, }, }, { status: 400 } ); } if (err instanceof AppError) { return NextResponse.json( { error: { code: err.code, message: err.message } }, { status: err.statusCode } ); } console.error('[API Error]', req.url, err); return NextResponse.json( { error: { code: 'INTERNAL_ERROR', message: 'An unexpected error occurred' } }, { status: 500 } ); } }; }
Usage:
typescript// app/api/posts/[id]/route.ts import { withErrorHandling } from '@/lib/withErrorHandling'; import { NotFoundError } from '@/lib/errors'; import Post from '@/models/Post'; export const GET = withErrorHandling(async (req, { params }: { params: { id: string } }) => { const post = await Post.findById(params.id).lean(); if (!post) throw new NotFoundError('Post', params.id); return NextResponse.json(post); });
The route handler is now free of try/catch noise. Every unhandled throw bubbles up to a single, consistent formatter.
A classic Express pitfall: async errors thrown inside route handlers are not caught by the error middleware unless you explicitly pass them to next:
typescript// Without wrapper — error is swallowed router.get('/posts/:id', async (req, res) => { const post = await Post.findById(req.params.id); // throws — not caught res.json(post); }); // With wrapper — error reaches errorHandler const asyncHandler = (fn: RequestHandler): RequestHandler => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next); router.get('/posts/:id', asyncHandler(async (req, res) => { const post = await Post.findById(req.params.id); res.json(post); }));
Express 5 (currently in release candidate) handles this natively. For Express 4, the wrapper is a one-liner worth adding globally.
Don't log err.message only — log the full stack, the request method and URL, and any request ID:
typescriptconsole.error(JSON.stringify({ level: 'error', message: err instanceof Error ? err.message : String(err), stack: err instanceof Error ? err.stack : undefined, requestId: req.headers['x-request-id'], method: req.method, url: req.url, timestamp: new Date().toISOString(), }));
In production, feed this into a structured log aggregator (Datadog, Logtail, CloudWatch). See Monitoring and Observability for Web Apps for the broader observability picture.
This is the most security-critical rule in error handling. Stack traces reveal:
For AppError instances, return the message (which you wrote deliberately). For unknown errors, return only a generic message and log the real error server-side. This connects directly to the principle in Common Web Security Threats — information leakage is a genuine attack vector.
Add these globally to catch anything that slips through:
typescriptprocess.on('unhandledRejection', (reason, promise) => { console.error('Unhandled rejection at:', promise, 'reason:', reason); // Exit so the process manager (PM2, Kubernetes) restarts with a clean state process.exit(1); }); process.on('uncaughtException', (err) => { console.error('Uncaught exception:', err); process.exit(1); });
These are safety nets, not a substitute for proper error handling. If you're regularly hitting them, something in the application layer needs fixing.
A clean error handling architecture has three layers: semantic custom error classes that live in your domain, a single formatting boundary (middleware or wrapper) that translates errors to HTTP responses, and global process handlers as a last resort. Keep stack traces server-side, structure your logs for machines, and use Zod to catch validation errors before they become untyped runtime exceptions.
For the TypeScript foundation that makes this pattern safe, see Runtime Validation in TypeScript with Zod, and visit /contact to discuss API architecture.