Build a REST API with Node.js and Express 5: Production Checklist - NextGenBeing
Back to discoveries

Build a REST API with Node.js and Express 5: Production Checklist

Build a REST API with Node.js and Express 5: validation, error handling, auth, helmet, rate limits, graceful shutdown and tests, with the app source and real output.

AI Tutorials 15 min read
Bekzod Erkinov

Bekzod Erkinov

Apr 24, 2026 • 163 views
Size:
Height:
15 min read 6,376 words Focus mode: Eye care:

Listen to Article

Loading...
0:00 / 0:00
0:00 0:00
Low High
0% 100%
⏸ Paused ▶️ Now playing... Ready to play ✓ Finished
Table of contents · 11 sections

Build a REST API with Node.js and Express 5: Production Checklist

By the end of this post you have a small Express 5 notes API you can copy and run (five files, 12 passing tests) and the six rules a production API usually gets wrong, each backed by output I produced. The rules are in the checklist below. Every source file, every experiment script and the exact curl/docker commands are in the post.

Production checklist

Rule Why Section
helmet() before the rate limiter A limiter that ends the response first sends 429s with no security headers Middleware order
Logger before the rate limiter Otherwise throttled requests are never logged Middleware order
Rate limiter before the body parser Rejected clients never cost you a parse Middleware order
400 for unparseable bodies, 422 for schema violations Clients can tell "not JSON" from "wrong fields" Validation
One error envelope; 500s return a request id, never the message No leaks, one shape to parse Error handling
SHUTDOWN_MS below your orchestrator's kill timeout The drain finishes before the SIGKILL Graceful shutdown

Build a REST API with Node.js and Express 5

Versions installed on 2026-10-07 (npm ls, matching npm view): express 5.2.1, helmet 8.3.0, express-rate-limit 8.7.1, pino 10.4.0, pino-http 11.0.0, zod 4.6.5, supertest 7.3.1. Several of these are recent majors: zod 4.0.0 was published 2025-07-09, pino 10.0.0 on 2025-10-03 and pino-http 11.0.0 on 2025-10-04 (npm view <pkg> time). Examples online are often written for zod 3 or pino 9, so check the version before pasting. I ran it on Node 24.19 locally and Node 22 in Docker. Express 5 requires Node.js 18 or higher.

Four files make the app. package.json:

{
  "name": "express5-api",
  "version": "1.0.0",
  "type": "module",
  "scripts": { "start": "node src/server.js", "test": "node --test" },
  "dependencies": {
    "express": "^5.2.1", "express-rate-limit": "^8.7.1", "helmet": "^8.3.0",
    "pino": "^10.4.0", "pino-http": "^11.0.0", "zod": "^4.6.5"
  },
  "devDependencies": { "supertest": "^7.3.1" }
}

src/errors.js holds the error type, the JSON 404 and the single error handler:

export class HttpError extends Error {
  constructor(status, code, message, details) {
    super(message);
    this.status = status;
    this.code = code;
    this.details = details;
  }
}

export function notFound(req, res) {
  res.status(404).json({ error: { code: 'not_found', message: `No route for ${req.method} ${req.path}` } });
}

// Express recognises error handlers by their 4-argument signature.
export function errorHandler(logger) {
  return (err, req, res, next) => {
    if (res.headersSent) return next(err);
    // body-parser errors carry a type
    if (err.type === 'entity.parse.failed') {
      return res.status(400).json({ error: { code: 'bad_json', message: 'Request body is not valid JSON' } });
    }
    if (err.type === 'entity.too.large') {
      return res.status(413).json({ error: { code: 'too_large', message: 'Request body too large' } });
    }
    if (err instanceof HttpError) {
      return res.status(err.status).json({ error: { code: err.code, message: err.message, details: err.details } });
    }
    logger.error({ err, reqId: req.id }, 'unhandled error');
    res.status(500).json({ error: { code: 'internal', message: 'Something went wrong', requestId: req.id } });
  };
}

src/app.js builds the app. It is a factory so tests can create a fresh app (fresh Map, fresh rate-limit counters) per test:

import express from 'express';
import helmet from 'helmet';
import { rateLimit } from 'express-rate-limit';
import pino from 'pino';
import { pinoHttp } from 'pino-http';
import { timingSafeEqual } from 'node:crypto';
import { z } from 'zod';
import { HttpError, notFound, errorHandler } from './errors.js';

