Skip to main content

๐Ÿ“ค Response Formatting and Status Codes

Your API's response is its voice. When every endpoint answers in the same shape, with the right status code, clients can be written once and trusted everywhere. This lesson turns response design from an afterthought into a deliberate contract โ€” status codes that tell the truth, a consistent envelope for success and failure, and middleware that enforces it automatically.

๐ŸŽฏ Learning Objectives

By the end of this lesson, you will be able to:

  • Classify HTTP status codes by category and choose the right one per operation
  • Set status codes and chain response methods correctly in Express
  • Design a consistent response envelope for success and error cases
  • Apply the JSend convention and know when a richer standard fits
  • Build response-formatting helpers/middleware and add pagination metadata and headers

Estimated Time: 45โ€“60 minutes  โ€ข  Difficulty: Intermediate

Hands-on: Build a response helper that standardizes success, error, and paginated replies.

In This Lesson

Why Response Design Matters

A response carries two channels of information: the status code (what happened, at a glance) and the body (the details). When those two are consistent across every endpoint, the developers consuming your API โ€” including future you โ€” can write generic handling once instead of special-casing each route.

๐Ÿ’ก Think of it this way: An API is only as good as its response design. The response is your API's voice โ€” make it speak clearly, and in the same accent everywhere.

Consistent responses pay off in developer experience, graceful error handling on the client, faster debugging (the status code shows up in every log and network tab), and self-documentation โ€” a well-shaped response teaches the client how to read it.

flowchart LR A[Request] --> B[Express handler] B --> C{Outcome?} C -->|Success| D[Success envelope] C -->|Error| E[Error envelope] D --> F[Set 2xx status] E --> G[Set 4xx / 5xx status] F --> H[Send response] G --> H

Status Code Categories

Every HTTP status code falls into one of five ranges. Knowing the range tells you the shape of the outcome before you read the exact number.

RangeCategoryMeaning
1xxInformationalRequest received, still processing
2xxSuccessReceived, understood, accepted
3xxRedirectionFurther action needed to complete
4xxClient ErrorThe request was wrong (fix your call)
5xxServer ErrorThe server failed a valid request

๐Ÿ“– The codes you'll actually use

Success: 200 OK ยท 201 Created ยท 204 No Content

Client error: 400 Bad Request ยท 401 Unauthorized ยท 403 Forbidden ยท 404 Not Found ยท 409 Conflict ยท 422 Unprocessable Entity ยท 429 Too Many Requests

Server error: 500 Internal Server Error ยท 502 Bad Gateway ยท 503 Service Unavailable

The right code depends on the operation. This table maps common REST actions to their typical outcomes:

OperationSuccessCommon errors
GET /users200 OK401, 403
GET /users/:id200 OK404, 401, 403
POST /users201 Created400, 409, 422
PUT /users/:id200 OK400, 404, 422
DELETE /users/:id204 No Content404, 403

โš ๏ธ 400 vs 422

Use 400 Bad Request when the request is malformed โ€” broken JSON, wrong types, a missing required field. Use 422 Unprocessable Entity when the request is well-formed but semantically invalid โ€” for example, a valid string that fails a business rule. Many teams simplify to 400 for all validation; the important thing is to be consistent.

Setting Codes in Express

Express makes status codes easy: res.status(code) sets the code and returns the response object, so you can chain the body method.

app.get('/api/users/:id', (req, res) => {
  const user = findUser(req.params.id);
  if (!user) {
    return res.status(404).json({ error: 'User not found' });
  }
  res.status(200).json(user); // chainable: status โ†’ json
});

๐Ÿ’ก Defaults Express fills in for you

res.json() and res.send() default to 200. If a route never sends anything, Express eventually responds 404. Convenient โ€” but always set the code explicitly for anything other than a plain 200, so intent is visible in the code.

There's also res.sendStatus(code), which sets the code and sends the standard reason phrase as the body โ€” handy for terse responses where no JSON is needed:

res.sendStatus(204); // โ†’ 204, body: "No Content"
res.sendStatus(403); // โ†’ 403, body: "Forbidden"

โš ๏ธ Return after you respond

A handler keeps executing after res.json() unless you stop it. Forgetting return before an early error response leads to the dreaded "Cannot set headers after they are sent" error. Always return res.status(...).json(...) in guard clauses.

A Consistent Response Envelope

An envelope is the outer shape every response shares. Whether the request succeeded or failed, the client finds the same top-level keys and knows exactly where to look. Here's a common, minimal pattern:

// Success โ€” a single resource
{ "success": true, "data": { "id": 7, "title": "Ship v2" } }

// Success โ€” a collection with metadata
{
  "success": true,
  "data": [ /* items */ ],
  "meta": { "total": 57, "page": 2, "limit": 10, "pages": 6 }
}

