๐ค 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.
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.
| Range | Category | Meaning |
|---|---|---|
| 1xx | Informational | Request received, still processing |
| 2xx | Success | Received, understood, accepted |
| 3xx | Redirection | Further action needed to complete |
| 4xx | Client Error | The request was wrong (fix your call) |
| 5xx | Server Error | The 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:
| Operation | Success | Common errors |
|---|---|---|
| GET /users | 200 OK | 401, 403 |
| GET /users/:id | 200 OK | 404, 401, 403 |
| POST /users | 201 Created | 400, 409, 422 |
| PUT /users/:id | 200 OK | 400, 404, 422 |
| DELETE /users/:id | 204 No Content | 404, 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.
โ 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:
| Header | Purpose |
|---|---|
Content-Type | Media type, e.g. application/json |
Cache-Control | How/whether the response may be cached |
Location | URL of a newly created resource (with 201) |
X-RateLimit-Remaining | Requests 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:
- Create
utils/api-response.jsexporting a helper withsuccess(data, meta),created(data),notFound(resource), andpaginated(items, page, limit, total). - Wire it onto
res.apiwith a middleware. - Build a
GET /api/notes(paginated) andGET /api/notes/:id(200 or 404) using only the helper. - 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 alwaysreturnin 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.