const title = z.string().trim().min(1).max(120);
const NoteInput = z.object({ title, body: z.string().max(5000).default('') });
// No .default() here: on a partial schema it would fill in body: '' and wipe the stored body.
const NotePatch = z.object({ title, body: z.string().max(5000) }).partial().refine((v) => Object.keys(v).length > 0, 'Send at least one field');
const ListQuery = z.object({
  page: z.coerce.number().int().min(1).default(1),
  limit: z.coerce.number().int().min(1).max(100).default(20),
});

const validate = (schema, where) => (req, res, next) => {
  const parsed = schema.safeParse(where === 'query' ? req.query : req.body);
  if (!parsed.success) {
    throw new HttpError(422, 'validation_failed', 'Validation failed', z.flattenError(parsed.error).fieldErrors);
  }
  req.valid = parsed.data; // don't overwrite req.query: it's a getter in Express 5
  next();
};

function requireToken(expected) {
  const want = Buffer.from(expected);
  return (req, res, next) => {
    const header = req.get('authorization') ?? '';
    const got = Buffer.from(header.startsWith('Bearer ') ? header.slice(7) : '');
    if (got.length !== want.length || !timingSafeEqual(got, want)) {
      res.set('WWW-Authenticate', 'Bearer');
      throw new HttpError(401, 'unauthorized', 'Missing or invalid token');
    }
    next();
  };
}

export function createApp({
  token = process.env.API_TOKEN,
  logger = pino({ level: process.env.LOG_LEVEL ?? 'info' }),
  rateLimitMax = 100,
  demoRoutes = false, // /slow and /boom are demo-only: never enable in production
} = {}) {
  if (!token) throw new Error('API_TOKEN is required');
  const app = express();
  const notes = new Map();
  let nextId = 1;

  app.disable('x-powered-by');
  app.use(helmet());
  app.use(pinoHttp({ logger, redact: ['req.headers.authorization'] }));
  app.use(rateLimit({ windowMs: 60_000, limit: rateLimitMax, standardHeaders: 'draft-7', legacyHeaders: false }));
  app.use(express.json({ limit: '100kb' }));

  app.get('/healthz', (req, res) => res.json({ status: 'ok' }));

  const api = express.Router();
  api.use(requireToken(token));

  api.get('/notes', validate(ListQuery, 'query'), (req, res) => {
    const { page, limit } = req.valid;
    const all = [...notes.values()];
    const items = all.slice((page - 1) * limit, page * limit);
    res.json({ items, meta: { page, limit, total: all.length, pages: Math.max(1, Math.ceil(all.length / limit)) } });
  });

  api.post('/notes', validate(NoteInput, 'body'), (req, res) => {
    const note = { id: nextId++, ...req.valid, createdAt: new Date().toISOString() };
    notes.set(note.id, note);
    res.status(201).location(`/api/notes/${note.id}`).json(note);
  });

  api.get('/notes/:id', (req, res) => {
    const note = notes.get(Number(req.params.id));
    if (!note) throw new HttpError(404, 'not_found', 'Note not found');
    res.json(note);
  });

  api.patch('/notes/:id', validate(NotePatch, 'body'), (req, res) => {
    const note = notes.get(Number(req.params.id));
    if (!note) throw new HttpError(404, 'not_found', 'Note not found');
    Object.assign(note, req.valid);
    res.json(note);
  });

  api.delete('/notes/:id', (req, res) => {
    if (!notes.delete(Number(req.params.id))) throw new HttpError(404, 'not_found', 'Note not found');
    res.status(204).end();
  });

  if (demoRoutes) {
    // Express 5: a rejected promise from an async handler reaches errorHandler. No try/catch, no next(err).
    api.get('/slow', async (req, res) => {
      await new Promise((r) => setTimeout(r, 3000));
      res.json({ done: true });
    });
    api.get('/boom', async () => {
      await Promise.resolve();
      throw new Error('db exploded: password=hunter2');
    });
  }

  app.use('/api', api);
  app.use(notFound);
  app.use(errorHandler(logger));
  return app;
}

zod version note. z.flattenError(error) is the zod 4 function. On zod 3 use error.flatten(). Zod 4.6.5 still has error.flatten() as a method, but its docs point you to z.flattenError().

src/server.js starts it and handles shutdown (explained below):

import { createApp } from './app.js';
import pino from 'pino';