// Error โ€” always the same shape
{
  "success": false,
  "error": {
    "code": "VALIDATION_ERROR",
    "message": "Invalid order data",
    "details": { "quantity": "must be greater than 0" }
  }
}
๐Ÿ’ก Analogy: A response envelope is like a company report template. No matter which department files it, the summary, the data, and the conclusions are always in the same place โ€” so any reader knows how to skim it instantly.
flowchart TB A[Response envelope] --> B["Single: { success, data }"] A --> C["Collection: { success, data, meta }"] A --> D["Error: { success, error }"]

โœ… Why the envelope wins

The client can write one function โ€” if (res.success) use(res.data) else show(res.error) โ€” and reuse it for every endpoint. Without an envelope, each route's ad-hoc shape forces bespoke handling and brittle code.

The JSend Convention

Rather than invent your own envelope, you can adopt a published one. JSend is the simplest: it defines three outcomes โ€” success, fail (the client's fault, e.g. validation), and error (the server's fault).

// success โ€” the request worked
{ "status": "success", "data": { "id": 1, "name": "John Doe" } }

// fail โ€” client-side problem (validation, bad input)
{ "status": "fail", "data": { "email": "Email is already in use" } }

// error โ€” server-side problem
{ "status": "error", "message": "Unable to reach the database" }

Implementing JSend as a tiny helper keeps every route terse and uniform:

// utils/jsend.js
const jsend = {
  success: (res, data, statusCode = 200) =>
    res.status(statusCode).json({ status: 'success', data: data ?? null }),

  fail: (res, data, statusCode = 400) =>
    res.status(statusCode).json({ status: 'fail', data }),

  error: (res, message, statusCode = 500, code = null) => {
    const body = { status: 'error', message };
    if (code) body.code = code;
    return res.status(statusCode).json(body);
  },
};

module.exports = jsend;
const jsend = require('./utils/jsend');

app.get('/api/users/:id', (req, res) => {
  const user = findUser(req.params.id);
  if (!user) return jsend.fail(res, { id: 'User not found' }, 404);
  return jsend.success(res, user);
});

๐Ÿ’ก When to choose something richer

For most apps, a simple envelope or JSend is plenty. If you're modeling many interrelated resources and want a standardized way to include related records, look at JSON:API, which formalizes data, relationships, and included. It's more powerful but heavier โ€” adopt it when relationships, not simplicity, are your main concern.

Response Helpers & Middleware

To keep formatting DRY, wrap it in reusable code. Two common shapes: a helper object, or a class attached to res via middleware.

A response helper class

// utils/api-response.js
class ApiResponse {
  constructor(res) {
    this.res = res;
  }

  success(data = null, meta = null, statusCode = 200) {
    const body = { success: true, data };
    if (meta) body.meta = meta;
    return this.res.status(statusCode).json(body);
  }

  created(data = null) {
    return this.success(data, null, 201);
  }

  noContent() {
    return this.res.status(204).send();
  }

  error(message, code = 'ERROR', statusCode = 500, details = null) {
    return this.res.status(statusCode).json({
      success: false,
      error: { code, message, details },
    });
  }

  notFound(resource = 'Resource') {
    return this.error(`${resource} not found`, 'NOT_FOUND', 404);
  }

  badRequest(message, details = null) {
    return this.error(message, 'BAD_REQUEST', 400, details);
  }
}

module.exports = ApiResponse;

Attach it to every request with a one-line middleware, and your controllers become wonderfully readable:

const ApiResponse = require('./utils/api-response');

app.use((req, res, next) => {
  res.api = new ApiResponse(res);
  next();
});

app.get('/api/users/:id', async (req, res) => {
  const user = await User.findById(req.params.id);
  if (!user) return res.api.notFound('User');
  return res.api.success(user);
});

Formatting middleware (override res.json)

An alternative is to intercept res.json once so every response is wrapped automatically, without touching each handler:

// middleware/response-formatter.js
module.exports = (req, res, next) => {
  const originalJson = res.json.bind(res);

  res.json = (body) => {
    // Don't double-wrap already-formatted responses
    if (body && (body.success !== undefined || body.status)) {
      return originalJson(body);
    }
    const success = res.statusCode >= 200 && res.statusCode < 300;
    return originalJson(
      success
        ? { success: true, data: body }
        : { success: false, error: body }
    );
  };

  next();
};

โš ๏ธ Pick one approach and stick to it

Explicit helpers (res.api.success()) are clearer to read; auto-wrapping middleware is less code but more "magic." Mixing both leads to double-wrapped responses. Choose one convention per project and apply it everywhere.

Pagination & Headers

Collections should never dump every row. Paginate, and tell the client how to navigate via meta:

