Migrating to Express 5: changes and edge cases

Express · Expert · 18 min

Many codebases and tutorials are still on Express 4; upgrades fail on details that aren't obvious until production.

Express 5 is mostly the same API with sharp edges removed. Upgrade with npm install express@5, run the official codemods (npx codemod@latest @expressjs/v5-migration-recipe), then check these areas.

Path syntax (path-to-regexp v8) — the most common breakage, failing at startup:

  • /* becomes /*splat (or /{*splat} to include the root); wildcard values are arrays of segments.
  • Optional /:id? becomes /{:id}; /:file.:ext? becomes /:file{.:ext}.
  • Inline regex like /:id(\d+) is gone; validate in the handler or pass a RegExp.
  • ()[]?+! are reserved and must be escaped.

Async errors are forwarded automatically, so express-async-errors and wrapper helpers can go.

Removed or changed APIs:

  • app.del → app.delete; req.param(name) removed; res.sendfile → res.sendFile.
  • res.json(obj, status), res.send(status), res.redirect(url, status) → res.status(...) / res.redirect(status, url); res.redirect("back") removed.
  • req.acceptsCharset and friends pluralised (acceptsCharsets).
  • app.param(fn) and router.param with an array of names removed.

Behaviour changes that pass tests but bite later:

  • req.body is undefined (not {}) when no parser ran.
  • req.query is a read-only getter and the default parser is "simple": nested a[b]=1 no longer expands.
  • express.urlencoded defaults to extended: false.
  • express.static ignores dotfiles, including /.well-known.
  • res.status throws on invalid codes; res.clearCookie ignores maxAge/expires.
  • req.host keeps the port; req.params has a null prototype.
  • app.listen passes server errors to its callback instead of throwing.

Express 5 requires Node 18+. Upgrade middleware too — some older packages assume Express 4 internals.

Example

// Express 4                                  // Express 5
// app.get("/*", h)                           app.get("/{*splat}", h)
// app.get("/user/:id?", h)                   app.get("/user{/:id}", h)
// app.get("/file/:name.:ext?", h)            app.get("/file/:name{.:ext}", h)
// app.get("/post/:id(\\d+)", h)              app.get(/^\/post\/(?<id>\d+)$/, h)
// app.del("/x", h)                           app.delete("/x", h)
// res.json(data, 201)                        res.status(201).json(data)
// res.redirect("/login", 301)                res.redirect(301, "/login")
// res.redirect("back")                       res.redirect(req.get("Referrer") || "/")
// req.param("id")                            req.params.id ?? req.body?.id ?? req.query.id

import express from "express";
const app = express();

app.get("/docs{/*path}", (req, res) => {
  res.json({ segments: req.params.path ?? [] });   // /docs/a/b -> ["a","b"]; /docs -> []
});

app.post("/echo", (req, res) => res.json({ body: req.body ?? null }));  // undefined without a parser

app.get("/user{/:id}", async (req, res) => {
  if (!req.params.id) return res.json({ list: true });
  throw new Error("lookup failed");                // forwarded to error handler automatically
});

app.listen(3000, (err) => { if (err) throw err; });

Remember

  • Rewrite wildcards, optional params and inline regexes for path-to-regexp v8
  • Async rejections reach error handlers; drop wrapper libraries
  • Use res.status(...) and res.redirect(status, url); "back" and req.param are gone
  • Watch silent changes: req.body undefined, read-only req.query, dotfiles ignored

Watch out for

  • Upgrading Express without updating route strings, so the app throws at startup on /* or :id?.
  • Code that mutates req.query (e.g. for defaults) silently stops working.

Check yourself

  1. After upgrading, GET /.well-known/assetlinks.json returns 404. Why?
  2. Which Express 4 route path is still valid unchanged in Express 5?

Sign in to answer, see the explanations, and get this lesson back as a quick review later.

Official docs: expressjs.com/en/guide/migrating-5