const logger = pino({ level: process.env.LOG_LEVEL ?? 'info' });
const port = Number(process.env.PORT ?? 3000);
const server = createApp({ logger, demoRoutes: process.env.DEMO_ROUTES === '1' }).listen(port, () => logger.info({ port }, 'listening'));

let shuttingDown = false;
function shutdown(signal) {
  if (shuttingDown) return;
  shuttingDown = true;
  logger.info({ signal }, 'shutting down: no new connections, draining in-flight requests');
  // Since Node 19, close() also closes idle keep-alive connections.
  server.close((err) => {
    if (err) logger.error({ err }, 'error during close');
    // close your DB pool / queue clients here
    logger.info('drained, exiting');
    process.exit(err ? 1 : 0);
  });
  // Hard deadline: keep it below your orchestrator's kill timeout.
  setTimeout(() => {
    logger.warn('drain timeout, forcing connections closed');
    server.closeAllConnections();
  }, Number(process.env.SHUTDOWN_MS ?? 10_000)).unref();
}
process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));

Run it with npm i && API_TOKEN=s3cret npm start.

/slow and /boom are demo-only. They exist to demonstrate async errors and draining, and /boom deliberately throws a fake secret (password=hunter2). They are registered only when demoRoutes is true (DEMO_ROUTES=1 in server.js). Leave the flag off in production.

What changes in Express 5

The biggest change is async errors. The migration guide says handlers that return rejected promises "are now handled by forwarding the rejected value as an Error to the error handling middleware." That is why /boom and /slow have no try/catch and no next(err).

I ran the same async-throwing route on Express 4.22.3 (installed in a separate exp4/ folder with npm i express@4) and 5.2.1. exp/async.cjs takes the folder holding the express package as its argument; the unhandledRejection listener is there only so Express 4's silent failure becomes visible:

// usage: node exp/async.cjs <dir containing node_modules/express>
const express = require(require.resolve('express', { paths: [process.argv[2]] }));
process.on('unhandledRejection', (e) => console.log('unhandledRejection:', e.message));
const app = express();
app.get('/boom', async () => { await Promise.resolve(); throw new Error('db exploded'); });
app.use((err, req, res, next) => res.status(500).json({ error: 'handled' }));
const server = app.listen(0, async () => {
  console.log('express', require(require.resolve('express/package.json', { paths: [process.argv[2]] })).version);
  try {
    const r = await fetch(`http://127.0.0.1:${server.address().port}/boom`, { signal: AbortSignal.timeout(2000) });
    console.log('status', r.status);
  } catch { console.log('no response after 2s (request hangs)'); }
  process.exit(0);
});
$ node exp/async.cjs exp4; node exp/async.cjs .
express 4.22.3
unhandledRejection: db exploded
no response after 2s (request hangs)
express 5.2.1
status 500

On Express 4 the error never reaches the handler, and the client waits until it times out. Express 5 needs no express-async-errors patch or per-route wrapper.

Three smaller changes bite APIs. exp/wild.mjs reproduces two of them:

