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:

flowchart LR A["Incoming request"] --> B["Parse: express.json, query parser"] B --> C["Authenticate: who is calling"] C --> D["Validate: params, query, body"] D --> E["Handle: business logic and data access"] E --> F["Serialize: status code, headers, JSON body"] B -.->|"error"| G["Error handler: problem details"] C -.->|"error"| G D -.->|"error"| G E -.->|"error"| G G --> F

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

/api/v1/books

collection of books

GET lists (paginated), POST creates

/api/v1/books/:id

one book

GET, PUT, PATCH, DELETE

/api/v1/books/:id/reviews

reviews of one book

A sub-collection, because a review cannot exist without its book

/api/v1/reviews/:id

one review

Addressable directly once created, so the URL stays short

/api/v1/authors and /api/v1/authors/:id

authors

Books refer to authors by id

The naming rules that keep an API predictable:

  • Use plural nouns for collections (/books, never /getBooks or /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/:id with a new status, or POST /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

GET

Read a representation

yes

yes

HEAD

Same headers as GET, no body (Express answers it from the GET handler automatically)

yes

yes

POST

Create a new item in a collection, or run a non-idempotent action

no

no

PUT

Replace the whole item at a known URL (or create it at that URL)

no

yes

PATCH

Apply a partial change to an item

no

not by definition

DELETE

Remove the item

no

yes

OPTIONS

Describe communication options; used by CORS preflight

yes

yes

QUERY

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

200

OK

GET and PUT or PATCH that return the resource

201

Created

POST that created a resource; send a Location header

202

Accepted

Work was queued, for example generating a cover thumbnail; the body points to a status resource

204

No Content

Successful DELETE, or an update where no body is returned

304

Not Modified

Conditional GET where the client copy is still fresh (Express does this for you with ETags)

400

Bad Request

Malformed JSON, or a query parameter that cannot be parsed

401

Unauthorized

No or invalid credentials (the name means "unauthenticated")

403

Forbidden

Authenticated but not allowed

404

Not Found

Unknown route or unknown resource id

405

Method Not Allowed

The URL exists but not for this verb; include an Allow header

406

Not Acceptable

The client asked for a representation the API cannot produce

409

Conflict

State conflict, such as a duplicate ISBN

412

Precondition Failed

An If-Match precondition no longer holds (lost-update protection)

415

Unsupported Media Type

The body is not application/json

422

Unprocessable Content

Well-formed JSON that fails validation

428

Precondition Required

The API demands If-Match and the client did not send it

429

Too Many Requests

Rate limit exceeded; include Retry-After

500

Internal Server Error

An unexpected bug; never expose the stack

503

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;
}

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

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 };
}

A few details in this router are deliberate and recur in every real API:

  • 201 Created carries a Location header with the URL of the new resource, built from req.baseUrl so 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 from res.location() and res.redirect(); use req.get('Referer') || '/' if you need it).

  • DELETE answers 204 with no body. A second DELETE of the same id answers 404, 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.body into the store as it is. Allow-listing the fields prevents mass assignment, where a client sets id, version, status or 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.body is undefined until a parser has run, instead of {}. The handler above therefore checks for it, and the application must install express.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)));

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

type

A URI that identifies the kind of problem; about:blank when the status alone is enough

title

A short, human-readable summary that stays the same for the same type

status

The HTTP status code, repeated in the body for clients that lose the response line

detail

An explanation specific to this occurrence

instance

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

application/json

application/merge-patch+json or application/json-patch+json

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' } })));

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)

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());

The alternatives and their trade-offs:

Strategy Strength Weakness

Path prefix (/api/v1)

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 (Accept: application/vnd.bookshelf.v2+json)

Stable URLs; uses real content negotiation (req.accepts())

Hard to test in a browser, and caches must Vary on Accept

Query parameter (?version=2)

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) and router.query() register a QUERY route, the same way app.get() does.

  • req.fresh and req.stale treat QUERY like GET and HEAD for conditional requests.

  • The runtime must know the method. Node.js accepts QUERY in 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; check http.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 });
}

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 with swagger-ui-express, and enforce it at run time with express-openapi-validator, which validates every request (and optionally every response) against the description and fails with an error that carries status and errors.

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

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; req.query is a getter and the default parser is simple; req.body is undefined without a parser; res.json(status, body), res.send(status, body) and res.redirect(status) signatures removed; app.listen callback receives the error; wildcard paths need names (/*splat); urlencoded defaults to extended: false

Express 5.1 (2025-03-31)

Express 5 became the default latest tag on npm; Node.js 18 minimum

Express 5.2 (2025-12-01)

Maintenance and security fixes

Express 5.3 (2026-10-09)

app.query() and router.query() for the HTTP QUERY method, req.fresh support for it, updated qs and proxy-addr dependencies with security fixes

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 res.send(status, body) or res.json(status, body)

Removed in Express 5. Chain res.status(code).json(body)

Mount the separate body-parser package, or the long-gone app.use(bodyParser())

Use the built-in express.json() and express.urlencoded(); and expect req.body to be undefined without them

Create with POST and answer 200, or return the bare document

Answer 201 with a Location header and the new representation; 204 for deletes

Save req.body straight into the database (mass assignment)

Allow-list fields, validate, and never take id, role or ownership from the client

Catch errors in every handler or call res.send(err) without return

Throw or reject; one central error handler; return next(err); check res.headersSent

Return stack traces or raw driver errors

Return problem details; log the cause server-side against an instance id

Use app.use('*', ...) as a 404 catch-all

Express 5 rejects * without a name; use a path-less app.use() handler, as in the examples above

Ship an open cors()

Allow an explicit list of origins; see the CORS page

Put filters in the path or return full collections

Paginate, cap limit, whitelist filters and sort keys

Use Mongoose callbacks, findByIdAndRemove and similar

Removed in Mongoose 7 and 8; use promises and findByIdAndDelete (see the MongoDB and Mongoose page)

No API description

Maintain an OpenAPI document and validate against it

Common mistakes

  • Sending the response twice (a missing return before res.status(...).json(...) in a guard clause). The second send raises ERR_HTTP_HEADERS_SENT.

  • Using PUT for partial updates, so every client that omits a field silently erases it.

  • Returning 200 with an error body. Clients, proxies and monitoring all key on the status code.

  • Trusting numeric query parameters. Number(req.query.limit) is NaN for abc and 0 for an empty string; validate with a regular expression or a schema.

  • Forgetting that req.query and req.params values are strings, so req.params.id === 5 is 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]=0 with 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: Express is on by default; app.disable('x-powered-by'), or better, use Helmet.