express-5

Writes, upgrades and hardens Express 5 (express@5.x, current 5.3) HTTP APIs in Node.js. Covers the path-to-regexp v8 route syntax (named wildcards like /*splat, {/:optional} groups, no ? + ( ) in path

SKILL.md

GitHub

Express 5

Facts below are for express@5.3.0 (Oct 2026), which ships router@2 (path-to-regexp 8), body-parser@2 and requires Node.js 18+. Check what the project actually runs before you write code:

npm ls express router path-to-regexp body-parser

If it prints express@4.x, the Express 4 rules you already know apply. Everything here is about 5.x.

The two classes of breakage

Express 5 breaks code in two ways. The first is loud and easy. The second is why upgrades ship bugs.

Loud: throws at startup or on the first request. You will see it in any test run.

Express 4 code Error on Express 5 Write instead
app.get('*', h), app.all('*', h), app.use('*', h) Missing parameter name at index 1: * catch-all: app.use(h) (no path); wildcard: '/*splat'
'/assets/*' Missing parameter name '/assets/*assetPath' (value is an array)
'/:teamId?', '/:invoiceId.:ext?' Unexpected ? at index ... '{/:teamId}', '/:invoiceId{.:ext}'
'/teams/:teamId(\\d+)', '/a+', '/[a|b]' Unexpected ( / + / [ validate in the handler, a RegExp route, or an array of paths
app.del(...) app.del is not a function app.delete(...)
req.param('teamId') req.param is not a function req.params.teamId / req.body?.teamId / req.query.teamId, explicitly
res.sendfile(...) res.sendfile is not a function res.sendFile(relativePath, { root })
req.acceptsCharset/Encoding/Language not a function the plural forms req.acceptsCharsets() etc.
router.param(['a', 'b'], fn) argument name must be a string one router.param(name, fn) per name
res.status(undefined), res.status('404'), res.status(99) TypeError/RangeError: Invalid status code pass an integer 100-999
res.redirect('/login', 301) Invalid status code: "/login" on that request (order is now status first) res.redirect(301, '/login')

Silent: no error, wrong response. Tests that only check bodies, or only the happy path, pass.

Express 4 code What Express 5 does Write instead
res.send(404) replies 200 with JSON body 404 res.sendStatus(404) (same status and body as Express 4)
res.send(410) and other bare numbers replies 200 with that number as the body res.sendStatus(410)
res.json(obj, 202) / res.send(obj, 202) / res.jsonp(obj, 202) ignores the status, replies 200 res.status(202).json(obj)
res.redirect('back'), res.location('back') sends Location: back (a relative URL) res.redirect(req.get('Referrer') || fallbackUrl)
req.query.perPage = 25 then read req.query.perPage req.query is a getter that re-parses the URL on every access; the write is lost keep derived values in local variables
req.query = {...} throws in strict mode (only a getter) same: local variables, or res.locals
?where[state]=open read as req.query.where.state default query parser is "simple": you get { 'where[state]': 'open' } app.set('query parser', 'extended') if clients send bracket syntax
express.urlencoded() with forms posting contact[phone]=... extended defaults to false: flat keys express.urlencoded({ extended: true }) when nested form fields exist
const { handle } = req.body in a route that may get no body req.body is undefined (was {}): TypeError req.body ?? {} or validate req.body first
'/*' renamed to '/*splat' but the handler still reads req.params[0] req.params[0] is undefined; the value is req.params.splat, an array of decoded segments req.params.splat.join('/')
optional param unmatched key is absent (was undefined) req.params.ext ?? defaultExt
req.params.hasOwnProperty('teamId') req.params has a null prototype (string paths): throws Object.hasOwn(req.params, 'teamId') or 'teamId' in req.params
req.host keeps the port (example.com:8080) req.hostname for the name only
express.static('public') serving /.well-known/... dotfiles and dot-directories are ignored (404) by default mount .well-known with { dotfiles: 'allow' }
res.clearCookie(name, { maxAge }) maxAge/expires are ignored nothing to do unless you relied on it

When you migrate, go through the silent table line by line over the whole codebase, not only the code the failing tests touch. references/migration-checklist.md has a grep for every row and the before/after for each.

Route paths (path-to-regexp 8)

Need Path req.params for an example request
one segment /teams/:teamId /teams/t-9 → { teamId: 't-9' }
literal suffix /invoices/:invoiceId.pdf /invoices/INV-77.pdf → { invoiceId: 'INV-77' }
optional suffix /invoices/:invoiceId{.:ext} /invoices/INV-77 → { invoiceId: 'INV-77' }; .html → { invoiceId, ext: 'html' }
optional segment /teams/:teamId/avatar{/:variant} /teams/t-9/avatar → { teamId: 't-9' }
one or more segments /assets/*assetPath /assets/img/hero%20v2.png → { assetPath: ['img', 'hero v2.png'] }
zero or more segments /assets{/*assetPath} /assets → {}
everything incl. / /{*splat} / → {}
catch-all 404 app.use(handler) no path at all
name that is not an identifier /:"asset-id" { 'asset-id': '...' }
a literal reserved char ()[]?+! escape it: '/v\\(1\\)' matches /v(1)
a digits-only param /teams/:teamId, then reject non-digits in the handler (or a RegExp route)

Rules that save time:

  • /*splat does not match the bare prefix (/assets/*assetPath does not match /assets or /assets/). Use {/*assetPath} when the bare prefix should match too.
  • Wildcard values are already URL-decoded per segment. Join with /; never string-concatenate the array (${req.params.assetPath} gives img,hero v2.png).
  • Register the specific route before the general one when both could match (/:invoiceId.pdf before /:invoiceId), or use one route with {.:ext} and branch.
  • RegExp routes still work (app.get(/^\/legacy-assets\/(.+)$/, ...), numbered req.params[0]), so they are a valid escape hatch when the string syntax cannot express a pattern.
  • In TypeScript (@types/express@5), route strings infer param types: wildcards are string[] and {...} params are optional. Let the inference catch req.params.assetPath.split mistakes.

More cases, including app.param, mergeParams and the error messages, are in references/routing.md.

Async handlers and errors

Express 5 forwards a returned rejected promise to the error middleware, exactly as next(err). async handlers therefore need no wrapper and no express-async-errors/express-async-handler.

app.get('/teams/:teamId/members', async (req, res) => {
  const team = await teams.find(req.params.teamId) // a rejection goes to the error handler
  if (!team) throw new ApiProblem(404, 'No team with that id')
  res.json(await members.listFor(team))
})

Express only sees promises the handler returns. These still need next(err) or await:

// BAD: the promise is not returned. A rejection is unhandled and, on Node's default
// --unhandled-rejections=throw, kills the process. The request also never gets a response.
app.post('/teams/:teamId/archive', (req, res) => {
  teams.archive(req.params.teamId).then((team) => res.json(team))
})

// GOOD (either)
app.post('/teams/:teamId/archive', async (req, res) => {
  res.json(await teams.archive(req.params.teamId))
})
app.post('/teams/:teamId/archive', (req, res, next) => {
  teams.archive(req.params.teamId).then((team) => res.json(team), next)
})

Callbacks (fs.readFile(p, cb)), timers, event emitters and streams are not promises: pass errors to next yourself, inside the callback.

An error handler that is safe in production

The built-in final handler replies with HTML and the full stack trace unless NODE_ENV=production. Never rely on the environment variable: install your own last middleware. This one answers with RFC 9457 problem details; keep whatever envelope the API already documents.

import { STATUS_CODES } from 'node:http'

const problem = (res, status, detail) =>
  res
    .status(status)
    .type('application/problem+json')
    .json({ type: 'about:blank', title: STATUS_CODES[status], status, ...(detail && { detail }) })

// Last two app.use calls, after every route.
app.use((req, res) => problem(res, 404, 'No such route'))

app.use((err, req, res, next) => {
  if (res.headersSent) return next(err) // let Express abort a response that already started
  const status = err?.status ?? err?.statusCode
  if (Number.isInteger(status) && status >= 400 && status < 500) {
    return problem(res, status, err.expose === false ? undefined : err.message)
  }
  console.error(err) // log the details server-side, never send them
  problem(res, 500)
})

Decision rules for the handler:

  • Keep 4xx statuses from upstream middleware. body-parser errors carry status and type (400 + entity.parse.failed for malformed JSON, 413 + entity.too.large, 415 for an unsupported charset or encoding). A handler that maps everything to 500 turns client mistakes into alerts.
  • Only expose messages that are meant for clients: your own error classes, or http-errors style errors with expose: true (set for 4xx). For 5xx always send a generic message.
  • Never call res.status(err.status) without checking it is an integer 100-999: on Express 5 that throws inside your error handler and Express falls back to the HTML stack-trace page.
  • Map domain errors explicitly (MissingRecordError → 404, VersionConflictError → 409) instead of matching message strings.
  • The handler has exactly four parameters (err, req, res, next); Express detects error middleware by arity, so keep next even when it looks unused.

references/errors-and-hardening.md has an ApiProblem class, validation without extra dependencies, and the full body-parser error table.

Request parsing defaults (body-parser 2)

Parser Default you get Set explicitly when
express.json() limit: '100kb', strict: true (only objects and arrays), type application/json the endpoint needs a different size cap; set it per route if one route takes uploads
express.urlencoded() extended: false, parameterLimit: 1000; with extended: true nesting depth is 32 the form posts nested a[b] fields
any parser req.body stays undefined when there is no body or the type does not match always: handlers must not assume an object

Since 5.3.0 an invalid limit value throws when the parser is created instead of silently disabling the size check. Use a number of bytes or a bytes string like '256kb'.

Production defaults checklist

Apply these to any Express app you create or touch, unless the project already does it differently:

  • app.disable('x-powered-by') (on by default), or helmet() if the project already uses it.
  • A 404 handler (app.use with no path) and an error handler, as above, registered last, both answering in the API's format instead of the HTML defaults.
  • Body parsers with an explicit limit sized for the API; a larger limit only on the routes that need it (mount that parser on the route, not globally).
  • Validate req.params, req.query and req.body at the edge (types, ranges, lengths) and reply 400 with a stable error shape. Treat everything in them as untrusted strings or arrays: even the simple parser turns ?tag=a&tag=b into ['a', 'b'], and the extended one builds nested objects.
  • trust proxy set to the real topology (1 hop, or the proxy subnet), never true unless the last proxy overwrites X-Forwarded-For, -Host and -Proto. Otherwise req.ip is spoofable, which defeats rate limits and audit logs.
  • res.sendFile(relativePath, { root: ASSETS_ROOT }) for user-influenced paths (it rejects .. with 403), never path.join(ASSETS_ROOT, userInput) followed by an absolute sendFile or fs.readFile.
  • Redirect targets from user input are checked against an allow-list (open redirects).
  • app.listen(port, (err) => { if (err) throw err }): listen errors like EADDRINUSE are passed to the callback in Express 5 instead of being thrown.

Migration procedure (4 → 5)

  1. Confirm the installed version (npm ls express) and the Node version (18+).
  2. Start the app or run the tests once and fix every loud error from the first table. The process cannot start until all route strings parse.
  3. Grep the whole codebase for every row of the silent table (references/migration-checklist.md), including routes no test covers. Keep behaviour identical unless the user asks for a change: a bare res.send(<status>) becomes res.sendStatus(<status>) (same status and status-text body), not a new JSON shape.
  4. Decide parser settings from the clients, not from the code you see: if any client sends where[x]= query strings or nested form fields, set query parser to 'extended' and/or urlencoded({ extended: true }).
  5. Remove wrappers made obsolete by native promise support only if they are simple pass-throughs.
  6. Write or extend tests for status codes, redirects (redirect: 'manual' in fetch), empty bodies, nested query/form input and the 404/error handlers, then run the whole suite.

The official codemods (expressjs/codemod) target mechanical renames: redirect-back, sendfile casing, req.param, pluralized accepts*, redirect argument order, app.del, sendFile/static options and status/send argument order. Going by the transform list (Nov 2026), none targets route path syntax, the query parser, urlencoded extended, req.body or req.query writes, so step 3 is still yours.

Testing Express apps without extra dependencies

import assert from 'node:assert/strict'
import { after, before, test } from 'node:test'
import { buildServer } from '../lib/build-server.mjs'

let server, origin
before(async () => {
  server = buildServer().listen(0)
  await new Promise((resolve) => server.once('listening', resolve))
  const { port } = server.address()
  origin = `http://127.0.0.1:${port}`
})
after(() => new Promise((resolve) => server.close(resolve)))

test('a missing team is a problem+json 404', async () => {
  const res = await fetch(`${origin}/teams/t-missing/members`, { redirect: 'manual', signal: AbortSignal.timeout(5000) })
  assert.equal(res.status, 404)
  assert.match(res.headers.get('content-type'), /^application\/problem\+json/)
})

Export a factory that builds the Express app separately from the listen call so tests and production share the same wiring. Assert status codes on every test: the silent breakages only show up there.

Anti-patterns

Anti-pattern Fix
Adding express-async-errors to an Express 5 app delete it; return or await the promise
app.all('*', fallback) app.use(fallback) after all routes
res.status(err.status).json(...) validate the status, default 500
Sending err.message or err.stack for 5xx generic message to the client, console.error(err) (or the app logger) server-side
Checking process.env.NODE_ENV === 'production' to hide stacks a custom error handler that never sends them
req.query.perPage = Number(req.query.perPage) || 25 const perPage = Number(req.query.perPage) || 25
const { handle } = req.body const { handle } = req.body ?? {}, then validate
res.sendFile(path.join(ASSETS_ROOT, req.params.assetPath.join('/'))) res.sendFile(req.params.assetPath.join('/'), { root: ASSETS_ROOT })
Downgrading to Express 4 to make an upgrade pass finish the migration; a downgrade undoes the work that was asked for
An error handler with three parameters (err, req, res) four parameters: with three, Express runs it as ordinary middleware on every request that reaches it