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

SauravSaurav
11 min read

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

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.

  • 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.

0
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