Consistent Response Formatting
When every endpoint returns a slightly different JSON shape, clients drown in special cases: one route returns a bare array, another a { user } object, errors come back as plain strings here and structured objects there. A consistent response envelope fixes this by guaranteeing one predictable structure for success and one for failure across the whole API. This page defines a data / meta / error envelope, builds reusable helpers and middleware to emit it, and covers the serialization concerns that bite you in production.
Why an envelope
A response envelope is a thin, stable wrapper around your payload. Instead of returning the resource directly, you nest it under a known key and reserve sibling keys for metadata (pagination, request IDs) and errors. The benefit is uniformity: a client can write one parser, one error handler, and one success path that works for every endpoint, today and after you add fields tomorrow.
The trade-off is a little extra nesting. Some teams skip the envelope for success responses and only standardize errors. Both are valid — what matters is that you choose one shape and apply it everywhere.
| Concern | Without envelope | With envelope |
|---|---|---|
| Success shape | Varies per route | Always { data, meta } |
| Error shape | String, object, or HTML | Always { error: { code, message } } |
| Pagination metadata | Mixed into payload | Lives in meta |
| Client parsing | Per-endpoint logic | One reusable handler |
A standard envelope shape
Define exactly two top-level shapes. A success response carries data (the resource or collection) and optional meta (pagination, counts, request IDs). An error response carries a single error object with a stable machine-readable code, a human message, and optional details.
// success
{
"data": { "id": 42, "title": "Hello" },
"meta": { "requestId": "req_8c1f" }
}
// error
{
"error": {
"code": "VALIDATION_FAILED",
"message": "title is required",
"details": [{ "field": "title", "issue": "required" }]
}
}
The code is the contract: clients branch on VALIDATION_FAILED, not on the English message, which you are free to reword or localize. Keep success and error mutually exclusive — a response has data or error, never both.
Response helpers
The simplest implementation is two helper functions that build the envelope. Keeping them in one module means the shape is defined in exactly one place.
// lib/respond.js
function success(res, data, { status = 200, meta } = {}) {
const body = { data };
if (meta) body.meta = meta;
return res.status(status).json(body);
}
function failure(res, { status = 400, code, message, details }) {
const error = { code, message };
if (details) error.details = details;
return res.status(status).json({ error });
}
module.exports = { success, failure };
Routes now read declaratively, and no handler hand-rolls JSON:
const { success, failure } = require("../lib/respond");
router.get("/:id", async (req, res) => {
const post = await db.posts.find(req.params.id);
if (!post) {
return failure(res, { status: 404, code: "NOT_FOUND", message: "Post not found" });
}
return success(res, post, { meta: { requestId: req.id } });
});
Wrapping responses with middleware
Helpers require every handler to import and call them. To make the envelope effortless, attach the helpers to res with an app-level middleware so they are available as res.success() and res.fail() everywhere — including in third-party routers you do not control.
// middleware/envelope.js
module.exports = function envelope(req, res, next) {
res.success = (data, opts = {}) => {
const body = { data };
if (opts.meta) body.meta = opts.meta;
return res.status(opts.status || 200).json(body);
};
res.fail = ({ status = 400, code, message, details }) => {
const error = { code, message };
if (details) error.details = details;
return res.status(status).json({ error });
};
next();
};
Register it before your routers so the methods exist by the time handlers run:
const express = require("express");
const app = express();
app.use(require("./middleware/envelope"));
app.get("/posts", async (req, res) => {
const posts = await db.posts.all();
res.success(posts, { meta: { count: posts.length } });
});
Centralize error envelopes in your error-handling middleware too. A single
app.use((err, req, res, next) => res.fail({...}))at the end guarantees thrown errors and rejected promises serialize to the same shape as deliberateres.fail()calls — no leaked stack traces or HTML error pages.
Serialization concerns
res.json() runs JSON.stringify under the hood, and several types do not survive that round trip cleanly. Decide on conventions before they bite you.
- Dates become ISO 8601 strings automatically via
Date.prototype.toJSON. That is usually what you want, but confirm clients expect UTC ISO strings rather than epoch numbers. BigIntthrows —JSON.stringify(1n)raisesTypeError. Convert large IDs to strings before responding.undefinedis dropped;nullis kept. Usenullto signal “explicitly empty” andundefined/omission for “not applicable.”- Sensitive fields like
passwordHashleak if you spread a database row straight intodata. Use a serializer that whitelists fields.
function toPublicUser(row) {
return { id: String(row.id), name: row.name, createdAt: row.createdAt };
}
router.get("/me", async (req, res) => {
const user = await db.users.find(req.userId);
res.success(toPublicUser(user)); // passwordHash never escapes
});
For consistent date handling and field control across many models, a res.json replacer or a library like class-transformer (TypeScript) centralizes the rules. Express 5 keeps res.json semantics identical to 4.x, so these patterns carry over unchanged; the main 5.x difference is stricter routing and rejected-promise handling in async handlers, which makes a centralized error envelope even more valuable.
Best practices
- Pick one envelope shape (
data/metafor success,errorfor failure) and enforce it on every route, including error paths. - Branch clients on a stable
error.code, never on the human-readablemessage, so you can reword messages freely. - Attach helpers via middleware (
res.success/res.fail) so handlers cannot drift from the standard shape. - Route all thrown and rejected errors through one error-handling middleware that emits the same envelope.
- Always pass database rows through a serializer that whitelists fields — never spread raw records into
data. - Stringify
BigIntIDs and standardize on ISO 8601 UTC dates to avoid silent serialization surprises.