Coding

Express 5 migration: what breaks in your API and how to fix it

The route syntax errors, silent req.query and req.body changes, and async error handling, with a checklist.

Express 5 migration: what actually breaks. Cover graphic for a guide to upgrading an Express 4 API to Express 5 and fixing what breaks.

You bump express to ^5, run the app, and it crashes before serving a request with Missing parameter name at index 2: /*. Or worse, it starts fine and a pagination middleware silently stops working. This Express 5 migration guide covers the changes that actually break production APIs, how to find each one in your codebase, and the fix.

The direct answer: Express 5 needs Node 18 or newer, changes route path syntax (named wildcards, no ? or regex in strings), makes req.query read-only, leaves req.body undefined when nothing parsed it, validates res.status(), removes old method signatures, and forwards rejected promises to your error handler. Most APIs migrate in an afternoon; the route syntax is where the time goes.

Why migrate to Express 5 now?

Express 5.0 shipped in September 2024 after a decade on 4.x, 5.1 followed in March 2025 and became the latest tag on npm, and the current release is 5.2.1. Express 4 moved to maintenance (security and high-priority fixes only) on April 1, 2025, and the Express team’s proposed schedule puts its end of life no sooner than October 1, 2026. Not an emergency, but the clock is running. The reasons to move are practical: async error handling without wrappers, a stricter path matcher that rejects ambiguous patterns at startup, and new middleware and tutorials targeting 5.

Check your runtime first. Express 5 requires Node 18+, and Node 20 reaches end of life on April 30, 2026. If you are moving anyway, Node 24 LTS is the sensible target.

Step-by-step Express 5 migration plan

1. Upgrade and run the codemods

bash
npm install express@5
npm install --save-dev @types/express@5   # TypeScript only
npx @expressjs/codemod upgrade

The official codemod rewrites the mechanical changes: res.redirect('back'), pluralized req.accepts* methods, the old res.json(obj, status) style signatures and req.param(). It does not fix route paths.

2. Find the route patterns that will throw

Search for patterns path-to-regexp v8 rejects:

bash
grep -rnE "\.(get|post|put|patch|delete|all|use)\(\s*['\"][^'\"]*(\*|\?|\(|\))" src/

Express 5 throws at registration time, so the app will not even start until they are fixed. That is the good news: nothing slips through to production. Here is what failed when registering common Express 4 patterns on 5.2:

text
THROW /*               -> Missing parameter name at index 2: /*
THROW /users/:id?      -> Unexpected ? at index 10: /users/:id?
THROW /files/:path(*)  -> Unexpected ( at index 12: /files/:path(*)
THROW /api/v(1|2)/x    -> Unexpected ( at index 6: /api/v(1|2)/x

3. Rewrite them

Express 4 Express 5
app.get('/*', ...) app.get('/*splat', ...), or '/{*splat}' to also match /
'/users/:id?' '/users{/:id}'
'/files/:path(*)' '/files/*path'
'/api/v(1|2)/x' ['/api/v1/x', '/api/v2/x']

Wildcard params are now arrays: /*splat on /a/b/c gives req.params.splat as ['a', 'b', 'c']. Code that did req.params[0] needs req.params.splat.join('/'). Optional parameters that are missing are omitted from req.params, not set to undefined.

What breaks at runtime after the Express 5 upgrade

These do not crash at startup, which makes them the dangerous ones.

req.query is now a getter

js
// Express 4 pattern that silently stops working
app.use((req, res, next) => {
  req.query.limit ??= '50'; // lost: req.query is re-parsed on every access
  next();
});

In ESM (strict mode), req.query = {...} throws Cannot set property query of #<IncomingMessage> which has only a getter. Mutating a property does not throw; the change just disappears. Put normalized values somewhere you own:

js
app.use((req, res, next) => {
  res.locals.page = {
    limit: Math.min(Number(req.query.limit ?? 50), 200),
    cursor: req.query.cursor ?? null,
  };
  next();
});

The default query parser is “simple”

?filter[status]=open used to give { filter: { status: 'open' } }. In Express 5 it gives { 'filter[status]': 'open' }. If your API uses bracket syntax, opt back in with app.set('query parser', 'extended'). Same story for forms: express.urlencoded() now defaults to extended: false.

req.body is undefined, not {}

When no body parser ran (no body, wrong Content-Type), req.body is undefined. Code like const { email } = req.body now throws a TypeError, which becomes a 500. Validate the body, or default it: const { email } = req.body ?? {}.

res.status() validates its argument

res.status('201') throws Invalid status code: "201". Status code must be an integer. Values must be integers from 100 to 999. Watch for status codes read from config, upstream responses or error objects as strings.

Rejected promises go to your error handler

js
app.get('/v1/orders/:id', async (req, res) => {
  const order = await db.orders.find(req.params.id); // a throw here...
  res.json(order);
});

app.use((err, req, res, next) => {                  // ...lands here
  req.log?.error({ err }, 'unhandled route error');
  res.status(err.status ?? 500).json({ error: 'internal_error' });
});

You can delete express-async-errors and hand-written asyncHandler wrappers. Check that your error handler sets a sensible status, because errors that used to become unhandled rejections now produce responses.

  • app.del() is gone; use app.delete().
  • res.sendfile() is now res.sendFile().
  • express.static ignores dotfiles by default; serve /.well-known explicitly with dotfiles: 'allow'.
  • req.host keeps the port.
  • app.listen passes errors such as EADDRINUSE to its callback instead of throwing.

Express 5 migration checklist

Forward this to whoever owns the upgrade:

  • [ ] Node 18+ everywhere (ideally Node 22 or 24).
  • [ ] npx @expressjs/codemod upgrade run and reviewed.
  • [ ] App starts: every route pattern registers.
  • [ ] No writes to req.query; bracket queries still parse.
  • [ ] No unguarded destructuring of req.body.
  • [ ] Every res.status() receives an integer.
  • [ ] Error handler returns a sane status for async errors.
  • [ ] Deployed to staging with real traffic before production.

A solo developer can do this in one sitting. A team of ten should split it: one person on route patterns, one on query and body handling, and a staging soak with production-like traffic before the cutover, ideally after you have graceful shutdown in place so the deploy itself does not drop requests.

FAQ

Is Express 5 backwards compatible with Express 4?

Mostly, but not fully. Most middleware works unchanged, while route path syntax, req.query, req.body defaults and several removed methods need changes. Expect your app to fail at startup until route patterns are updated.

How do I fix “Missing parameter name” in Express 5?

Wildcards must be named. Replace /* with /*splat (or /{*splat} to also match the root) and read the matched segments from req.params.splat, which is an array.

Do I still need express-async-errors in Express 5?

No. Express 5 forwards rejected promises and errors thrown in async handlers to your error middleware, as if you had called next(err).

Doing this with ApiTraffic

The scary part of the Express 5 migration is the runtime changes that do not crash. If you would rather not eyeball logs to find them, ApiTraffic captures every request and response from one middleware placed after your body parsers, and records them under the matched route pattern, so a route that stopped matching shows up as traffic moving to 404.

js
app.use(express.json());
app.use(apitraffic.express());

After the deploy, search for status_code >= 500 per endpoint, or record a deploy event and use Regressions to compare each endpoint’s p95 before and after the upgrade.

Start free and watch your Express 5 upgrade request by request: apitraffic.io

Sources: Express: Moving to Express 5; Express 5.1.0 release announcement (March 2025); @expressjs/codemod; path-to-regexp README; Node.js release schedule.

Keep reading