Best Practices for Consistent API Response and Error Formats with Routing Patterns

Prerequisites and environment
Node.js 18+
TypeScript 5+
Express 4 or 5
npm or pnpm
Basic familiarity with Express routing and middleware
Why consistent response and error formats matter
A consistent response envelope is a contract between backend and frontend. It removes guesswork, reduces code branches, and accelerates debugging:
Predictable frontend logic: one narrow type for success, one for error.
Fewer edge cases: same shape across routes and teams.
Observability: structured fields (status, code, requestId) enable dashboards and alerts.
Faster debugging: requestId correlates client logs, server logs, and traces, useful during a “midnight bug hunt” when time matters.
The contract: success and error envelopes
Success shape
{
"status": "success",
"message": "User fetched",
"data": { "id": "u_123", "name": "Asha" },
"code": 200,
"timestamp": "2025-08-21T07:00:00.000Z",
"requestId": "req_abc123"
}
Error shape
{
"status": "error",
"message": "Validation failed",
"errors": ["email is invalid"],
"code": 400,
"timestamp": "2025-08-21T07:00:01.000Z",
"requestId": "req_abc123"
}
Field semantics and why they help
status: 'success' or 'error'. Frontend branches cleanly without try/catch.
message: human-readable summary; drives toasts and UX copy.
data: present only on success; typed payload for rendering.
errors: present only on error; an array for field-level issues.
code: mirrors HTTP status for analytics and retries.
timestamp: server-side time; helps ordering and SLA checks.
requestId: correlates server logs, client logs, and traces.
Building blocks: ApiError, ApiResponse, asyncHandler
Use the following files verbatim.
// APIError.ts
export class ApiError extends Error {
statusCode: number;
data: null;
success: boolean;
errors: any[];
constructor(
statusCode: number,
message = "Something went wrong",
errors: any[] = [],
stack = ""
) {
super(message);
this.statusCode = statusCode;
this.data = null;
this.message = message;
this.success = false;
this.errors = errors;
if (stack) {
this.stack = stack;
} else {
(Error as any).captureStackTrace(this, this.constructor);
}
}
}
// packages/common/src/utils/ApiResponse.ts
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export class ApiResponse<T = any> {
status: 'success' | 'error';
message: string;
data?: T;
errors?: string[];
code?: number;
timestamp?: Date;
requestId?: string;
constructor({
success = true,
message,
data,
errors,
code,
requestId
}: {
success: boolean;
message: string;
data?: T;
errors?: string[];
code?: number;
timestamp?: Date;
requestId?: string;
}) {
this.status = success ? 'success' : 'error';
this.message = message;
this.code = code;
if (success && data !== undefined) this.data = data;
if (!success && errors) this.errors = errors;
this.timestamp = new Date();
if (requestId) this.requestId = requestId;
}
}
/* eslint-disable @typescript-eslint/no-explicit-any */
// packages/common/src/utils/asyncHandler.ts
import { ApiResponse } from './ApiResponse'
export const asyncHandler =
(fn: (req:any, res:any, next:any) => Promise<void>) =>
(req:any, res:any, next:any) => {
Promise.resolve(fn(req, res, next)).catch((error) => {
console.error('Error occurred:', error);
const statusCode = error?.statusCode || 500
const message = error?.message || 'Internal Server Error'
const errors = error?.errors || ['An unexpected error occurred']
const apiError = new ApiResponse({
success: false,
message,
code: statusCode,
errors,
timestamp: new Date(),
})
res.status(statusCode).json(apiError)
})
}
Notes
ApiError encapsulates known error cases (4xx) and lets unknown errors become 5xx.
ApiResponse enforces the envelope and timestamps every response.
asyncHandler centralizes error serialization and prevents unhandled promise rejections.
Integrate in Express routing (with requestId)
A minimal Express setup that:
Attaches a requestId per request.
Uses asyncHandler on routes.
Returns ApiResponse on success.
Throws ApiError for validation errors.
Demonstrates a simulated 500 path.
Project structure
src/server.ts
src/middleware/requestId.ts
src/routes/users.ts
APIError.ts (at repo root as given)
packages/common/src/utils/ApiResponse.ts
packages/common/src/utils/asyncHandler.ts
Install
npm i express cors
npm i -D typescript @types/express ts-node-dev
npx tsc --init
tsconfig.json (essentials)
{
"compilerOptions": {
"target": "ES2020",
"module": "CommonJS",
"rootDir": ".",
"outDir": "dist",
"esModuleInterop": true,
"strict": true,
"skipLibCheck": true
},
"include": ["src", "APIError.ts", "packages/common/src/utils/**/*.ts"]
}
src/middleware/requestId.ts
import { randomUUID } from 'crypto';
import type { Request, Response, NextFunction } from 'express';
declare global {
namespace Express {
interface Request {
requestId?: string;
}
}
}
export function requestId(): (req: Request, _res: Response, next: NextFunction) => void {
return (req, _res, next) => {
// honor inbound header if present (e.g., from reverse proxy)
const inbound = req.header('x-request-id');
req.requestId = inbound && inbound.trim() ? inbound : randomUUID();
next();
};
}
src/server.ts
import express from 'express';
import cors from 'cors';
import { requestId } from './middleware/requestId';
import usersRouter from './routes/users';
const app = express();
app.use(cors());
app.use(express.json());
app.use(requestId());
// add requestId to every response envelope by wrapping res.json
app.use((req, res, next) => {
const originalJson = res.json.bind(res);
res.json = (body: any) => {
if (body && typeof body === 'object' && !body.requestId && req.requestId) {
body.requestId = req.requestId;
}
return originalJson(body);
};
next();
});
app.use('/users', usersRouter);
// 404 handler using the same envelope
app.use((req, res) => {
res.status(404).json({
status: 'error',
message: 'Not Found',
errors: ['Route not found'],
code: 404,
timestamp: new Date(),
requestId: (req as any).requestId
});
});
const port = process.env.PORT || 3000;
app.listen(port, () => {
console.log(`API listening on http://localhost:${port}`);
});
src/routes/users.ts
import { Router, Request, Response } from 'express';
import { asyncHandler } from '../../packages/common/src/utils/asyncHandler';
import { ApiResponse } from '../../packages/common/src/utils/ApiResponse';
import { ApiError } from '../../APIError';
type User = { id: string; name: string; email: string };
const router = Router();
// In-memory demo store
const USERS: Record<string, User> = {
u_123: { id: 'u_123', name: 'Asha', email: 'asha@example.com' }
};
// GET /users/:id - success path
router.get(
'/:id',
asyncHandler(async (req: Request, res: Response) => {
const user = USERS[req.params.id];
if (!user) {
throw new ApiError(404, 'User not found', ['id not found']);
}
const response = new ApiResponse<User>({
success: true,
message: 'User fetched',
data: user,
code: 200,
requestId: req.requestId
});
res.status(200).json(response);
})
);
// POST /users - validation error example
router.post(
'/',
asyncHandler(async (req: Request, res: Response) => {
const { name, email } = req.body || {};
const errors: string[] = [];
if (!name) errors.push('name is required');
if (!email || !/^\S+@\S+\.\S+$/.test(email)) errors.push('email is invalid');
if (errors.length) {
throw new ApiError(400, 'Validation failed', errors);
}
const id = `u_${Date.now()}`;
const user: User = { id, name, email };
USERS[id] = user;
const response = new ApiResponse<User>({
success: true,
message: 'User created',
data: user,
code: 201,
requestId: req.requestId
});
res.status(201).json(response);
})
);
// GET /users/:id/crash - simulate unexpected 500
router.get(
'/:id/crash',
asyncHandler(async (_req: Request, _res: Response) => {
// Simulate an unexpected failure
// This will be caught by asyncHandler and serialized consistently
throw new Error('Simulated server error');
})
);
export default router;
Run
npx ts-node-dev src/server.ts
Try it
GET http://localhost:3000/users/u_999 (404 error envelope)
POST http://localhost:3000/users with body {} (400 validation error)
GET http://localhost:3000/users/u_123/crash (500 error envelope)
Expected behavior
All responses follow the defined envelope with status, message, code, timestamp, and requestId.
Validation and not found use ApiError(4xx).
Unexpected errors are standardized by asyncHandler as 5xx with a generic, safe message.
Frontend consumption patterns (TypeScript)
A narrow fetch wrapper
type Success<T> = {
status: 'success';
message: string;
data: T;
code: number;
timestamp: string | Date;
requestId?: string;
};
type Failure = {
status: 'error';
message: string;
errors?: string[];
code: number;
timestamp: string | Date;
requestId?: string;
};
type ApiEnvelope<T> = Success<T> | Failure;
async function apiFetch<T>(input: RequestInfo, init?: RequestInit): Promise<ApiEnvelope<T>> {
const res = await fetch(input, {
...init,
headers: {
'content-type': 'application/json',
'x-request-id': crypto.randomUUID(), // optional: propagate client id
...(init?.headers || {})
}
});
const body = (await res.json()) as ApiEnvelope<T>;
// Optionally ensure code mirrors HTTP status
if (typeof (body as any).code !== 'number') {
(body as any).code = res.status as any;
}
return body;
}
UI handling example (React)
import { useEffect, useState } from 'react';
type User = { id: string; name: string; email: string };
export function UserCard({ id }: { id: string }) {
const [state, setState] = useState<{ loading: boolean; user?: User; error?: string }>({
loading: true
});
useEffect(() => {
let active = true;
(async () => {
const resp = await apiFetch<User>(`/users/${id}`);
if (!active) return;
if (resp.status === 'success') {
setState({ loading: false, user: resp.data });
console.log('requestId:', resp.requestId);
} else {
const msg = [resp.message, ...(resp.errors ?? [])].join(' • ');
setState({ loading: false, error: msg });
console.warn('requestId:', resp.requestId, 'code:', resp.code, 'error:', msg);
}
})();
return () => {
active = false;
};
}, [id]);
if (state.loading) return <div>Loading…</div>;
if (state.error) return <div role="alert">Error: {state.error}</div>;
return (
<div>
<h2>{state.user!.name}</h2>
<p>{state.user!.email}</p>
</div>
);
}
Benefits for the frontend
Predictable branching on status eliminates try/catch fragmentation.
Uniform error messages power consistent toasts/forms.
requestId makes support tickets actionable: “Error with requestId req_abc123”.
Observability and debugging
RequestId correlation
Generate at ingress (reverse proxy or requestId middleware).
Include requestId in every response envelope.
Log requestId on client and server for cross-system tracing.
Structured logs
Log JSON with keys: requestId, route, method, statusCode, code, durationMs, userId (if available).
Example server log line:
{
"level": "info",
"msg": "GET /users/:id",
"requestId": "req_abc123",
"route": "/users/:id",
"method": "GET",
"statusCode": 200,
"durationMs": 42
}
Metrics/APM
Count codes (2xx, 4xx, 5xx) and specific app codes if used.
Track latency buckets per route.
Tie traces to requestId or traceparent headers when available.
Routing patterns recap
Success: res.status(...).json(new ApiResponse({ success: true, message, data, code, requestId }))
Known errors: throw new ApiError(4xx, message, fieldErrors)
Unknown errors: throw Error; asyncHandler converts to standardized 5xx
Wrap every async controller with asyncHandler
Versioning and compatibility
Prefer additive changes: add fields like pagination without altering existing ones.
Use app-specific code values alongside HTTP status for richer semantics (e.g., code: 422100 for validation schema errors).
Introduce new envelope fields as optional; keep status/message/data/errors stable.
If a breaking change is unavoidable, version via URL (/v2) or content negotiation, and support both during migration.
Trade-offs and security considerations
Pros
Predictable contracts and simpler frontend integrations.
Faster debugging via requestId and structured logs.
Easier analytics on status/code across routes.
Cons
Slightly larger payloads due to envelope.
Risk of overexposing internals if 5xx includes raw messages.
Recommendations
For 5xx, return safe messages like “Internal Server Error”; log stack traces server-side only.
For 4xx, include field-level errors to guide UX.
Never include stack in responses in production; include in server logs with proper redaction.
Migration strategy (incremental)
Wrap existing async controllers with asyncHandler to unify 5xx behavior.
Replace ad-hoc res.json with ApiResponse on successful paths route-by-route.
Standardize known errors with new ApiError(4xx, message, errors).
Add requestId middleware and response wrapper to inject requestId consistently.
Update frontend fetch wrapper to branch on status.
Testing and validation
Supertest examples
import request from 'supertest';
import { app } from '../src/server'; // export app from server.ts for tests
describe('API envelope', () => {
it('returns success envelope on GET /users/u_123', async () => {
const res = await request(app).get('/users/u_123');
expect(res.status).toBe(200);
expect(res.body.status).toBe('success');
expect(res.body.message).toBe('User fetched');
expect(res.body.data).toHaveProperty('id', 'u_123');
expect(typeof res.body.code).toBe('number');
expect(res.body).toHaveProperty('timestamp');
expect(res.body).toHaveProperty('requestId');
});
it('returns validation error envelope on POST /users', async () => {
const res = await request(app).post('/users').send({});
expect(res.status).toBe(400);
expect(res.body.status).toBe('error');
expect(res.body.message).toBe('Validation failed');
expect(res.body.errors).toContain('email is invalid');
expect(res.body).toHaveProperty('requestId');
});
it('returns 500 envelope on crash route', async () => {
const res = await request(app).get('/users/u_123/crash');
expect(res.status).toBe(500);
expect(res.body.status).toBe('error');
expect(res.body.message).toBe('Internal Server Error'); // from asyncHandler default
expect(res.body).toHaveProperty('code', 500);
});
});
Unit test for ApiResponse
import { ApiResponse } from '../packages/common/src/utils/ApiResponse';
describe('ApiResponse', () => {
it('builds success response with data', () => {
const r = new ApiResponse({ success: true, message: 'ok', data: { a: 1 }, code: 200 });
expect(r.status).toBe('success');
expect(r.data).toEqual({ a: 1 });
expect(r.code).toBe(200);
expect(r.timestamp).toBeInstanceOf(Date);
});
it('builds error response with errors', () => {
const r = new ApiResponse({ success: false, message: 'nope', errors: ['bad'], code: 400 });
expect(r.status).toBe('error');
expect(r.errors).toEqual(['bad']);
expect(r.code).toBe(400);
expect(r.timestamp).toBeInstanceOf(Date);
});
});
Note: In tests, export the Express app from server.ts and start the server only in production entrypoint.
Quickstart (15 minutes)
Add the three building blocks:
ApiError.ts
packages/common/src/utils/ApiResponse.ts
packages/common/src/utils/asyncHandler.ts
Add requestId middleware and a res.json wrapper to inject requestId.
Wrap one existing route with asyncHandler.
Replace res.json({...}) with res.status(...).json(new ApiResponse({ success: true, message, data, code, requestId: req.requestId })).
Throw new ApiError(400, 'Validation failed', ['field is invalid']) for known bad input.
Update frontend fetch wrapper to branch on status and surface message/errors.
Verify with curl/postman:
success returns status: success with data.
bad input returns status: error with errors array.
unexpected error returns status: error with code: 500 and safe message.
Pitfalls and practical tips
Don’t include stack traces or database error messages in responses; log them server-side.
Ensure code mirrors HTTP status for consistency; clients often depend on both.
Keep errors as string[] for simplicity; if you need field mapping, extend additively later (e.g., details: { field: message }).
For pagination, add optional meta: { page, pageSize, total } without changing the envelope.
In Express 5, async handlers are supported, but keeping asyncHandler gives consistent serialization and logging.
Alternatives and when to choose them
Problem Details (RFC 9457, formerly 7807): a standard JSON error format. Great if interoperating with other systems; can be mapped into this envelope or used directly.
GraphQL error envelopes: if using GraphQL, prefer GraphQL’s result shape; the ideas here still apply (consistent extensions, requestId).
tRPC/JSON-RPC: similar benefits from a unified envelope and error normalization.
Human anecdote
A teammate once reported “Profile page broken for some users” at midnight. Because the API returned a stable error envelope with requestId, support pasted req_abc123 from the browser console. We grep’d server logs by that id, found the 422 validation error (invalid locale), and shipped a fix, no guesswork, no log spelunking.
Metadata and internal links
Title: Designing Consistent API Response and Error Formats (with Routing Patterns) for Seamless Frontend Integration and Faster Debugging
Meta description: Standardize REST responses in Node.js/TypeScript with ApiResponse, ApiError, and asyncHandler for predictable frontend handling, better logs, and faster debugging.
Suggested internal links (by topic):
Type-safe API clients in TypeScript
Adding request tracing and structured logging in Node.js
Pagination and filtering patterns for REST APIs
Self-check
Audience fit: intermediate Node/TS; assumes Express basics.
Prerequisites/versions: listed; last-tested date included.
Reproducibility: copy-pasteable files and run instructions.
Trade-offs/alternatives: covered with security notes.
Accessibility: examples show role="alert"; avoid logs dump.
Determinism: consistent envelope, tests provided.
No sensitive data; no fabricated benchmarks.
Subscribe to my newsletter
Read articles from Saurav directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by

Saurav
Saurav
CSE(AI)-27' NCER | AI&ML Enthusiast | full stack Web Dev | Freelancer | Next.js & Typescript | Python