Migrating & What’s Changed
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. The reference material consulted while preparing these pages is listed in the section bibliography. |
This page is written against Express 5.3.0 (published 2026-10-09) and Express 4.22.3 (2026-09-14, the newest 4.x), running on Node.js 24 LTS, and the facts were checked on 2026-10-29. Express 5 requires Node.js 18 or newer; Express 4 still runs on very old Node.js releases, which is one reason many projects have not moved. npm install express has installed Express 5 since 5.1.0 on 2025-03-31, so a project that was created before that date and is installed again without a lock file can already be on the new line without anyone deciding to migrate.
The page has five jobs. It lists every Express 5 breaking change from the official migration guide, each with a before and after example and the codemod that rewrites it, when one exists. It describes how to run the codemods and gives a checklist for the upgrade, in the order that keeps the application working at every step. It reminds you of the Express 3 to 4 changes that still show up in old code. It lists the deprecated middleware. And it collects, in one place, every statement from the four reference books of this section that no longer holds, with the version that changed it and a link to the page that treats the topic in depth.
The running example is the Bookshelf API used throughout the section: a small catalogue of books and reviews. The sibling pages are Versions, Support & LTS (which Express line to run and for how long), Route Paths & Parameters (the new path syntax in depth) and Security Best Practices.
How big is the migration
Express 5 is mostly a cleanup of the 4.x API, not a new framework. The changes fall in four groups:
-
Methods and properties that were deprecated for years and are now gone. Calling them throws a
TypeErroror silently does something different. -
A new routing engine. The router moved to
router2 andpath-to-regexp8, which changed the spelling of wildcards, optional segments and regular expressions in route strings, and the shape ofreq.params. -
Changed defaults and changed return values:
req.body,req.query,req.host,express.urlencoded,express.static,res.clearCookie,res.status,res.varyandapp.listen. -
Improvements that need no migration: rejected promises reach the error handlers,
res.render()is always asynchronous, and request bodies compressed with Brotli are decompressed.
The two changes that cause most of the real-world breakage are the path syntax (any catch-all such as app.get('*', ...) stops the application at start-up) and req.body being undefined when no parser ran. Both fail loudly and early in a test run, which is why the checklist later in the page puts a test run before every change.
The Express 5 breaking changes
The sections below follow the order of the official migration guide: removed methods and properties first, then changed behaviour, then the improvements. Each example is a before and after pair. Where a codemod covers the change, its name is given; the codemods are collected in Codemods.
Removed methods and properties
These calls fail on Express 5. A test that exercises the route finds them immediately.
app.del() is app.delete()
app.del() was a deprecated alias that existed because delete was a reserved word in very old JavaScript. Use app.delete(). Codemod: route-del-to-delete.
// Express 4
app.del('/books/:id', removeBook);
// Express 5
app.delete('/books/:id', removeBook);
Source: Migrating to Express 5, "app.del()".
app.param(fn) and router.param(fn)
The form of app.param() and router.param() that took only a function, to customise how parameter callbacks are registered, was deprecated since 4.11.0 and is removed. The form with a name and a function still works. Separately, a leading colon in the name (app.param(':id', fn)) is silently ignored in Express 5, so pass the plain name.
// Express 4: global customisation, removed
app.param((name, fn) => fn);
// Express 4 and 5: name and function
app.param('bookId', async (req, res, next, id) => {
req.book = await books.find(id);
next();
});
The singular req.accepts methods
req.acceptsCharset(), req.acceptsEncoding() and req.acceptsLanguage() are removed in favour of the plural names, which have the same meaning. Codemod: pluralize-method-names.
// Express 4
req.acceptsCharset('utf-8');
req.acceptsEncoding('br');
req.acceptsLanguage('en');
// Express 5
req.acceptsCharsets('utf-8');
req.acceptsEncodings('br');
req.acceptsLanguages('en');
req.param()
req.param(name) searched the route parameters, then the body, then the query string, which made it an injection-friendly lookup. It is removed: read the source you mean. Codemod: explicit-request-params.
// Express 4
const id = req.param('id');
const body = req.param('body');
const query = req.param('query');
// Express 5
const id = req.params.id;
const body = req.body;
const query = req.query;
Status-first call signatures
res.json(obj, status), res.jsonp(obj, status) and res.send(body, status) are removed; chain res.status() first. res.send(status) is removed too: use res.sendStatus(status), and quote a number if you really want a numeric body. Codemod: status-send-order.
// Express 4
res.json({ title: 'Dune' }, 201);
res.jsonp({ title: 'Dune' }, 201);
res.send({ title: 'Dune' }, 200);
res.send(404);
// Express 5
res.status(201).json({ title: 'Dune' });
res.status(201).jsonp({ title: 'Dune' });
res.status(200).send({ title: 'Dune' });
res.sendStatus(404);
A related trap: in Express 5, res.send(404) no longer sets a status. A number passed to res.send() is sent as a JSON body, so a leftover call produces a 200 response whose body is 404. Search for it explicitly.
res.redirect arguments and the back keyword
res.redirect(url, status) is removed; the status comes first. res.redirect('back') and res.location('back') are removed because the keyword trusted a client-controlled Referer header; redirect to an explicit URL and validate it. A redirect that relies on the default 302 is not affected. Codemods: redirect-arg-order and back-redirect-deprecated.
// Express 4
res.redirect('/books', 302);
res.redirect('back');
// Express 5
res.redirect(302, '/books');
res.redirect(req.get('Referrer') || '/');
The Referrer fallback above mirrors the official example, but a redirect to a header value is an open redirect if the value comes from another site. Where it matters, compare the header to an allow-list of your own paths before using it.
res.sendfile() and the sendFile options
res.sendfile() (lower case) is removed; use res.sendFile(). The hidden and from options of res.sendFile() are removed in favour of dotfiles and root, and the dotfile check now also applies to hidden directories anywhere in the resolved path, so a file under /var/www/app/.cache/ returns 404 unless you opt in. Codemods: camelcase-sendfile and sendfile-options.
// Express 4
res.sendfile('/path/to/cover.png');
res.sendFile(req.params.name, { hidden: true, from: '/uploads' });
// Express 5
res.sendFile('/path/to/cover.png');
res.sendFile(req.params.name, { dotfiles: 'allow', root: '/uploads' });
express.static options and express.static.mime
The hidden and from options of express.static() are removed (from was an undocumented alias of root). The express.static.mime object is removed; use the mime-types package when you need a lookup. Codemods: static-dotfiles and static-mime.
// Express 4
app.use(express.static('public', { hidden: true }));
const type = express.static.mime.lookup('json');
// Express 5
import mime from 'mime-types';
app.use(express.static('public', { dotfiles: 'allow' }));
const type = mime.lookup('json');
MIME types and debug namespaces
Two changes need no code change but can surprise a deployment. The MIME database moved on from mime-db 1.52.0, so express.static() and res.sendFile() serve some extensions with a different Content-Type; the most visible is .js, now text/javascript instead of application/javascript. The migration guide does not count this as breaking, so pin dependencies and test any client or proxy that matches on the old value.
The router’s debug namespaces were renamed because the router is now a separate module, so a DEBUG value from a runbook silently prints less.
| Express 4 | Express 5 |
|---|---|
|
|
|
|
|
|
|
|
# Express 4
DEBUG=express:* node index.js
# Express 5
DEBUG=express:*,router,router:* node index.js
Changed behaviour
These do not throw, so a test suite that never checks the affected behaviour passes. Read each one against your code.
Path matching with path-to-regexp 8
Route strings in app.all(), app.use(), app.METHOD() and the matching router methods are parsed by path-to-regexp 8 (through router 2). The rules that changed:
-
Wildcards must be named:
/*becomes/*splat. A named wildcard does not match the root path/; use/{*splat}when it must. -
The
?suffix is gone; optional segments use braces. -
Regular expression syntax inside a string path is not supported; use several paths in an array, or a real
RegExpobject. -
The characters
()[]?+!are reserved and must be escaped with a backslash. -
Parameter names must be valid JavaScript identifiers, or quoted like
:"this".
// Express 4
app.get('/*', notFoundPage);
app.get('/:file.:ext?', download);
app.get('/[discussion|page]/:slug', render);
// Express 5
app.get('/*splat', notFoundPage); // not the root
app.get('/{*splat}', notFoundPage); // including the root
app.get('/:file{.:ext}', download);
app.get(['/discussion/:slug', '/page/:slug'], render);
Source: Migrating to Express 5, "Path route matching syntax". The full grammar, escaping rules and a decision chart are in Route Paths & Parameters; the verbs and routers are in Routing and Routers & Modular Apps.
There is no codemod for the path syntax, so it needs a manual search. The patterns to grep for are '*', /*, a ? after a parameter, a parenthesis after a parameter name, and any bracket in a string path.
Rejected promises reach the error handlers
In Express 5 a middleware or handler that returns a rejected promise, or an async function that throws, calls next(err) for you. The try/catch that existed only to forward the error can go, and so can the packages that patched Express 4 to do the same.
// Express 4
app.get('/books/:id', (req, res, next) => {
getBook(req.params.id)
.then((book) => res.send(book))
.catch(next);
});
// Express 5
app.get('/books/:id', async (req, res) => {
const book = await getBook(req.params.id);
res.send(book);
});
Source: Migrating to Express 5, "Rejected promises handled from middleware and handlers". The consequences for error handlers are in Error Handling and Middleware Fundamentals.
express.urlencoded extended defaults to false
express.urlencoded() now parses with the querystring library unless you ask for extended: true. A form whose field names use brackets (user[name]) stops producing nested objects.
// Express 4 default behaviour is now opt-in
app.use(express.urlencoded({ extended: true }));
Source: Migrating to Express 5, "express.urlencoded". See Body Parsing.
express.static ignores dotfiles by default
The default for dotfiles is now "ignore", and the check covers hidden directories, so /.well-known/ is a 404. Serve it from its own mount with dotfiles: 'allow'.
app.use('/.well-known', express.static('public/.well-known', { dotfiles: 'allow' }));
app.use(express.static('public'));
router.param() rejects arrays
router.param(name, fn) throws a TypeError when name is not a string. app.param() still accepts an array.
// Express 4
router.param(['id', 'page'], loadParam);
// Express 5
router.param('id', loadParam);
router.param('page', loadParam);
app.listen passes errors to its callback
A failure to listen (for example EADDRINUSE) used to be an unhandled error event on the server. In Express 5 the callback receives it as an argument.
const server = app.listen(3000, '0.0.0.0', (error) => {
if (error) {
throw error;
}
console.log(`Listening on ${JSON.stringify(server.address())}`);
});
app.router is back
app.router existed in Express 3, was removed in 4, and returns in 5 as a plain reference to the application’s base router. There is still no reason to call app.use(app.router).
// Express 4
app.router; // throws Error: 'app.router' is deprecated!
// Express 5
console.log(app.router.stack.length); // the base router, created on first access
Source: Migrating to Express 5, "app.router".
req.body is undefined without a parser
Express 4 returned {} for a request that no body parser had touched. Express 5 returns undefined, so destructuring it throws.
// Express 4: {} Express 5: undefined
console.dir(req.body);
// defensive form
const { title, isbn } = req.body ?? {};
Source: Migrating to Express 5, "req.body". See Request and Validation & Input Handling.
req.host keeps the port
Express 4 stripped the port from req.host. Express 5 returns the value of the Host header including the port. Use req.hostname when you need the host name alone, and build absolute links from configuration rather than from a header.
// GET / with Host: books.example.com:3000
// Express 4: req.host === 'books.example.com'
// Express 5: req.host === 'books.example.com:3000'
const name = req.hostname; // 'books.example.com' in both
Source: Migrating to Express 5, "req.host".
req.params has a null prototype, array wildcards and omitted parameters
For a string path, req.params is an object with a null prototype (a regular expression path still gets a normal object), so req.params.hasOwnProperty(...) fails; use Object.hasOwn(). A wildcard produces an array of path segments, not one string. A parameter that did not match is omitted instead of being '' or undefined.
app.get('/*splat', (req, res) => {
// GET /library/fiction
// Express 4 with '/*': { '0': 'library/fiction' }
// Express 5 with '/*splat': [Object: null prototype] { splat: [ 'library', 'fiction' ] }
res.json(req.params.splat);
});
Source: Migrating to Express 5, "req.params".
req.query is a getter and uses the simple parser
req.query is no longer a writable property, so req.query.page = 1 has no effect. The default query parser setting changed from extended to simple, so ?filter[genre]=fiction is a flat key, not a nested object. Opt in with app.set('query parser', 'extended') only when you need nesting, and validate what you receive: nested objects are how operator injection reaches a database.
app.set('query parser', 'extended'); // opt back in, then validate the result
app.get('/search', (req, res) => {
const page = Number(req.query.page ?? 1); // read, never assign
res.json({ page });
});
Source: Migrating to Express 5, "req.query". See Application & Settings.
res.clearCookie ignores maxAge and expires
res.clearCookie(name, options) no longer honours maxAge and expires; the cookie is cleared with an expiry in the past. Pass the same path, domain, secure and sameSite as when you set it.
// Express 4
res.clearCookie('session', { maxAge: 0, expires: new Date(0) });
// Express 5
res.clearCookie('session');
Source: Migrating to Express 5, "res.clearCookie". See Cookies & Sessions.
res.status accepts only 100 to 999
res.status() throws for anything that is not an integer between 100 and 999. A computed status that can be NaN, a string or 0 now fails at the call.
// Express 4: accepted, with a deprecation warning for the string
res.status('404').send('Not found');
// Express 5
res.status('404'); // throws TypeError: not an integer
res.status(1000); // throws RangeError: outside 100 to 999
res.status(404).send('Not found'); // correct
Source: Migrating to Express 5, "res.status".
res.vary needs a field
res.vary() with no argument only logged a warning in Express 4. It now throws.
res.vary(); // throws in Express 5
res.vary('Accept'); // correct
Source: Migrating to Express 5, "res.vary". See Response and Content Negotiation & HTTP Caching.
Improvements that need no migration
-
res.render()is always asynchronous, whatever the view engine does. Code that assumed a synchronous render for a particular engine must use the callback or the following middleware. See Template Engines & Views. -
express.json(),express.urlencoded(),express.text()andexpress.raw()decompressContent-Encoding: brrequest bodies, in addition togzipanddeflate.
A worked migration of the Bookshelf API
The two files below are the same small service. The first is how the books teach it: Express 4, CommonJS, the separate body-parser package, status-first calls and a catch-all. The second is the Express 5 version.
// app.js, Express 4 style
const express = require('express');
const bodyParser = require('body-parser');
const books = new Map();
const app = express();
app.use(bodyParser.json());
app.get('/books/:id?', (req, res) => {
const id = req.param('id');
if (!id) return res.json([...books.values()]);
const book = books.get(id);
if (!book) return res.send(404);
res.json(book);
});
app.post('/books', (req, res) => {
const book = { id: String(books.size + 1), ...req.body };
books.set(book.id, book);
res.json(book, 201);
});
app.del('/books/:id', (req, res) => {
books.delete(req.params.id);
res.send(204);
});
app.get('*', (req, res) => {
res.send({ error: 'Not found' }, 404);
});
app.listen(3000);
// app.js, Express 5
import express from 'express';
const books = new Map();
export const app = express();
app.use(express.json());
app.get('/books{/:id}', (req, res) => {
const { id } = req.params;
if (id === undefined) {
return res.json([...books.values()]);
}
const book = books.get(id);
if (!book) {
return res.sendStatus(404);
}
res.json(book);
});
app.post('/books', (req, res) => {
const book = { id: String(books.size + 1), ...(req.body ?? {}) };
books.set(book.id, book);
res.status(201).json(book);
});
app.delete('/books/:id', (req, res) => {
books.delete(req.params.id);
res.sendStatus(204);
});
app.use((req, res) => {
res.status(404).json({ error: 'Not found' });
});
if (import.meta.url === `file://${process.argv[1]}`) {
app.listen(3000, (error) => {
if (error) throw error;
console.log('Bookshelf listening on http://localhost:3000');
});
}
Source: derived from Migrating to Express 5 and Routing. The path-less app.use() at the end replaces the catch-all, books{/:id} replaces the optional parameter, req.params.id replaces req.param(), and the export lets a test import the app without opening a port (see Testing).
Codemods
The Express team publishes codemods for the mechanical part of the migration. They run through the Codemod tool and rewrite source files in place, so run them on a clean working tree and review the diff.
The full recipe is a single command; the yarn, pnpm and bun equivalents are yarn dlx, pnpm dlx and bunx.
npx codemod@latest @expressjs/v5-migration-recipe
Each change can also be applied alone:
npx codemod@latest @expressjs/route-del-to-delete
The recipes listed in the official migration guide, checked on 2026-10-29, are these:
| Codemod | What it rewrites |
|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
The |
|
The |
|
|
The guide lists no recipe for the path syntax, req.query, req.host, req.params, res.clearCookie, res.status, res.vary, router.param() arrays, the app.listen callback or the urlencoded default. Those are manual edits, and the checklist below covers them. The Express project keeps the up-to-date list of recipes at codemod.link/express; re-read it before a migration, because recipes are added and renamed.
Express 4 to 5 upgrade checklist
Do the steps in order and keep the tests green after each one. A migration that mixes the upgrade with feature work is hard to bisect.
-
Make the test suite pass on the current Express 4 version, and add tests for the routes that use wildcards, optional parameters,
req.queryfilters and static files. Most of the changes fail only where the code runs. -
Move to Node.js 18 or newer. The recommended target is the current LTS (24 at the time of writing); see Versions, Support & LTS.
-
Upgrade to the newest 4.x first (
express@4) and fix the deprecation warnings it prints. Most removals were deprecated in 4.x for years. -
Commit, then run
npx codemod@latest @expressjs/v5-migration-recipeand review the diff. -
Install Express 5 and its companion types:
npm install express@5and, for TypeScript,npm install --save-dev @types/express@5. Update the sibling packages that pin Express 4 (for example the Apollo integration moves to@as-integrations/express5, and middleware that listsexpressas a peer dependency). -
Search for the path patterns that crash at start-up:
'*',/*, a parameter followed by?, a parenthesis or bracket in a string route. Rewrite them with/*splat,/{*splat}and brace groups, or with an array of paths. -
Check every place that reads
req.body(guard againstundefined),req.query(thesimpleparser, no assignment),req.params(arrays for wildcards, null prototype) andreq.host. -
Search for leftovers the codemods cannot see:
res.send(with a number,app.param(fn),router.param([,res.clearCookie(withmaxAge,res.status(with a computed value andres.vary(). -
Review
express.static()andres.sendFile()for dotfiles (/.well-known) and for clients that match on the old.jscontent type. -
Register a JSON error handler last, remove
try/catchand shims that only existed to forward async errors, and handle theerrorargument of theapp.listencallback. -
Update the
DEBUGvalue in runbooks to includerouterandrouter:*. -
Update the tests, run them with
NODE_ENV=production, and deploy to a staging environment before production. Keep the lock file committed so the rollback to Express 4 is a revert.
Source: Migrating to Express 5; Error handling.
The Express 3 to 4 leftovers
Several tutorials, and parts of the older books, were written for Express 3 or the early 4.x releases. Their code fails on every current version, and the symptoms are often confusing because the error mentions a missing function. The changes were made in Express 4.0, published in April 2014.
| Express 3 code | Express 4 and 5 |
|---|---|
|
Removed. Call |
|
Removed. Use |
|
Not needed. Routes run in the order they were registered. |
Bundled Connect middleware reachable as |
Removed from the core; each is a separate package (table below). Body parsing returned to the core in 4.16.0 as |
|
Deprecated in 4.x and removed in 5.0. |
The bundled middleware and the packages that replaced them:
| Express 3 member | Package today |
|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
Removed; use |
Source: Migrating to Express 4. The maintained modules are described in Official Middleware Modules; the application API leftovers in Extending the Express API.
Deprecated middleware
The Express team announced on 2025-05-16 that several modules are deprecated and no longer receive fixes. Do not add them to a new project and plan a replacement in existing ones.
| Module | What to use instead |
|---|---|
|
SameSite cookies with a maintained token library such as |
|
|
|
Express 5 routing, or |
connect-timeout is still published but is better replaced by Node.js server timeouts and abortable operations. method-override still works and needs a body parser registered before it. Both are covered in Official Middleware Modules.
Source: Express blog; Express middleware.
Where the books are outdated, insecure or wrong
The four books behind this section target Express 4.0 to 4.18, and one of them was published three months before Express 5.0.0, so none can show Express 5. The statements below are described and corrected, never reproduced. Each row says what changed, in which version, and links to the page that treats the topic in depth. In the columns, "Sharma", "Monteiro 2014", "Monteiro 2016" and "Bugl" refer to the four books in the bibliography on the section landing page.
Express API changes
| Older practice | Current state | Changed in | Details |
|---|---|---|---|
All four books present Express 4 (4.0 to 4.18) as the current line. |
Express 5.3.0 is the |
5.0 (2024-09), |
|
|
Express has no CLI. The generator is the separate |
Express 4.0 |
|
|
The combined function no longer exists. |
4.0 (removal), 4.16.0 (built in), 5.0 (defaults) |
Body Parsing, Middleware Fundamentals, Routers & Modular Apps, Routing, Validation & Input Handling |
|
Removed. Chain |
Deprecated in 4.x, removed in 5.0 |
Response, Building REST APIs, Content Negotiation & HTTP Caching, File Uploads & Downloads, TypeScript with Express |
|
Throw at start-up ("Missing parameter name") with |
5.0 |
Route Paths & Parameters, Routing, Routers & Modular Apps, SPAs & Server-Side Rendering, CORS |
Reading a wildcard as the string |
The wildcard is an array under a name you choose, the colon is ignored, the function-only form is removed, |
5.0 |
Route Paths & Parameters, Request, Routers & Modular Apps, Extending the Express API |
|
Removed. Use |
Deprecated in 4.x, removed in 5.0 |
Routing, Response, Serving Static Files, Content Negotiation & HTTP Caching |
Reading nested query-string filters from |
Express 5 defaults to the |
5.0 |
Application & Settings, Request, Validation & Input Handling |
Using |
|
5.0 |
|
A |
Express 5 forwards rejected promises; a missing |
5.0 |
Error Handling, Middleware Fundamentals, File Uploads & Downloads, SPAs & Server-Side Rendering, Building REST APIs |
Ad hoc error bodies such as an error key or a success flag. |
Prefer RFC 9457 |
Practice |
|
|
In Express 5 the callback receives the |
5.0 |
Getting Started, Application & Settings, Error Handling, Health Checks & Graceful Shutdown, Project Structure & Configuration |
|
Use |
5.0 |
|
|
|
4.0, 5.0 |
Response, Template Engines & Views, Application & Settings, Extending the Express API, Routers & Modular Apps |
Plugin and app code that builds paths with |
Express 5 supports CommonJS and ESM. In ESM use |
Practice |
Application & Settings, Serving Static Files, Routers & Modular Apps, Introduction & Architecture |
Middleware, sessions and authentication
| Older practice | Current state | Changed in | Details |
|---|---|---|---|
|
Removed from the core; each is a separate package. |
4.0; deprecations announced 2025-05 |
Official Middleware Modules, Performance Best Practices, Debugging & Logging, Security Best Practices |
|
Both still work, but Node.js server timeouts with abortable operations and |
Practice |
|
|
Both flags |
Practice; session 1.5.0 |
Cookies & Sessions, Authentication & Authorization, Security Best Practices, Behind Proxies |
|
|
connect-mongo 4 |
|
|
Passport 0.6 and later needs |
Passport 0.6 |
Authentication & Authorization, Cookies & Sessions, Middleware Fundamentals |
JWTs with no expiry, a custom |
Short |
express-jwt 8 |
|
A first OAuth login that passes |
Create the local user at first login, identify by issuer and subject, and use authorization code with PKCE. |
Practice |
|
Deploying behind a PaaS router with |
Cookies marked |
Practice |
|
|
An explicit allow-list from configuration, |
Practice |
CORS, GraphQL with Express, Security Best Practices, Real-Time: WebSockets & SSE |
Data, input and output
| Older practice | Current state | Changed in | Details |
|---|---|---|---|
Mongoose callbacks ( |
Removed; use promises and |
Mongoose 7 |
|
|
Use |
Mongoose 8 |
|
|
|
Mongoose 5, MongoDB 5.0 |
|
|
Pick fields explicitly, validate first, consider |
Practice |
|
|
Use migrations ( |
Sequelize 6 |
|
|
Removed; use |
express-validator 6 and 7 |
|
Jade as the generator default, |
Jade was renamed Pug in 2016 and the |
Pug 2016, EJS 3 |
|
Stack traces or error messages rendered with an unescaped EJS tag, and |
A cross-site scripting hole. Escape the output, hide stacks in production and name the values you pass. |
Practice |
Template Engines & Views, Error Handling, Security Best Practices |
Uploads written under |
Generate the stored name, verify the content and re-encode images, store outside |
Multer 2 |
File Uploads & Downloads, Serving Static Files, Request, Security Best Practices |
Mass assignment (a role or |
Allow-list the accepted fields, take the owner from the authenticated user, build every response from a public shape, and validate identifiers (answer |
Practice |
Request, Response, Validation & Input Handling, Authentication & Authorization, Building REST APIs |
POST answered with |
Answer |
Practice |
|
Sitemaps built from unescaped strings, |
Escape values and respect the 50,000-URL limit, skip paths with a file extension, and prefer a dev-server proxy and one origin in production. |
Practice |
|
Weak ETags ignored, disabled or mixed up with |
Weak ETags are on by default and combine with |
Practice |
Ecosystem, hosting and tooling
| Older practice | Current state | Changed in | Details |
|---|---|---|---|
The Apollo Server 4 integration imported from |
Apollo Server 4 reached end of life in January 2026 (check Apollo’s version policy). Apollo Server 5 moved the Express integration to |
Apollo Server 5 |
|
|
|
Socket.IO 3 and 4 |
|
LoopBack 2/3 and |
End of life, archived or deprecated: LoopBack 4 is a rewrite, Keystone 5 and 6 replaced the old one, and current stacks use Vite and a maintained front end. Several frameworks still pin Express 4, so the Express 5 changes may not apply to them. Koa, Fastify, Hono and NestJS are mainstream options. |
Practice |
Frameworks Built on Express & Alternatives, Introduction & Architecture, Project Structure & Configuration |
Heroku free dynos, mLab/MongoLab, MongoHQ/Compose, travis-ci.org, Shippable and Codeship. |
The Heroku free tier ended in November 2022 (the platform still exists), mLab and MongoHQ are gone (MongoDB Atlas replaced them), travis-ci.org was shut down, Shippable was discontinued and Codeship is legacy. GitHub Actions and similar hosted runners replaced the pipelines. |
2022 and later |
Deployment & Containers, Health Checks & Graceful Shutdown, Performance Best Practices, Database Integration, Behind Proxies |
|
Use systemd, a container restart policy or an orchestrator in production; |
Node.js 18 and later |
Deployment & Containers, Performance Best Practices, Health Checks & Graceful Shutdown, Getting Started, Debugging & Logging |
|
Use |
Node.js 18 |
Debugging & Logging, Performance Best Practices, Health Checks & Graceful Shutdown |
Mocha through Grunt, |
|
Practice |
|
Running TypeScript through |
Node.js strips types natively from 22.18 and 23.6 (use |
Node.js 22.18, @types/express 5 |
|
|
Ignore |
Node.js 20.6 |
Project Structure & Configuration, Getting Started, Security Best Practices |
One process, a hand-written cluster, |
|
Practice |
Security problems the books leave in place
Security corrections are spread over the rows above (sessions, JWTs, CORS, mass assignment, uploads, stack traces, secrets). The rows below add the controls that none of the four books covers.
| Weakness | Current practice | Details |
|---|---|---|
Session, JWT, OAuth and database secrets written in source, and |
Environment variables or a secret manager, |
Security Best Practices, Project Structure & Configuration, Deployment & Containers |
No Helmet, no rate limiting, no input validation and no CSRF protection in any book; |
One middleware sets the standard headers, per-route and per-account rate limits use a shared store, schemas validate every input, and SameSite cookies are paired with |
Security Best Practices, Validation & Input Handling, Authentication & Authorization |
Inline regular expressions in route strings that relied on |
|
|
|
Disable the |
Where each page keeps its own list
Every concept page in the section has a "Where the books are outdated" subsection written for the topic of that page, with the chapter-level attribution of each practice. The consolidated tables above merge them; use the index below to jump to the original.
| Page | Subsection scope |
|---|---|
The Express 4 era, the generator, body parsing, status signatures, the old front-end stack. |
|
The generator, Jade, listen errors, |
|
Which Express line is current, global installs, Node.js minimums, hosting and CI of 2014 to 2018. |
|
|
|
Global installs, the generated layout, the app and server split, secrets, |
|
|
|
The Connect middleware removed in Express 4, |
|
|
|
|
|
Catch-alls, optional parameters, wildcard values, |
|
CommonJS routers, |
|
|
|
Status-first calls, |
|
|
|
Singular |
|
Session flags, stores, |
|
|
|
Secrets, sessions, Helmet, rate limiting, CSRF, CORS, mass assignment, uploads, stack traces, audits. |
|
Open |
|
|
|
Per-handler |
|
No validation, express-validator 2, mass assignment, injection, id formats, |
|
Status-first calls, |
|
|
|
Callbacks, removed methods, connection options, |
|
Jade and Pug, EJS 3, Swig, |
|
Root paths, Bower, |
|
Enctype, original names, MIME trust, Multer versions, error handling, storage, |
|
Catch-all fallbacks, missing assets, CORS and dev servers, |
|
Apollo Server 4 integration, Express 4, CORS and limits, start order, introspection, demand control. |
|
Socket.IO |
|
|
|
Heroku, mLab, Travis and friends, |
|
No signal handlers, listen errors, |
|
Process supervisors, clustering, bundled logger and |
|
|
|
|
|
LoopBack, KeystoneJS, MEAN.JS, framework pins on Express 4, the other Node.js choices. |
After migrating
Run the whole test suite, then exercise the application with a client that sends requests the tests do not: a form post with brackets in the field names, a request to /.well-known/, a wildcard route with a deep path, a call with no body, a failed listen, and a rejected promise in a handler. Re-read Versions, Support & LTS for the maintenance plan of the line you chose, and keep the lock file and a scheduled dependency update in place so the next upgrade is small.