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 aRegExp. ()[]?+!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.acceptsCharsetand friends pluralised (acceptsCharsets).app.param(fn)androuter.paramwith an array of names removed.
Behaviour changes that pass tests but bite later:
req.bodyisundefined(not{}) when no parser ran.req.queryis a read-only getter and the default parser is "simple": nesteda[b]=1no longer expands.express.urlencodeddefaults toextended: false.express.staticignores dotfiles, including/.well-known.res.statusthrows on invalid codes;res.clearCookieignoresmaxAge/expires.req.hostkeeps the port;req.paramshas a null prototype.app.listenpasses 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(...)andres.redirect(status, url);"back"andreq.paramare gone - Watch silent changes:
req.bodyundefined, read-onlyreq.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.