import express from 'express';
import request from 'supertest';
try { express().get('/*', (req, res) => res.end()); } catch (e) { console.log(e.message); }
const app = express();
app.get('/s/*splat', (req, res) => res.json(req.params));
app.get('/q', (req, res) => { try { req.query = {}; res.end('ok'); } catch (e) { res.status(500).end(e.message); } });
console.log((await request(app).get('/s/a/b')).text);
console.log((await request(app).get('/q')).text);
$ node exp/wild.mjs
Missing parameter name at index 2: /*; visit https://git.new/pathToRegexpError for info
{"splat":["a","b"]}
Cannot set property query of #<IncomingMessage> which has only a getter
  • req.body is undefined, not {}, when nothing parsed a body. The migration guide: "The req.body property returns undefined when the body has not been parsed. In Express 4, it returns {} by default." Pass the value to a validator instead of destructuring it.
  • Wildcards need names. The migration guide: "The wildcard * must have a name, matching the behavior of parameters :, use /*splat instead of /*." The first output line is what /* throws (the link is path-to-regexp's own error page). To also match the root path use /{*splat}.
  • req.query is a getter. The guide says it "is no longer a writable property and is instead a getter", and the last output line is what assigning to it does. So validate() stores parsed values on req.valid.

Also removed: app.del(), req.param() and the res.json(obj, status) signature. The migration guide's removed-methods list gives the replacement: res.status(status).json(obj).

Validate input with one schema per route

validate() uses zod, whose z.coerce.number() turns query strings into numbers for pagination. It throws an HttpError with status 422 and the per-field messages. With no body, safeParse receives undefined and returns a clean 422 instead of a 500. That top-level "expected object" issue lands in zod's formErrors, not fieldErrors, so details is {} in that case:

$ curl -s -X POST -H 'Authorization: Bearer s3cret' localhost:3200/api/notes
{"error":{"code":"validation_failed","message":"Validation failed","details":{}}}

The rule: 400 when the body can't be parsed, 422 when it parses but breaks the schema.

One bug the tests caught while I rebuilt this for the post: PATCH {} returned 200 instead of 422, because a .default('') on body survives .partial() and fills in body: '', which would have wiped the stored body. That is why NotePatch is built from its own fields with no default.

Express REST API error handling: one shape for every failure

errorHandler maps four cases to the same { error: { code, message } } envelope:

  • entity.parse.failed (malformed JSON from express.json()) becomes 400. Without the branch it would be a 500.
  • entity.too.large (body over the 100 kb limit) becomes 413. A test posts a 200 kb body and checks both the status and the too_large code.
  • HttpError uses its own status, code and details.
  • Anything else is logged in full and returned as a 500 with only a request id.
$ curl -s -X POST -H 'Authorization: Bearer s3cret' -H 'content-type: application/json' -d '{"title":' localhost:3200/api/notes
{"error":{"code":"bad_json","message":"Request body is not valid JSON"}}
$ curl -s -H 'Authorization: Bearer s3cret' localhost:3200/api/boom
{"error":{"code":"internal","message":"Something went wrong","requestId":4}}

/api/boom throws db exploded: password=hunter2. The server log held two entries for that request: a pino-http request errored line with "statusCode":500 and "authorization":"[Redacted]" (my Authorization: Bearer s3cret header, hidden by redact: ['req.headers.authorization']), and an unhandled error entry from errorHandler carrying the real message. The secret reached your logs (so scrub what you throw) but not the response. The JSON 404 is registered before the error handler so unknown routes don't get Express's HTML page.

Protect routes with a bearer token

This static-token check is enough to gate an internal API; for user accounts use a session or JWT library (see API security with JWT). The header must start with Bearer . An earlier version of mine stripped the prefix with a regex, which also accepted a raw token with no scheme, so the test sends Authorization: s3cret and expects 401. timingSafeEqual throws on different-length buffers, hence the length check first. The check is mounted on the /api router so /healthz stays open for your load balancer:

$ curl -si localhost:3200/api/notes   # status line and body only
HTTP/1.1 401 Unauthorized
{"error":{"code":"unauthorized","message":"Missing or invalid token"}}

The handler also sets WWW-Authenticate: Bearer on that response.

Paginate with a capped limit

ListQuery sets limit to at most 100. Without the cap, one request can pull the whole table. The response carries meta: { page, limit, total, pages }. The test creates five notes and asserts ?page=2&limit=2 returns n3, n4 with pages: 3, and that limit=1000 is a 422.

Page/limit suits small or slowly changing tables. On a large table, deep OFFSET pages get slow and inserts between requests shift rows, so switch to cursor pagination then. The demo uses a Map, so I have no benchmark for where that crossover is.

Order middleware: helmet, then logging, then the rate limiter

The order in createApp is deliberate: helmet, pino-http, rate limiter, body parser. exp/order.mjs builds apps with limit: 1 and compares both orders: helmet/limiter (headers on the second request, a 429) and logger/limiter (pino-http lines written for three requests). Run it from the project root with node exp/order.mjs:

import express from 'express';
import helmet from 'helmet';
import { rateLimit } from 'express-rate-limit';
import pino from 'pino';
import { pinoHttp } from 'pino-http';
import request from 'supertest';

// 1) helmet vs limiter
const build = (helmetFirst) => {
  const app = express();
  const lim = rateLimit({ windowMs: 60_000, limit: 1, standardHeaders: 'draft-7', legacyHeaders: false });
  if (helmetFirst) { app.use(helmet()); app.use(lim); } else { app.use(lim); app.use(helmet()); }
  app.get('/healthz', (req, res) => res.json({ ok: true }));
  return app;
};
for (const first of [true, false]) {
  const app = build(first);
  await request(app).get('/healthz');
  const r = await request(app).get('/healthz');
  console.log(first ? 'helmet first ' : 'limiter first', r.status,
    'x-content-type-options=' + r.headers['x-content-type-options'],
    'sts=' + r.headers['strict-transport-security']);
}

// 2) logger vs limiter: count pino-http lines
for (const loggerFirst of [true, false]) {
  const lines = [];
  const logger = pino({ level: 'info' }, { write: (s) => lines.push(JSON.parse(s)) });
  const app = express();
  const log = pinoHttp({ logger });
  const lim = rateLimit({ windowMs: 60_000, limit: 1, standardHeaders: 'draft-7', legacyHeaders: false });
  if (loggerFirst) { app.use(log); app.use(lim); } else { app.use(lim); app.use(log); }
  app.get('/healthz', (req, res) => res.json({ ok: true }));
  const statuses = [];
  for (let i = 0; i < 3; i++) statuses.push((await request(app).get('/healthz')).status);
  console.log(loggerFirst ? 'logger first ' : 'limiter first', 'statuses', statuses.join(','),
    'log lines', lines.length, 'logged statuses', lines.map((l) => l.res.statusCode).join(','));
}
helmet first  429 x-content-type-options=nosniff sts=max-age=31536000; includeSubDomains
limiter first 429 x-content-type-options=undefined sts=undefined
logger first  statuses 200,429,429 log lines 3 logged statuses 200,429,429
limiter first statuses 200,429,429 log lines 1 logged statuses 200

With the limiter first, every 429 leaves without X-Content-Type-Options and Strict-Transport-Security, because the limiter ends the response before helmet runs. Those are the responses an attacker triggers on purpose. The rule: anything that can end a response (limiter, auth, body parser) goes after helmet. The test 429 responses still carry helmet headers pins this order in the real app.

The same logic applies to logging: a limiter placed before pino-http means the two throttled requests vanish from your logs, which is exactly the traffic you want to see during an abuse spike. Body parsing goes after the limiter so rejected clients never cost you a parse.

Helmet. The Express security best practices page recommends it, and helmet's site says helmet() "will set 13 HTTP response headers" and lists them, with X-Powered-By marked as removed. For JSON the useful ones are nosniff and HSTS. Headers from this app (curl -si localhost:3200/healthz, filtered):

Strict-Transport-Security: max-age=31536000; includeSubDomains
X-Content-Type-Options: nosniff
RateLimit-Policy: 100;w=60
RateLimit: limit=100, remaining=97, reset=60

Rate limiting. The default store is per process. The express-rate-limit docs say: "When running multiple instances of the memory store, the ratelimiter will allow at least the configured max number of hits through, and at most the configured max multiplied by the number of instances." I ran one process, so I did not reproduce that. The docs' stores page lists rate-limit-redis (npm view rate-limit-redis version gave 6.0.1 on 2026-10-07; I did not run it) as the Redis-backed store. Behind a reverse proxy every client shares the proxy's IP unless Express's trust proxy setting is set (1 for one proxy hop); the library's configuration docs say it runs validation checks to detect proxy misconfigurations, including trustProxy. If you add Redis for this, Redis vs Memcached covers what else it is good for.

Logging. pino-http writes one JSON line per request. The default line also includes every response header, so helmet's long CSP string repeats in every line; add a res serializer if log volume matters.

Shut down gracefully on SIGTERM

The Express health check and graceful shutdown page says that on SIGTERM an app should stop accepting new requests, finish in-flight ones, clean up and exit. server.js does that. Per the Node.js http docs, since Node 19 server.close() also closes idle keep-alive connections, so you don't call closeIdleConnections() yourself. closeAllConnections() is the hard stop after the timeout, which is why SHUTDOWN_MS (default 10 s) must be lower than your orchestrator's kill timeout. Docker's default grace period is 10 s, so on Docker set SHUTDOWN_MS below that or raise the stop timeout.

I used Docker because the Node signal docs say SIGTERM "is not supported on Windows, it can be listened on", and that sending it there terminates the process unconditionally. exp/drain.sh starts the app in a node:22 container, begins a 3-second /api/slow request, sends SIGTERM 0.5 s later, then makes a new request. Run it from the project root (in Git Bash on Windows, prefix with MSYS_NO_PATHCONV=1):

#!/bin/sh
# run from the project root; needs docker and curl
docker rm -f api >/dev/null 2>&1
docker run -d --name api -p 3100:3000 -v "$(pwd -W 2>/dev/null || pwd)":/app -w /app \
  -e API_TOKEN=s3cret -e DEMO_ROUTES=1 node:22 node src/server.js >/dev/null
until curl -sf http://localhost:3100/healthz >/dev/null; do sleep 0.5; done
curl -s -o /dev/null -w 'slow: HTTP %{http_code} after %{time_total}s\n' \
  -H 'Authorization: Bearer s3cret' http://localhost:3100/api/slow &
sleep 0.5
docker kill --signal=SIGTERM api >/dev/null
sleep 0.3
curl -s -o /dev/null -w 'new request after SIGTERM: curl exit=%{exitcode}\n' http://localhost:3100/healthz
wait
sleep 1
docker logs api 2>&1 | grep -E 'shutting|drained' | sed -E 's/"pid":[0-9]+,"hostname":"[^"]*",//'
docker inspect -f 'container exit code: {{.State.ExitCode}}' api
docker rm -f api >/dev/null
new request after SIGTERM: curl exit=52
slow: HTTP 200 after 3.009713s
{"level":30,"time":1791350054608,"signal":"SIGTERM","msg":"shutting down: no new connections, draining in-flight requests"}
{"level":30,"time":1791350056898,"msg":"drained, exiting"}
container exit code: 0

The in-flight request finished with a 200 after 3.0 s, the new request failed, and the two log lines are 2.29 s apart with exit code 0. The new request's curl exit is 52 (empty reply) rather than 7 (connection refused): on my machine Docker Desktop's port proxy accepted the connection and dropped it. Either way the new request was not served.

Test with supertest and node --test

supertest takes the app object directly, so there is no port to manage. This is the complete test/api.test.js; npm test runs it with Node's built-in runner:

import { test } from 'node:test';
import assert from 'node:assert/strict';
import request from 'supertest';
import pino from 'pino';
import { createApp } from '../src/app.js';

const auth = { Authorization: 'Bearer s3cret' };
const make = (opts = {}) =>
  createApp({ token: 's3cret', logger: pino({ level: 'silent' }), demoRoutes: true, ...opts });

test('rejects missing and wrong tokens', async () => {
  const app = make();
  assert.equal((await request(app).get('/api/notes')).status, 401);
  assert.equal((await request(app).get('/api/notes').set('Authorization', 'Bearer nope')).status, 401);
  // a raw token with no "Bearer " scheme must not pass
  assert.equal((await request(app).get('/api/notes').set('Authorization', 's3cret')).status, 401);
});

test('health check needs no token and helmet headers are set', async () => {
  const res = await request(make()).get('/healthz');
  assert.equal(res.status, 200);
  assert.equal(res.headers['x-content-type-options'], 'nosniff');
  assert.equal(res.headers['x-powered-by'], undefined);
});

test('validation errors are 422 with field details', async () => {
  const res = await request(make()).post('/api/notes').set(auth).send({ title: '' });
  assert.equal(res.status, 422);
  assert.equal(res.body.error.code, 'validation_failed');
  assert.ok(res.body.error.details.title);
});

test('malformed JSON is 400, not 500', async () => {
  const res = await request(make()).post('/api/notes').set(auth)
    .set('Content-Type', 'application/json').send('{"title":');
  assert.equal(res.status, 400);
  assert.equal(res.body.error.code, 'bad_json');
});

test('POST without a body does not crash (req.body is undefined in Express 5)', async () => {
  const res = await request(make()).post('/api/notes').set(auth);
  assert.equal(res.status, 422);
});

test('create, read, patch, delete', async () => {
  const app = make();
  const created = await request(app).post('/api/notes').set(auth).send({ title: 'a', body: 'b' });
  assert.equal(created.status, 201);
  assert.equal(created.headers.location, `/api/notes/${created.body.id}`);
  const url = `/api/notes/${created.body.id}`;
  assert.equal((await request(app).get(url).set(auth)).body.title, 'a');
  assert.equal((await request(app).patch(url).set(auth).send({ title: 'z' })).body.title, 'z');
  assert.equal((await request(app).patch(url).set(auth).send({})).status, 422);
  assert.equal((await request(app).delete(url).set(auth)).status, 204);
  assert.equal((await request(app).get(url).set(auth)).status, 404);
});

test('pagination', async () => {
  const app = make();
  for (let i = 1; i <= 5; i++) await request(app).post('/api/notes').set(auth).send({ title: `n${i}` });
  const res = await request(app).get('/api/notes?page=2&limit=2').set(auth);
  assert.deepEqual(res.body.items.map((n) => n.title), ['n3', 'n4']);
  assert.equal(res.body.meta.pages, 3);
  assert.equal((await request(app).get('/api/notes?limit=1000').set(auth)).status, 422);
});

test('async throw reaches the error handler and does not leak the message', async () => {
  const res = await request(make()).get('/api/boom').set(auth);
  assert.equal(res.status, 500);
  assert.equal(res.body.error.code, 'internal');
  assert.ok(!res.text.includes('hunter2'));
});

test('unknown route is a JSON 404', async () => {
  const res = await request(make()).get('/nope');
  assert.equal(res.status, 404);
  assert.equal(res.body.error.code, 'not_found');
});

test('rate limit returns 429 with RateLimit headers', async () => {
  const app = make({ rateLimitMax: 3 });
  const codes = [];
  for (let i = 0; i < 5; i++) codes.push((await request(app).get('/healthz')).status);
  assert.deepEqual(codes, [200, 200, 200, 429, 429]);
  assert.ok((await request(app).get('/healthz')).headers['ratelimit-policy']);
});

test('oversized body is 413 with our error shape', async () => {
  const res = await request(make()).post('/api/notes').set(auth).send({ title: 'x', body: 'y'.repeat(200_000) });
  assert.equal(res.status, 413);
  assert.equal(res.body.error.code, 'too_large');
});

test('429 responses still carry helmet headers', async () => {
  const app = make({ rateLimitMax: 1 });
  await request(app).get('/healthz');
  const res = await request(app).get('/healthz');
  assert.equal(res.status, 429);
  assert.equal(res.headers['x-content-type-options'], 'nosniff');
});
$ npm test
✔ rejects missing and wrong tokens (198.7937ms)
✔ health check needs no token and helmet headers are set (17.462ms)
✔ validation errors are 422 with field details (47.9435ms)
✔ malformed JSON is 400, not 500 (18.9096ms)
✔ POST without a body does not crash (req.body is undefined in Express 5) (18.6906ms)
✔ create, read, patch, delete (75.6189ms)
✔ pagination (82.6029ms)
✔ async throw reaches the error handler and does not leak the message (16.9933ms)
✔ unknown route is a JSON 404 (13.5897ms)
✔ rate limit returns 429 with RateLimit headers (54.7556ms)
✔ oversized body is 413 with our error shape (20.4915ms)
✔ 429 responses still carry helmet headers (17.2033ms)
ℹ tests 12
ℹ pass 12
ℹ fail 0

Next steps

  1. Add the 409 unique-violation branch once the Map is a database. With pg, a unique-index violation has err.code === '23505'. I ran this with a stand-in error carrying that code, not against Postgres:
import express from 'express';
import request from 'supertest';
import { errorHandler } from '../src/errors.js';
const app = express();
// stand-in for a pg driver error on a unique index
app.post('/dup', () => { throw Object.assign(new Error('duplicate key value violates unique constraint "notes_title_key"'), { code: '23505' }); });
app.use((err, req, res, next) => {
  if (err.code === '23505') {
    return res.status(409).json({ error: { code: 'conflict', message: 'Resource already exists' } });
  }
  next(err);
});
app.use(errorHandler({ error() {} }));
const r = await request(app).post('/dup');
console.log(r.status, r.text);
409 {"error":{"code":"conflict","message":"Resource already exists"}}
  1. Before you run a second instance, move the rate limiter to a shared store such as rate-limit-redis, and behind one reverse proxy call app.set('trust proxy', 1) so clients are told apart by IP, not by the proxy's address.
  2. Run npm audit in CI, as the Express security guide recommends.

Coming from PHP? The Laravel REST API series covers the same ground.

Bekzod Erkinov

Bekzod Erkinov

Author

Founder of NextGenBeing. Software engineer working with Laravel, Python, and cloud infrastructure. Writes about patterns that actually hold up in production. Based in Tashkent, Uzbekistan.

Free guide

Get the AI-Assisted Developer's Field Guide

The workflow, prompts, and tools I use to ship faster with AI — free when you subscribe. Plus new deep-dives in your inbox. No spam, unsubscribe anytime.

Comments (0)

Please log in to leave a comment.

Log In

Related Articles

Don't miss the next deep dive

Get one well-researched tutorial in your inbox each week. No spam, unsubscribe anytime.