app.get('/api/products', async (req, res) => {
  const page = Number(req.query.page) || 1;
  const limit = Number(req.query.limit) || 10;
  const skip = (page - 1) * limit;

  const [items, total] = await Promise.all([
    Product.find().skip(skip).limit(limit),
    Product.countDocuments(),
  ]);

  const pages = Math.ceil(total / limit);
  res.status(200).json({
    success: true,
    data: items,
    meta: {
      pagination: {
        total, page, limit, pages,
        hasNext: page < pages,
        hasPrev: page > 1,
      },
    },
  });
});

For very large or fast-changing datasets, cursor-based pagination (pass an opaque cursor instead of a page number) is more stable and efficient than offset pagination, because inserts don't shift the page boundaries.

Useful response headers

Headers carry metadata that doesn't belong in the body. A few worth knowing:

HeaderPurpose
Content-TypeMedia type, e.g. application/json
Cache-ControlHow/whether the response may be cached
LocationURL of a newly created resource (with 201)
X-RateLimit-RemainingRequests left in the current window
app.post('/api/products', async (req, res) => {
  const product = await Product.create(req.body);
  res
    .status(201)
    .location(`/api/products/${product.id}`) // where to find it
    .json({ success: true, data: product });
});

๐Ÿ’ก Content negotiation, briefly

If an endpoint can return more than one format, inspect the Accept header (Express offers res.format({...})) to serve JSON, CSV, or PDF from the same URL. Most JSON APIs don't need this โ€” reach for it only when a real export use-case appears.

Hands-on Exercise

๐Ÿ‹๏ธ Build a Response Helper

Objective: Standardize every reply from a small API through one helper.

Instructions:

  1. Create utils/api-response.js exporting a helper with success(data, meta), created(data), notFound(resource), and paginated(items, page, limit, total).
  2. Wire it onto res.api with a middleware.
  3. Build a GET /api/notes (paginated) and GET /api/notes/:id (200 or 404) using only the helper.
  4. Confirm both endpoints return the same envelope shape and correct status codes.
๐Ÿ’ก Hint

paginated() should compute pages = Math.ceil(total / limit) and place everything under meta.pagination, calling through to success() so the envelope stays identical. Return this.res... from each method so calls stay chainable and terminate the handler.

โœ… Solution
// utils/api-response.js
class ApiResponse {
  constructor(res) { this.res = res; }

  success(data = null, meta = null, statusCode = 200) {
    const body = { success: true, data };
    if (meta) body.meta = meta;
    return this.res.status(statusCode).json(body);
  }

  created(data) { return this.success(data, null, 201); }

  notFound(resource = 'Resource') {
    return this.res.status(404).json({
      success: false,
      error: { code: 'NOT_FOUND', message: `${resource} not found` },
    });
  }

  paginated(items, page, limit, total) {
    const pages = Math.ceil(total / limit);
    return this.success(items, {
      pagination: { total, page, limit, pages,
        hasNext: page < pages, hasPrev: page > 1 },
    });
  }
}
module.exports = ApiResponse;
// app.js
const express = require('express');
const ApiResponse = require('./utils/api-response');

const app = express();
app.use(express.json());
app.use((req, res, next) => { res.api = new ApiResponse(res); next(); });

const notes = [
  { id: '1', text: 'First note' },
  { id: '2', text: 'Second note' },
];

app.get('/api/notes', (req, res) => {
  const page = Number(req.query.page) || 1;
  const limit = Number(req.query.limit) || 10;
  const start = (page - 1) * limit;
  const pageItems = notes.slice(start, start + limit);
  res.api.paginated(pageItems, page, limit, notes.length);
});

app.get('/api/notes/:id', (req, res) => {
  const note = notes.find(n => n.id === req.params.id);
  if (!note) return res.api.notFound('Note');
  res.api.success(note);
});

app.listen(3000, () => console.log('http://localhost:3000'));

Summary & Quiz

๐ŸŽ‰ Key Takeaways

  • Status codes fall into five ranges; pick the most specific accurate one per operation.
  • res.status(code) is chainable โ€” and always return in guard clauses.
  • Adopt a consistent envelope so clients handle every response the same way.
  • JSend is a simple three-outcome standard; JSON:API fits relationship-heavy APIs.
  • Centralize formatting in helpers or middleware, and expose pagination via meta.

๐ŸŽฏ Quick Quiz

Question 1: A DELETE request succeeds and there's no body to return. Which status code is most appropriate?

Question 2: What is the main benefit of a consistent response envelope?

Question 3: In the JSend convention, which status value indicates a client-side problem such as a validation error?

๐Ÿ“š Further Reading

๐Ÿš€ What's Next?

You can now shape every reply โ€” including error replies. Next we'll centralize how those errors are produced with error handling middleware, so a thrown error anywhere becomes a clean, consistent response automatically.

๐ŸŽ‰ Great work!

Your API now speaks with one clear voice. Consistent codes and envelopes are what make an API a joy to build against.