Building REST APIs with Express
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. The reference material consulted while preparing these pages is listed in the section bibliography. |
This page is written against Express 5.3 (npm express 5.3.0, published 2026-10-09) on Node.js 18 or later, the
minimum for Express 5, and it states where Express 4 (latest 4.22.3) behaves differently. Everything was checked against
the official documentation and the package repositories on 2026-10-29. Express has no notion of a "REST framework": it
gives you routing, request parsing, response helpers and an error path, and everything that makes an API good (the
resource model, the right status codes, stable pagination, a uniform error format, a machine-readable contract) is a
set of decisions you encode in ordinary middleware. The page builds the JSON API of the Bookshelf scenario that runs
through the whole Express.js section (/api/v1/books, /api/v1/authors, /api/v1/reviews) in small steps, from the
resource model to an OpenAPI description served next to the API. The examples use ESM and the node: prefix for
built-in modules, keep their data in memory so that they run with npm install express alone, and contain placeholders
only, never real secrets. Persistence is the subject of
Database Integration and
MongoDB and Mongoose; the framework-free version of the same API is in
Building a REST API (Node.js).
The request pipeline of an API
A JSON API is a pipeline of small layers, each with one job. Keeping the order explicit makes every later section of the page easy to place:
Parsing is covered in Body Parsing; authentication and validation have their own pages (Authentication and Authorization and Validation and Input Handling). This page concentrates on the design of the handlers and of what they return, plus the error and documentation layers at the two ends of the pipeline.
Resource design and URLs
REST models an API as resources (things with an identity) that are manipulated through a small, uniform set of operations. In the Bookshelf API the resources are books, authors and reviews, and the URL is the identity:
| URL | Resource | Notes |
|---|---|---|
|
collection of books |
|
|
one book |
|
|
reviews of one book |
A sub-collection, because a review cannot exist without its book |
|
one review |
Addressable directly once created, so the URL stays short |
|
authors |
Books refer to authors by id |
The naming rules that keep an API predictable:
-
Use plural nouns for collections (
/books, never/getBooksor/book/list). The verb is already in the HTTP method. -
Use lower-case, hyphenated path segments and keep identifiers opaque (UUIDs, ULIDs or database ids), never an array index, and never a value that leaks business information such as an ever-growing integer when that matters.
-
Nest at most one level (
/books/:id/reviews). Deeper nesting couples the URL to the data model; give the inner resource its own top-level URL instead. -
Use query parameters for filtering, sorting and paging of a collection and the path for identity.
-
Model actions that do not fit a resource (publishing a book, for example) as a sub-resource or a state change (
PATCH /books/:idwith a new status, orPOST /books/:id/publication), not as an RPC-style verb in the URL.
Express maps this directly. app.METHOD() and router.route() (see
Routing and Routers and Modular Apps)
declare the verbs of one URL together, and :id is a named parameter available as req.params.id.
Verbs and status codes
The HTTP semantics (RFC 9110) define what each method promises to clients, proxies and caches. Respecting those promises is what makes an API "RESTful" in practice:
| Method | Meaning on a collection or item | Safe | Idempotent |
|---|---|---|---|
|
Read a representation |
yes |
yes |
|
Same headers as |
yes |
yes |
|
Create a new item in a collection, or run a non-idempotent action |
no |
no |
|
Replace the whole item at a known URL (or create it at that URL) |
no |
yes |
|
Apply a partial change to an item |
no |
not by definition |
|
Remove the item |
no |
yes |
|
Describe communication options; used by CORS preflight |
yes |
yes |
|
A safe, idempotent read whose parameters travel in the body (see below) |
yes |
yes |
Safe means the request does not change server state, so crawlers and prefetchers may issue it. Idempotent means that
sending the same request several times leaves the server in the same state as sending it once, which is what allows a
client to retry after a timeout. A POST that creates a book is neither, which is why idempotency keys
exist.
The status code is the first thing a client looks at. The set that covers nearly all of an API:
| Code | Name | Use in the Bookshelf API |
|---|---|---|
|
OK |
|
|
Created |
|
|
Accepted |
Work was queued, for example generating a cover thumbnail; the body points to a status resource |
|
No Content |
Successful |
|
Not Modified |
Conditional |
|
Bad Request |
Malformed JSON, or a query parameter that cannot be parsed |
|
Unauthorized |
No or invalid credentials (the name means "unauthenticated") |
|
Forbidden |
Authenticated but not allowed |
|
Not Found |
Unknown route or unknown resource id |
|
Method Not Allowed |
The URL exists but not for this verb; include an |
|
Not Acceptable |
The client asked for a representation the API cannot produce |
|
Conflict |
State conflict, such as a duplicate ISBN |
|
Precondition Failed |
An |
|
Unsupported Media Type |
The body is not |
|
Unprocessable Content |
Well-formed JSON that fails validation |
|
Precondition Required |
The API demands |
|
Too Many Requests |
Rate limit exceeded; include |
|
Internal Server Error |
An unexpected bug; never expose the stack |
|
Service Unavailable |
Draining during shutdown or a dependency is down |
Two choices are worth settling once for the whole API. First, the choice between 400 and 422 for a validation
failure: either is defensible, but use one consistently (the examples below use 422 for semantic validation and 400
for malformed input). Second, a missing resource is 404, but a request that refers to a missing related resource
inside an otherwise valid body (a book with an unknown authorId) is a validation failure, not a 404.
Express helps with the mechanics. res.status(code) sets the status and is chainable; res.json(body) serializes with
JSON.stringify (honouring the json spaces, json replacer and json escape application settings) and sets the
Content-Type; res.sendStatus(code) sends the status text as the body; res.location(url) sets the Location header;
res.links() sets pagination Link headers. In Express 5, res.status() throws for a code outside the range 100 to 999,
and the removed signatures such as res.json(status, body), res.send(status, body) and res.send(status) with a
number are gone: always res.status(code).json(body).
A first resource: books
The first version of the API holds data in a Map and implements the five operations on /api/v1/books. A few helpers
come first, because the same few lines are needed everywhere: an asyncHandler-free route style (Express 5 forwards
rejected promises, see Error Handling), a function that turns a stored book into
its public representation, and an HttpError class that the problem-details handler will understand later.
// src/books/store.mjs
import { randomUUID } from 'node:crypto';
const books = new Map();
export function seed() {
for (const [title, author, year] of [
['Dune', 'Frank Herbert', 1965],
['Neuromancer', 'William Gibson', 1984],
['The Left Hand of Darkness', 'Ursula K. Le Guin', 1969],
]) {
create({ title, author, year, status: 'draft' });
}
}
export function create(input) {
const now = new Date().toISOString();
const book = { id: randomUUID(), ...input, version: 1, createdAt: now, updatedAt: now };
books.set(book.id, book);
return book;
}
export const find = (id) => books.get(id);
export const all = () => [...books.values()];
export const remove = (id) => books.delete(id);
export function replace(id, input) {
const current = books.get(id);
if (!current) return undefined;
const next = { ...input, id, version: current.version + 1, createdAt: current.createdAt, updatedAt: new Date().toISOString() };
books.set(id, next);
return next;
}
Source: Express: Routing
The error class is the contract between handlers and the error layer. A handler throws it; the single error handler at the end of the chain turns it into a problem-details response (see below):
// src/http-error.mjs
export class HttpError extends Error {
constructor(status, title, { detail, type, errors, headers } = {}) {
super(detail ?? title);
this.name = 'HttpError';
this.status = status;
this.title = title;
this.type = type;
this.errors = errors;
this.headers = headers;
this.expose = status < 500;
}
}
export const notFound = (what) => new HttpError(404, 'Not Found', { detail: `${what} was not found` });
Source: Express: Error handling
The router that implements the verbs for one resource uses router.route('/') and router.route('/:id') so that each
URL lists its methods together. Note that Express 5 rejects handlers that are not functions at registration time, and that
a 405 for the verbs a URL does not support is the job of router.all():
// src/books/router.mjs
import { Router } from 'express';
import * as store from './store.mjs';
import { HttpError, notFound } from '../http-error.mjs';
export const booksRouter = Router();
function publicBook({ id, title, author, year, status, version, createdAt, updatedAt }) {
return { id, title, author, year, status, version, createdAt, updatedAt };
}
function loadBook(req, res, next) {
const book = store.find(req.params.id);
if (!book) return next(notFound(`Book ${req.params.id}`));
res.locals.book = book;
next();
}
booksRouter.param('id', (req, res, next, id) => {
// Reject obviously malformed ids early with 400 instead of a confusing 404
if (!/^[0-9a-f-]{36}$/.test(id)) return next(new HttpError(400, 'Bad Request', { detail: 'The book id is not a valid UUID' }));
next();
});
booksRouter
.route('/')
.get((req, res) => {
res.json({ data: store.all().map(publicBook) });
})
.post((req, res) => {
const input = readBookBody(req.body); // validated below
const book = store.create({ ...input, status: 'draft' });
res.status(201).location(`${req.baseUrl}/${book.id}`).json(publicBook(book));
})
.all((req, res, next) => next(new HttpError(405, 'Method Not Allowed', { headers: { Allow: 'GET, HEAD, POST' } })));
booksRouter
.route('/:id')
.get(loadBook, (req, res) => res.json(publicBook(res.locals.book)))
.put(loadBook, (req, res) => {
const input = readBookBody(req.body);
res.json(publicBook(store.replace(req.params.id, { ...input, status: res.locals.book.status })));
})
.delete(loadBook, (req, res) => {
store.remove(req.params.id);
res.status(204).end();
})
.all((req, res, next) => next(new HttpError(405, 'Method Not Allowed', { headers: { Allow: 'GET, HEAD, PUT, PATCH, DELETE' } })));
export function readBookBody(body) {
if (body === undefined || typeof body !== 'object' || Array.isArray(body)) {
throw new HttpError(400, 'Bad Request', { detail: 'A JSON object body is required' });
}
const errors = [];
if (typeof body.title !== 'string' || body.title.trim() === '') errors.push({ pointer: '/title', detail: 'title is required' });
if (typeof body.author !== 'string' || body.author.trim() === '') errors.push({ pointer: '/author', detail: 'author is required' });
if (!Number.isInteger(body.year) || body.year < 0 || body.year > 3000) errors.push({ pointer: '/year', detail: 'year must be an integer between 0 and 3000' });
if (errors.length) throw new HttpError(422, 'Validation failed', { errors });
// Allow-list the fields: id, version and timestamps are never taken from the client
return { title: body.title.trim(), author: body.author.trim(), year: body.year };
}
Source: Express API: router.route() and Express API: Response
A few details in this router are deliberate and recur in every real API:
-
201 Createdcarries aLocationheader with the URL of the new resource, built fromreq.baseUrlso that the router does not need to know where it is mounted.res.location()accepts a relative URL; do not pass'back'(Express 5 removed that special value fromres.location()andres.redirect(); usereq.get('Referer') || '/'if you need it). -
DELETEanswers204with no body. A secondDELETEof the same id answers404, which is acceptable: the state (the book is gone) is the same, only the status differs, so the method is still idempotent. -
The handler never copies
req.bodyinto the store as it is. Allow-listing the fields prevents mass assignment, where a client setsid,version,statusor a role it should never control. The two books that cover REST with Express show bodies being saved wholesale; see Where the books are outdated. -
In Express 5,
req.bodyisundefineduntil a parser has run, instead of{}. The handler above therefore checks for it, and the application must installexpress.json()(next section).
Assembling the application
The application file mounts the parser, the versioned router and the two terminal layers (a JSON 404 and the error
handler). The server is started from a separate file so that tests can import app without opening a port, and the
app.listen callback receives the error in Express 5 (it is called with an error argument, for example EADDRINUSE):
// src/app.mjs
import express from 'express';
import { booksRouter } from './books/router.mjs';
import { problemHandler, notFoundHandler } from './problem.mjs';
import { openApiRouter } from './openapi.mjs';
export function createApp() {
const app = express();
app.disable('x-powered-by');
app.set('json spaces', process.env.NODE_ENV === 'production' ? 0 : 2);
app.use(express.json({ limit: '100kb', type: ['application/json', 'application/merge-patch+json'] }));
const v1 = express.Router();
v1.use('/books', booksRouter);
app.use('/api/v1', v1);
app.use(openApiRouter);
app.use(notFoundHandler);
app.use(problemHandler);
return app;
}
// src/server.mjs
import { createApp } from './app.mjs';
import * as store from './books/store.mjs';
store.seed();
const port = Number(process.env.PORT ?? 3000);
const server = createApp().listen(port, (error) => {
if (error) {
console.error('Cannot start the server', error);
process.exit(1);
}
console.log(`Bookshelf API on http://localhost:${port}/api/v1`);
});
process.on('SIGTERM', () => server.close(() => process.exit(0)));
Source: Express: Hello world and Express API: express
The options of express.json() that matter for an API are limit (the default is 100kb; raise it per route rather than
globally), strict (only objects and arrays are accepted by default, so a bare string body gives 400), type (the
media types parsed; the example adds the application/merge-patch+json type used by PATCH) and verify (a
hook that receives the raw buffer, used for webhook signatures). A body that does not match the type is skipped, and
req.body stays undefined, so a client that forgot its Content-Type header deserves a 415, not an obscure
validation error. Malformed JSON makes body-parser raise an error with status 400 and type entity.parse.failed, and
an over-sized body one with status 413, both of which the error handler in the next section converts.
Errors as problem details (RFC 9457)
Every API ends up inventing an error format. The standard one, RFC 9457 (which replaced RFC 7807) defines a JSON object
served as application/problem+json with five well-known members:
| Member | Meaning |
|---|---|
|
A URI that identifies the kind of problem; |
|
A short, human-readable summary that stays the same for the same |
|
The HTTP status code, repeated in the body for clients that lose the response line |
|
An explanation specific to this occurrence |
|
A URI identifying this occurrence, useful as a support reference |
Extension members are allowed, which is how the Bookshelf API reports every failing field at once in an errors array
with JSON Pointers. The error handler below is the only place that writes error responses. It understands HttpError,
the errors raised by body-parser and http-errors, and treats everything else as a 500 whose details are never sent
to the client:
// src/problem.mjs
import { randomUUID } from 'node:crypto';
import { HttpError } from './http-error.mjs';
const PROBLEM_TYPE = 'application/problem+json';
export function notFoundHandler(req, res, next) {
next(new HttpError(404, 'Not Found', { detail: `No route for ${req.method} ${req.path}` }));
}
// Four parameters: this is what makes Express treat it as an error handler
export function problemHandler(err, req, res, next) {
if (res.headersSent) return next(err);
let status = Number(err.status ?? err.statusCode);
if (!Number.isInteger(status) || status < 400 || status > 599) status = 500;
const body = {
type: err.type?.startsWith?.('http') ? err.type : 'about:blank',
title: err.title ?? (status >= 500 ? 'Internal Server Error' : 'Request failed'),
status,
instance: `urn:uuid:${randomUUID()}`,
};
if (status < 500 || err.expose) body.detail = err.message;
if (err.type === 'entity.parse.failed') body.detail = 'The request body is not valid JSON';
if (err.type === 'entity.too.large') body.title = 'Payload Too Large';
if (Array.isArray(err.errors)) body.errors = err.errors;
if (status >= 500) console.error(body.instance, err);
if (err.headers) res.set(err.headers);
res.status(status).type(PROBLEM_TYPE).send(JSON.stringify(body));
}
res.type('application/problem+json') sets the Content-Type header and appends the charset for text-like types, and
res.send() of a string keeps that header, whereas res.json() would overwrite it with application/json. That is why
the handler serializes by hand. A failing request now looks like this:
POST /api/v1/books HTTP/1.1
Content-Type: application/json
{"title":"","year":"1999"}
HTTP/1.1 422 Unprocessable Content
Content-Type: application/problem+json; charset=utf-8
{
"type": "about:blank",
"title": "Validation failed",
"status": 422,
"instance": "urn:uuid:5a2d6a54-7c0b-4b3f-9d74-1c0f2f9a0b11",
"detail": "Validation failed",
"errors": [
{ "pointer": "/title", "detail": "title is required" },
{ "pointer": "/author", "detail": "author is required" },
{ "pointer": "/year", "detail": "year must be an integer between 0 and 3000" }
]
}
Three rules keep the format trustworthy. Never put internal information (stack, SQL, file paths) in detail for a 5xx;
log it against the instance identifier instead, so support can find it. Never invent a new status in the body that
differs from the HTTP status. And register the JSON notFoundHandler after all routes, otherwise unknown URLs fall to the
default HTML finalhandler page. The package http-errors (already a dependency of Express) offers a ready-made
createError(404, 'message') if you prefer it to a custom class; the handler above reads its status, expose and
message unchanged.
PUT versus PATCH
The two update methods answer different questions, and the books' REST examples tend to blur them:
PUT |
PATCH |
|
|---|---|---|
Body |
The complete new representation |
A description of the change only |
Missing fields |
Removed or reset to defaults |
Left untouched |
Idempotent |
Yes, by definition |
Depends on the patch format |
Typical media type |
|
|
Use when |
The client owns the whole object |
Changing one or two fields of a large object |
A PUT is simple: validate the whole body with the same rules as POST, replace the stored object, keep the identity
fields (id, createdAt) from the server, and answer 200 with the new representation (or 204). If the URL does not
exist yet, a PUT may create the resource and answer 201; most APIs prefer to reject it with 404 and let clients
create through POST.
For PATCH, the simplest format to implement and the one that matches how people think is JSON Merge Patch (RFC 7396):
the body is a partial object, a present field replaces the stored value, and an explicit null removes it. It cannot
express changes inside arrays (the whole array is replaced) and cannot set a value to null, which is when the more
verbose JSON Patch (RFC 6902, an array of op/path/value operations) is the better tool.
// src/books/patch.mjs
import * as store from './store.mjs';
import { HttpError, notFound } from '../http-error.mjs';
import { readBookBody } from './router.mjs';
const PATCHABLE = new Set(['title', 'author', 'year', 'status']);
const STATUSES = new Set(['draft', 'published', 'archived']);
export function patchBook(req, res) {
const current = store.find(req.params.id);
if (!current) throw notFound(`Book ${req.params.id}`);
const contentType = req.get('Content-Type') ?? '';
if (!contentType.startsWith('application/merge-patch+json') && !contentType.startsWith('application/json')) {
throw new HttpError(415, 'Unsupported Media Type', {
detail: 'Send application/merge-patch+json',
headers: { 'Accept-Patch': 'application/merge-patch+json' },
});
}
const patch = req.body;
if (patch === undefined || typeof patch !== 'object' || Array.isArray(patch)) {
throw new HttpError(400, 'Bad Request', { detail: 'A JSON object patch is required' });
}
const unknown = Object.keys(patch).filter((key) => !PATCHABLE.has(key));
if (unknown.length) {
throw new HttpError(422, 'Validation failed', { errors: unknown.map((key) => ({ pointer: `/${key}`, detail: 'field cannot be patched' })) });
}
if ('status' in patch && !STATUSES.has(patch.status)) {
throw new HttpError(422, 'Validation failed', { errors: [{ pointer: '/status', detail: 'status must be draft, published or archived' }] });
}
// Merge, then validate the merged result with the same rules as POST and PUT
const merged = { ...current, ...patch };
const input = readBookBody(merged);
const updated = store.replace(current.id, { ...input, status: merged.status });
res.json(updated);
}
// in src/books/router.mjs: PATCH joins the /:id chain, before the 405 fallback
import { patchBook } from './patch.mjs';
booksRouter
.route('/:id')
.get(loadBook, (req, res) => res.json(publicBook(res.locals.book)))
// .put() and .delete() unchanged
.patch(patchBook)
.all((req, res, next) => next(new HttpError(405, 'Method Not Allowed', { headers: { Allow: 'GET, HEAD, PUT, PATCH, DELETE' } })));
Source: Express: Routing and RFC 7396: JSON Merge Patch
patchBook goes in the existing route('/:id') chain, ahead of .all(). A separate booksRouter.patch('/:id', ...)
registered after that chain would never run, because the .all() handler already answers every method with 405.
Validating the merged object rather than the patch alone is the detail most implementations miss: a patch that is valid
on its own can still leave the stored object invalid. The Accept-Patch response header advertises the supported patch
formats, and OPTIONS handlers or 415 responses are the natural places to send it.
Lost updates and conditional requests
Two clients that read a book, edit it and PUT it back will silently overwrite each other. HTTP solves this with
validators and preconditions, and Express supplies half of the machinery. For every GET handler that answers with
res.send() or res.json(), Express computes a weak ETag from the body (the etag application setting, weak by
default) and answers 304 Not Modified itself when the request carries a matching If-None-Match header. req.fresh
and req.stale expose that decision; the caching rules are in
Content Negotiation and HTTP Caching.
For writes the API must do the check. The simplest scheme uses the version counter that the store already maintains as
a strong validator:
// src/books/preconditions.mjs
import { HttpError } from '../http-error.mjs';
export function requireIfMatch(getCurrent) {
return (req, res, next) => {
const header = req.get('If-Match');
if (!header) {
return next(new HttpError(428, 'Precondition Required', { detail: 'Send If-Match with the ETag of the version you read' }));
}
const current = getCurrent(req);
if (current && header !== `"${current.version}"`) {
return next(new HttpError(412, 'Precondition Failed', { detail: 'The book changed since you read it' }));
}
next();
};
}
// In the GET handler: res.set('ETag', `"${book.version}"`)
// On PUT and PATCH, in the route('/:id') chain: .put(requireIfMatch((req) => store.find(req.params.id)), handler)
Source: Express API: Response and RFC 9110: Conditional requests
When a handler sets its own ETag, Express does not overwrite it. In databases the same idea is "optimistic locking": a
WHERE version = ? clause or an updatedAt comparison, mapped to 409 or 412 when no row was updated.
Pagination, filtering and sorting
A collection endpoint that returns everything is a denial-of-service waiting to happen. Pagination, filtering and sorting are all expressed in the query string, so the first thing to know is how Express parses it.
req.query holds the parsed query string. Express 5 changed it in two ways relative to Express 4: it is now a getter
(a handler or middleware can no longer assign to req.query; copy it to a local variable or to res.locals instead),
and the default query parser setting is simple, which uses the Node.js node:querystring module and does not parse
bracket notation. ?filter[year]=1984 is therefore the single key filter[year] and ?tag=a&tag=b is an array, but
?a[b]=1 no longer becomes a nested object as it did with the Express 4 default (extended, backed by the qs
package). Setting app.set('query parser', 'extended') restores nesting, at the cost of a larger attack surface
(deeply nested or very long arrays are parsed). The page recommends staying on simple and using flat, explicit
parameters. Whatever the parser, every value is a string (or an array of strings), so numbers must be converted and
checked.
Offset pagination
The familiar ?page=2&limit=20 (or offset and limit) is easy to build and lets clients jump to any page. Its weakness
is that results drift when rows are inserted or deleted between two requests, and large offsets are slow in databases.
// src/books/list.mjs
import * as store from './store.mjs';
import { HttpError } from '../http-error.mjs';
const MAX_LIMIT = 100;
const DEFAULT_LIMIT = 20;
const SORTABLE = new Set(['title', 'author', 'year', 'createdAt']);
const STATUSES = new Set(['draft', 'published', 'archived']);
function intParam(name, raw, { min, max, fallback }) {
if (raw === undefined) return fallback;
if (typeof raw !== 'string' || !/^\d+$/.test(raw)) {
throw new HttpError(400, 'Bad Request', { detail: `${name} must be a non-negative integer` });
}
return Math.min(Math.max(Number(raw), min), max);
}
export function listBooks(req, res) {
const query = req.query; // Express 5: a read-only getter
const limit = intParam('limit', query.limit, { min: 1, max: MAX_LIMIT, fallback: DEFAULT_LIMIT });
const page = intParam('page', query.page, { min: 1, max: 1_000_000, fallback: 1 });
let items = store.all();
if (query.status !== undefined) {
const wanted = [query.status].flat();
if (!wanted.every((status) => STATUSES.has(status))) {
throw new HttpError(400, 'Bad Request', { detail: 'status must be draft, published or archived' });
}
items = items.filter((book) => wanted.includes(book.status));
}
if (typeof query.author === 'string') {
items = items.filter((book) => book.author.toLowerCase().includes(query.author.toLowerCase()));
}
if (typeof query.q === 'string') {
const needle = query.q.toLowerCase();
items = items.filter((book) => book.title.toLowerCase().includes(needle));
}
if (query.yearFrom !== undefined) {
const from = intParam('yearFrom', query.yearFrom, { min: 0, max: 3000, fallback: 0 });
items = items.filter((book) => book.year >= from);
}
// Sorting: ?sort=-year,title (a leading minus means descending)
const sort = typeof query.sort === 'string' ? query.sort.split(',') : ['title'];
for (const key of sort.toReversed()) {
const descending = key.startsWith('-');
const field = descending ? key.slice(1) : key;
if (!SORTABLE.has(field)) throw new HttpError(400, 'Bad Request', { detail: `Cannot sort by ${field}` });
items.sort((a, b) => (a[field] < b[field] ? -1 : a[field] > b[field] ? 1 : 0) * (descending ? -1 : 1));
}
const total = items.length;
const pages = Math.max(1, Math.ceil(total / limit));
const data = items.slice((page - 1) * limit, page * limit);
const link = (target) => {
const params = new URLSearchParams(Object.entries(query).flatMap(([k, v]) => [v].flat().map((x) => [k, x])));
params.set('page', String(target));
params.set('limit', String(limit));
return `${req.baseUrl}${req.path}?${params}`;
};
res.links({
first: link(1),
last: link(pages),
...(page > 1 ? { prev: link(page - 1) } : {}),
...(page < pages ? { next: link(page + 1) } : {}),
});
res.set('X-Total-Count', String(total));
res.json({ data: data.map(({ id, title, author, year, status }) => ({ id, title, author, year, status })), meta: { page, limit, total, pages } });
}
// in src/books/router.mjs: listBooks replaces the inline .get() handler of route('/')
import { listBooks } from './list.mjs';
booksRouter
.route('/')
.get(listBooks)
// .post() and .all() unchanged
Source: Express API: Request (req.query) and
Express API: Application (query parser)
Things this handler does on purpose: it caps limit so a client cannot ask for a million rows; it treats req.query
values as strings and rejects anything that is not a plain digit sequence (an attacker can send ?limit[]=1 or
?limit=1&limit=2, which arrive as arrays); it whitelists the sortable fields so the client cannot sort or filter by a
field you did not index or want to disclose; and it reports navigation as Link headers (RFC 8288) built with
res.links(), plus a meta object for clients that prefer the body. Array.prototype.toReversed() needs Node.js 20 or
later; on Node.js 18 use [...sort].reverse().
Cursor (keyset) pagination
For feeds and large tables, return an opaque cursor that encodes the sort key of the last item and ask the database for "rows after this key". The result is stable under inserts and deletes and uses an index at any depth:
// src/books/cursor.mjs
export const encodeCursor = (book) => Buffer.from(JSON.stringify({ t: book.createdAt, id: book.id })).toString('base64url');
export function decodeCursor(raw) {
try {
const { t, id } = JSON.parse(Buffer.from(raw, 'base64url').toString('utf8'));
if (typeof t !== 'string' || typeof id !== 'string') throw new Error('shape');
return { t, id };
} catch {
return undefined; // the caller answers 400
}
}
// Usage: items sorted by (createdAt, id); the next page is items strictly after the cursor
// SQL: WHERE (created_at, id) > ($1, $2) ORDER BY created_at, id LIMIT $3 + 1
// Return `nextCursor` when the extra (limit + 1) row exists, otherwise omit it
Source: Node.js: Buffer (base64url)
Offset pagination is the right default for small, mostly static collections and admin screens; cursor pagination is the
right default for anything large, ordered by time or exposed to the public. Whichever is chosen, document the maximum
limit, the default sort order and whether total is exact (counting is expensive on large tables).
Versioning with routers
An API is a contract, and breaking it needs a new version. The most common and the easiest to operate in Express is a
version prefix in the path, which maps directly onto a router mounted at /api/v1. When v2 arrives, mount a second
router next to it and share the code that did not change:
// src/api.mjs
import express from 'express';
import { booksRouter as booksV1 } from './books/router.mjs';
import { booksRouterV2 } from './books/router-v2.mjs';
export function createApi() {
const api = express.Router();
const v1 = express.Router();
v1.use((req, res, next) => {
res.set({ Deprecation: 'true', Sunset: 'Wed, 31 Dec 2027 23:59:59 GMT', Link: '</api/v2/books>; rel="successor-version"' });
next();
});
v1.use('/books', booksV1);
const v2 = express.Router();
v2.use('/books', booksRouterV2);
api.use('/v1', v1);
api.use('/v2', v2);
return api;
}
// app.use('/api', createApi());
Source: Express: Routing and Express API: Router
The alternatives and their trade-offs:
| Strategy | Strength | Weakness |
|---|---|---|
Path prefix ( |
Visible, easy to route, to cache and to log; trivial to run two versions side by side |
The URL of a resource changes between versions |
Header ( |
Stable URLs; uses real content negotiation ( |
Hard to test in a browser, and caches must |
Query parameter ( |
Easy to try |
Easy to forget, fragile for caches, pollutes the query string |
No versioning, additive changes only |
No branching code |
Only works while changes stay backward compatible |
Prefer additive changes (new optional fields, new endpoints) that need no new version, and treat removing a field,
changing a type or tightening validation as breaking. The Deprecation and Sunset response headers (RFC 9745 and
RFC 8594) tell clients a version is on its way out. The routers' options (mergeParams, strict, caseSensitive) and the
mounting rules are in Routers and Modular Apps.
Idempotency keys for POST
POST is not idempotent, so a client that times out and retries can create two books or charge a card twice. The
widely used remedy, from the IETF Idempotency-Key header draft, is a client-generated key sent with the request. The
server remembers the response for that key and replays it for a repeat. The sketch uses a Map; production code stores
the keys with a time to live in Redis or the database, scoped to the authenticated user:
// src/idempotency.mjs
import { createHash } from 'node:crypto';
import { HttpError } from './http-error.mjs';
const seen = new Map(); // key -> { fingerprint, status, body }
export function idempotent(req, res, next) {
const key = req.get('Idempotency-Key');
if (!key) return next();
if (key.length > 200) return next(new HttpError(400, 'Bad Request', { detail: 'Idempotency-Key is too long' }));
const fingerprint = createHash('sha256').update(req.method + req.originalUrl + JSON.stringify(req.body ?? {})).digest('hex');
const stored = seen.get(key);
if (stored) {
if (stored.fingerprint !== fingerprint) {
return next(new HttpError(422, 'Idempotency key reused', { detail: 'The key was used with a different request' }));
}
res.set('Idempotent-Replayed', 'true');
return res.status(stored.status).json(stored.body);
}
const json = res.json.bind(res);
res.json = (body) => {
seen.set(key, { fingerprint, status: res.statusCode, body });
return json(body);
};
next();
}
// in the route('/') chain: .post(idempotent, createBook)
The sketch has a gap that a real implementation must close: two concurrent requests with the same key both miss the
Map and both create a book. Reserve the key atomically (a unique constraint or SET NX in Redis) before running the
handler, and return 409 while the first request is still in flight.
The HTTP QUERY method for complex searches
Some searches do not fit in a URL: dozens of filters, long lists of ids, or a structured query that you would rather not
leak into access logs. Sending them with GET and a body is not allowed, and POST loses safety and cacheability. The
IETF’s QUERY method fills the gap: it is safe and idempotent like GET but carries the query in the body, so
caches can in principle key on the body. Express 5.3 adds first-class support:
-
app.query(path, ...handlers)androuter.query()register aQUERYroute, the same wayapp.get()does. -
req.freshandreq.staletreatQUERYlikeGETandHEADfor conditional requests. -
The runtime must know the method. Node.js accepts
QUERYin its HTTP parser only in recent lines, which the Express API reference lists as Node.js 20.19.3 or later in the 20 line and 22.2.0 or later in the 22 line and above; checkhttp.METHODS.includes('QUERY')at start-up.
// src/books/search.mjs
import { METHODS } from 'node:http';
import * as store from './store.mjs';
import { HttpError } from '../http-error.mjs';
export function mountSearch(router) {
if (!METHODS.includes('QUERY')) {
console.warn('This Node.js version does not support the QUERY method; the fallback is POST /search');
router.post('/search', search);
return;
}
router.query('/search', search);
router.post('/search', search); // keep a POST fallback for clients and proxies that do not know QUERY
}
function search(req, res) {
const { titles, authors, yearRange } = req.body ?? {};
if (!Array.isArray(titles ?? [])) throw new HttpError(422, 'Validation failed', { errors: [{ pointer: '/titles', detail: 'must be an array' }] });
const [from, to] = yearRange ?? [0, 3000];
const data = store.all().filter((book) =>
(!titles || titles.some((t) => book.title.toLowerCase().includes(String(t).toLowerCase()))) &&
(!authors || authors.includes(book.author)) &&
book.year >= from && book.year <= to);
res.json({ data });
}
Source: Express API: Application (app.query()) and
IETF: The HTTP QUERY Method
Two practical cautions. Intermediaries and some HTTP clients and API gateways still reject unknown methods, so keep a
POST fallback while clients migrate; and CORS treats QUERY as a non-simple method, so cross-origin browser calls
trigger a preflight whose Access-Control-Allow-Methods must list it (see CORS and
Official Middleware Modules).
Representations, media types and CORS
An API should be explicit about the media types it speaks. req.is('application/json') tells whether the request body
has the expected type, req.accepts(['json', 'html']) negotiates the response, and res.format({ ... }) dispatches by
Accept header and answers 406 when nothing matches. res.json() always sends application/json; the custom
application/problem+json of the error handler is set explicitly. When a browser front-end (React, Vue, Angular or Next.js
running on another origin) calls the API, the server must answer the preflight and send the Access-Control-* headers
with the cors middleware, with an explicit list of origins rather than a wildcard when credentials are involved. The
front-end side is in React, Vue, Angular
and Next.js; the same Bookshelf API can serve any of them.
Documenting the API with OpenAPI
Docs written by hand drift from the code. An OpenAPI 3 description (a JSON or YAML file) is a machine-readable contract from which documentation, client SDKs, mocks and request validation can all be generated. Three approaches fit Express:
-
Schema-first. Write
openapi.yaml, serve it withswagger-ui-express, and enforce it at run time withexpress-openapi-validator, which validates every request (and optionally every response) against the description and fails with an error that carriesstatusanderrors. -
Code-first. Generate the description from route comments or from schemas that also drive validation (Zod or Ajv schemas converted to JSON Schema); the code stays the source of truth.
-
Generated clients and stubs. Tools such as the OpenAPI Generator produce typed clients for TypeScript, Java or Kotlin from the same file.
The schema-first route is the most robust for a public API because the contract is reviewed before the code. A compact description of the book collection, built as a JavaScript object so the example needs no YAML parser (a real project keeps a YAML file and loads it):
// src/openapi.mjs
import { Router } from 'express';
import swaggerUi from 'swagger-ui-express';
export const spec = {
openapi: '3.1.0',
info: { title: 'Bookshelf API', version: '1.0.0' },
servers: [{ url: '/api/v1' }],
paths: {
'/books': {
get: {
summary: 'List books',
parameters: [
{ name: 'page', in: 'query', schema: { type: 'integer', minimum: 1, default: 1 } },
{ name: 'limit', in: 'query', schema: { type: 'integer', minimum: 1, maximum: 100, default: 20 } },
{ name: 'sort', in: 'query', schema: { type: 'string', example: '-year,title' } },
],
responses: {
200: { description: 'A page of books', content: { 'application/json': { schema: { $ref: '#/components/schemas/BookPage' } } } },
400: { $ref: '#/components/responses/Problem' },
},
},
post: {
summary: 'Create a book',
requestBody: { required: true, content: { 'application/json': { schema: { $ref: '#/components/schemas/BookInput' } } } },
responses: {
201: { description: 'Created', headers: { Location: { schema: { type: 'string' } } }, content: { 'application/json': { schema: { $ref: '#/components/schemas/Book' } } } },
422: { $ref: '#/components/responses/Problem' },
},
},
},
'/books/{id}': {
parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string', format: 'uuid' } }],
get: { summary: 'Read a book', responses: { 200: { description: 'The book', content: { 'application/json': { schema: { $ref: '#/components/schemas/Book' } } } }, 404: { $ref: '#/components/responses/Problem' } } },
delete: { summary: 'Delete a book', responses: { 204: { description: 'Deleted' }, 404: { $ref: '#/components/responses/Problem' } } },
},
},
components: {
schemas: {
BookInput: {
type: 'object',
required: ['title', 'author', 'year'],
additionalProperties: false,
properties: { title: { type: 'string', minLength: 1 }, author: { type: 'string', minLength: 1 }, year: { type: 'integer', minimum: 0, maximum: 3000 } },
},
Book: {
allOf: [{ $ref: '#/components/schemas/BookInput' }, { type: 'object', properties: { id: { type: 'string', format: 'uuid' }, status: { type: 'string', enum: ['draft', 'published', 'archived'] }, version: { type: 'integer' } } }],
},
BookPage: { type: 'object', properties: { data: { type: 'array', items: { $ref: '#/components/schemas/Book' } }, meta: { type: 'object' } } },
Problem: { type: 'object', required: ['title', 'status'], properties: { type: { type: 'string' }, title: { type: 'string' }, status: { type: 'integer' }, detail: { type: 'string' }, instance: { type: 'string' } } },
},
responses: {
Problem: { description: 'Problem details (RFC 9457)', content: { 'application/problem+json': { schema: { $ref: '#/components/schemas/Problem' } } } },
},
},
};
export const openApiRouter = Router();
openApiRouter.get('/openapi.json', (req, res) => res.json(spec));
openApiRouter.use('/docs', swaggerUi.serve, swaggerUi.setup(spec));
// enforcing the contract: install before the routes in src/app.mjs
import * as OpenApiValidator from 'express-openapi-validator';
import { spec } from './openapi.mjs';
app.use(
OpenApiValidator.middleware({
apiSpec: spec,
validateRequests: true,
validateResponses: false, // enable in tests and staging, not on a hot production path
ignorePaths: /^\/(docs|openapi\.json)/,
}),
);
// Its errors carry `status` and `errors`, so problemHandler already turns them into problem details.
npm install express swagger-ui-express express-openapi-validator
node src/server.mjs
# http://localhost:3000/docs interactive documentation
# http://localhost:3000/openapi.json the raw contract
Source: swagger-ui-express and express-openapi-validator; the format itself is specified at OpenAPI Specification
Before adopting express-openapi-validator, read its compatibility notes for Express 5 and for the OpenAPI version you
write; it is a third-party package and follows its own release schedule. Whichever tool is used, keep the description in
the repository, review changes to it like code, publish it as a build artifact so that clients can generate SDKs, and
serve the interactive UI only where it is safe (many teams disable it in production, or protect it).
Testing the API without a network
Because createApp() returns an app without listening, the API can be tested in-process. Node’s built-in test runner
and fetch against an ephemeral port is enough, with no extra dependency:
// test/books.test.mjs
import { test, before, after } from 'node:test';
import assert from 'node:assert/strict';
import { createApp } from '../src/app.mjs';
let server;
let base;
before(async () => {
server = createApp().listen(0);
await new Promise((resolve) => server.once('listening', resolve));
base = `http://127.0.0.1:${server.address().port}/api/v1`;
});
after(() => server.close());
test('creates a book and returns 201 with Location', async () => {
const res = await fetch(`${base}/books`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title: 'Hyperion', author: 'Dan Simmons', year: 1989 }),
});
assert.equal(res.status, 201);
assert.match(res.headers.get('location'), /\/api\/v1\/books\/[0-9a-f-]{36}$/);
});
test('reports validation problems as problem details', async () => {
const res = await fetch(`${base}/books`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' });
assert.equal(res.status, 422);
assert.match(res.headers.get('content-type'), /^application\/problem\+json/);
});
Source: Node.js: Test runner
Testing in depth (supertest, test databases, contract tests) is the subject of Testing.
What changed recently
| Version | Change that affects REST APIs |
|---|---|
Express 5.0 (2024-09) |
Rejected promises from handlers reach the error handlers; |
Express 5.1 (2025-03-31) |
Express 5 became the default |
Express 5.2 (2025-12-01) |
Maintenance and security fixes |
Express 5.3 (2026-10-09) |
|
Express 4 remains supported (latest 4.22.3). On Express 4, an async handler that rejects is not forwarded: wrap it or
use a helper, otherwise the request hangs and the process may receive an unhandled rejection. Express 4’s default extended
query parser yields nested objects, and req.query is assignable.
Where the books are outdated
The books that include a REST API with Express (Full-Stack Web Development with Vue.js and Node, Learning Single-page Web Application Development, Node.js 6.x Blueprints and Modern Full-Stack React Projects) were all written for Express 4, and several habits in them need correcting before they are copied into a new project:
| What the books do | What to do now |
|---|---|
Respond with |
Removed in Express 5. Chain |
Mount the separate |
Use the built-in |
Create with |
Answer |
Save |
Allow-list fields, validate, and never take |
Catch errors in every handler or call |
Throw or reject; one central error handler; |
Return stack traces or raw driver errors |
Return problem details; log the cause server-side against an |
Use |
Express 5 rejects |
Ship an open |
Allow an explicit list of origins; see the CORS page |
Put filters in the path or return full collections |
Paginate, cap |
Use Mongoose callbacks, |
Removed in Mongoose 7 and 8; use promises and |
No API description |
Maintain an OpenAPI document and validate against it |
Common mistakes
-
Sending the response twice (a missing
returnbeforeres.status(...).json(...)in a guard clause). The second send raisesERR_HTTP_HEADERS_SENT. -
Using
PUTfor partial updates, so every client that omits a field silently erases it. -
Returning
200with an error body. Clients, proxies and monitoring all key on the status code. -
Trusting numeric query parameters.
Number(req.query.limit)isNaNforabcand0for an empty string; validate with a regular expression or a schema. -
Forgetting that
req.queryandreq.paramsvalues are strings, soreq.params.id === 5is never true. -
Letting
?sort=and?fields=reach the database unchecked, which allows sorting by sensitive columns or injecting operators into a Mongo filter (?price[$gt]=0with the extended parser). -
Registering the error handler before the routes, or giving it three parameters, so it is never called.
-
Returning different error shapes from different layers (the validator,
body-parser, the 404 handler and the handlers). Put all of them through one handler. -
Leaking the framework:
X-Powered-By: Expressis on by default;app.disable('x-powered-by'), or better, use Helmet.