# 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

```json
{
  "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

```json
{
  "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.

```typescript
// 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);
        }
    }
}
```

```typescript
// 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;
  }
}
```

```typescript
/* 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

```bash
npm i express cors
npm i -D typescript @types/express ts-node-dev
npx tsc --init
```

tsconfig.json (essentials)

```json
{
  "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

```typescript
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

```typescript
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

```typescript
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

```bash
npx ts-node-dev src/server.ts
```

Try it

* GET [http://localhost:3000/users/u\_123](http://localhost:3000/users/u_123)
    
* GET [http://localhost:3000/users/u\_999](http://localhost:3000/users/u_999) (404 error envelope)
    
* POST [http://localhost:3000/users](http://localhost:3000/users) with body {} (400 validation error)
    
* GET [http://localhost:3000/users/u\_123/crash](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

```typescript
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)

```typescript
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:
    

```json
{
  "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

```typescript
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

```typescript
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.
