Routing

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 (npm 5.3.0, released 2026-10-09; the last Express 4 release is 4.22.3), ESM and node: imports, and was checked on 2026-10-29. Express 5 itself runs on Node.js 18 and later, but the examples assume Node.js 20.19.3 or later on the 20 line, or 22.2.0 or later, because app.query() needs it (see app.query() and the HTTP QUERY method). Every example belongs to the Bookshelf scenario used throughout this section, and the outputs shown are what the applications return when they are exercised with curl.

Routing is how an Express application decides which code answers a request: a request is matched on its HTTP method and its path, and the matching route runs one or more handlers. A route is a small piece of the middleware machinery: its handlers have the same (req, res, next) signature, and the handlers of one route are called in order until one of them ends the response. This page covers the routing methods of the application object, multiple handlers and next('route'), chained routes, the order in which routes are tried and what happens when none matches, and three of the official examples. The path syntax itself (named parameters, wildcards, optional segments) is on Route Paths and Parameters, and grouping routes into mountable modules is on Routers and Modular Apps.

Anatomy of a route

A route definition has the shape app.METHOD(path, handler, ...):

  • app is an instance of express() (or an express.Router(), which exposes the same routing methods).

  • METHOD is an HTTP method in lower case: get, post, put, delete, and so on.

  • path is a string, a regular expression or an array of either, matched against the request path.

  • handler is one or more functions: a function, a comma-separated list, an array, or a mix of the three.

A route matches only when the method and the whole path match. A string path such as /books matches /books but not /books/1, whereas app.use('/books', ...) matches both because it mounts middleware on a prefix. Regular expressions are matched against any part of the path unless anchored with ^ and $.

Express 5 builds its routes on the standalone router package (version 2.x), which uses path-to-regexp 8. The behaviour described here is that of router, so it is the same for the application and for express.Router().

app.METHOD() for every HTTP verb

Express creates one routing method per method known to the running Node.js: the list is http.METHODS converted to lower case. The official documentation lists the following, and states that the API reference has explicit entries only for get, post, put, delete and query, the others working in exactly the same way:

checkout, copy, delete, get, head, lock, merge, mkactivity, mkcol, move, m-search, notify, options, patch, post, purge, put, query, report, search, subscribe, trace, unlock, unsubscribe.

Because the list comes from Node.js, others such as propfind or link exist too, and app.query exists only on a Node.js line whose http.METHODS contains QUERY (see app.query() and the HTTP QUERY method). A method whose name is not a valid JavaScript identifier needs bracket notation: app['m-search'](path, handler).

The usual REST verbs of the Bookshelf API look like this.

verbs.mjs
import { METHODS } from 'node:http';
import express from 'express';

const app = express();
const books = new Map([[1, { id: 1, title: 'Dune', year: 1965 }]]);
let nextId = 2;

app.use(express.json());

// Every verb has a lower-case method on the app.
app.get('/books', (req, res) => {
  res.json([...books.values()]);
});

app.post('/books', (req, res) => {
  const book = { id: nextId++, ...req.body };
  books.set(book.id, book);
  res.status(201).location(`/books/${book.id}`).json(book);
});

app.put('/books/:id', (req, res) => {
  const id = Number(req.params.id);
  books.set(id, { id, ...req.body });
  res.json(books.get(id));
});

app.delete('/books/:id', (req, res) => {
  res.sendStatus(books.delete(Number(req.params.id)) ? 204 : 404);
});

// app.all() matches every verb, whatever the request method is.
app.all('/books/audit', (req, res, next) => {
  console.log(`audit: ${req.method} ${req.originalUrl}`);
  next();
});

// QUERY: a safe, idempotent read whose criteria travel in the body.
// Registered only when this Node.js line knows the verb.
if (METHODS.includes('QUERY')) {
  app.query('/books/search', (req, res) => {
    const { titleContains = '', yearFrom = 0 } = req.body ?? {};
    const found = [...books.values()].filter(
      (b) => b.title.toLowerCase().includes(String(titleContains).toLowerCase()) && b.year >= yearFrom,
    );
    res.json(found);
  });
}

app.listen(3000, (error) => {
  if (error) throw error;
  console.log('Bookshelf listening on http://localhost:3000');
});

Observed behaviour, with the server above running:

Request Response Why

POST /books with a JSON body

201 Created and the new book

express.json() filled req.body; the handler sets the status and the Location header.

QUERY /books/search with a JSON body

200 OK and the matching books

app.query() is registered; the criteria are read from req.body.

PATCH /books/audit

404 Not Found, after the line audit: PATCH /books/audit is logged

app.all() ran and called next(), but no route handles PATCH, so the request falls through (Route order and the 404 fall-through).

HEAD /books

200 OK with no body

There is no app.head(): a GET route also answers HEAD, and Node.js discards the body.

OPTIONS /books

200 OK with Allow: GET, HEAD, POST

Express answers OPTIONS itself from the methods registered for the path.

Three details are worth remembering:

  • A GET route also handles HEAD, unless a HEAD route is registered before it.

  • Express does not send 405 Method Not Allowed on its own. A path that exists for GET but is requested with PUT simply falls through to the 404 handler, so an API that wants 405 and an Allow header has to add it (see Route order and the 404 fall-through).

  • app.METHOD() registers a route on the application, while app.use() registers middleware. The first one matches the full path and one method, the second matches a path prefix and every method.

app.all()

app.all(path, handler, ...) is app.METHOD() for every HTTP method. It is the usual place for code that must run for a path regardless of the verb: logging, loading a record that every verb needs, or a pre-condition. Its handlers behave exactly like middleware, so they must call next() to let the specific verb routes run; the example above does so, and the PATCH request still falls through because nothing else matches.

app.all('*', ...) is a common Express 4 idiom for "every path". It no longer works in Express 5, because a bare * is not valid path syntax. Use a named wildcard (app.all('/*splat', ...)) or, more simply, app.use() without a path.

Source: app.all().

app.query() and the HTTP QUERY method

app.query() was added in Express 5.3. It routes the HTTP QUERY method, which the official documentation describes as standardised on 2026-06-15 as RFC 10008. QUERY is a read operation that, like POST, carries its input in the request body, but, like GET, is safe and idempotent. Intermediaries may retry it automatically, and its responses are cacheable with the request body part of the cache key. It is meant for queries whose input is too large, too sensitive or too structured for a URL: deeply nested filters, search criteria that should not end up in server logs or browser history, or lists of identifiers that exceed URL length limits.

Four rules apply:

  • Node.js requirement. app.query() exists only when the running Node.js knows the QUERY method in http.METHODS. The API reference gives the requirement as Node.js >=20.19.3 <21 || >=22.2.0, whereas Express 5.3 itself declares engines.node as >= 18. On Node.js 18 or on older 20 and 22 releases the method is not defined: calling it throws a TypeError, and the HTTP parser of such a release does not recognise the verb either. The example guards the registration with http.METHODS, so that the same file runs everywhere.

  • The body is not parsed for you. req.body is undefined until a body parser such as express.json() or express.urlencoded() runs for a matching Content-Type. Mount it before the route.

  • app.all() includes QUERY. A catch-all handler also runs for QUERY requests where Node.js supports them.

  • Support outside the server is limited. Browsers, fetch wrappers, proxies, CDNs and API gateways may not know the verb yet. Verify every hop between the client and the application before relying on it in production, and consider POST /books/search as the portable alternative.

A curl call that exercises the route of the example:

curl -X QUERY http://localhost:3000/books/search \
  -H 'content-type: application/json' \
  -d '{"titleContains": "em", "yearFrom": 1800}'

Source: app.query(); History.md. The method specification itself is published by the RFC Editor as RFC 10008.

Multiple handlers, next() and next('route')

A route accepts any number of handlers. Each one is called with (req, res, next); the chain continues only when a handler calls next(), and stops when a handler ends the response (any of the methods in The response methods) or calls next(err). A handler that neither responds nor calls next leaves the client waiting until it times out.

Handlers can be passed as separate arguments, as an array, or as a combination of both, and Express flattens them. That makes it easy to share a stack such as "log, then load the shelf" between routes.

next() and next('route') differ in how far they skip:

Call Effect

next()

Go to the next handler of the same route; after the last handler, go to the next matching route or middleware.

next('route')

Skip the remaining handlers of the current route and continue with the next route that matches the request. It works only in handlers registered with app.METHOD(), app.all(), app.route() or the router equivalents.

next('router')

Leave the current router altogether (covered in Routers and Modular Apps).

next(err)

Skip all remaining non-error handlers and go to the error-handling middleware (see Error Handling). Any truthy argument other than the strings 'route' and 'router' is treated as an error.

next('route') is the tool for pre-conditions: "this handler stack only applies if a condition holds, otherwise let a later route answer". The example sends members to a members-only response and everybody else to a public one, for the same method and path.

flow.mjs
import express from 'express';

const app = express();

const members = new Set(['ana', 'luis']);

// A pre-condition handler: members go on, everybody else is sent to the next matching route.
function membersOnly(req, res, next) {
  if (members.has(req.get('x-user') ?? '')) {
    next(); // continue with the next handler of THIS route
  } else {
    next('route'); // skip the remaining handlers of this route
  }
}

app.get('/library/books', membersOnly, (req, res) => {
  res.json({ audience: 'members', books: ['Dune', 'Emma', 'Ulysses'] });
});

// Reached by non-members because of next('route'), not by members.
app.get('/library/books', (req, res) => {
  res.json({ audience: 'public', books: ['Dune'] });
});

// An array of handlers, a list of handlers or a mix of both is accepted.
const logRequest = (req, res, next) => {
  console.log(`${req.method} ${req.path}`);
  next();
};
const loadShelf = (req, res, next) => {
  res.locals.shelf = 'fiction';
  next();
};
app.get('/library/shelf', [logRequest, loadShelf], (req, res) => {
  res.json({ shelf: res.locals.shelf });
});

app.listen(3000, (error) => {
  if (error) throw error;
  console.log('Listening on http://localhost:3000');
});

Observed behaviour:

Request Response

GET /library/books with x-user: ana

{"audience":"members","books":["Dune","Emma","Ulysses"]}

GET /library/books without the header

{"audience":"public","books":["Dune"]}

GET /library/shelf

{"shelf":"fiction"}, after the line GET /library/shelf is logged

The diagram summarises where each call sends the request.

flowchart TD A["Request GET /library/books"] --> B[membersOnly] B -->|next| C["Members handler"] B -->|"next('route')"| D["Next matching route"] C --> E["Response: members"] D --> F["Response: public"] B -->|"next(err)"| G["Error-handling middleware"]

Express 5 forwards the outcome of async handlers for you: a rejected promise (or a thrown error) in a handler is passed to next(err) automatically. In Express 4 an async handler had to catch the error and call next(err) itself, or the request hung and Node.js reported an unhandled rejection. A handler that calls next() and then also writes a response is a bug in both versions, and produces the error Cannot set headers after they are sent.

Chaining routes with app.route()

app.route(path) returns a single Route instance for one path, on which every verb is attached by chaining. The path is written once, which removes the typos that arise when /books/:id is repeated for get, put and delete, and .all() on the route runs before the specific verb for every method.

chain.mjs
import express from 'express';

const app = express();
const books = new Map([[1, { id: 1, title: 'Dune' }]]);

app.use(express.json());

// The path is written once; each verb adds its handlers.
app
  .route('/books/:id')
  .all((req, res, next) => {
    const book = books.get(Number(req.params.id));
    if (!book && req.method !== 'PUT') {
      res.status(404).json({ error: 'book not found' });
      return;
    }
    res.locals.book = book;
    next();
  })
  .get((req, res) => {
    res.json(res.locals.book);
  })
  .put((req, res) => {
    const id = Number(req.params.id);
    books.set(id, { id, ...req.body });
    res.json(books.get(id));
  })
  .delete((req, res) => {
    books.delete(Number(req.params.id));
    res.sendStatus(204);
  });

app.listen(3000, (error) => {
  if (error) throw error;
  console.log('Listening on http://localhost:3000');
});

Observed behaviour:

Request Response

GET /books/1

200 and the book

GET /books/7

404 and {"error":"book not found"}, produced by the .all() guard

PUT /books/7 with a JSON body

200 and the new book (the guard lets PUT through)

DELETE /books/1

204 with no body

A route created by app.route() has no mount path of its own and cannot be reused elsewhere. For a group of related routes, express.Router() is the better tool (see Routers and Modular Apps), and a router exposes the same route() method.

The response methods

A handler must end the request-response cycle. These are the methods of res that do:

Method Description

res.download()

Prompts the client to save a file; see File Uploads and Downloads.

res.end()

Ends the response without data (the low-level node:http method).

res.json()

Sends a JSON response with the right Content-Type.

res.jsonp()

Sends a JSON response with JSONP support.

res.redirect()

Redirects the request.

res.render()

Renders a view template and sends the HTML.

res.send()

Sends a response of various types (string, Buffer, object, array).

res.sendFile()

Sends a file as an octet stream, with a Content-Type guessed from its extension.

res.sendStatus()

Sets the status code and sends its standard text as the body.

Their details are on Response. Two Express 5 changes matter in routes: the forms res.json(obj, status), res.send(body, status), res.send(status) and res.redirect(url, status) were removed, so the status is always set with res.status(code) first (res.status(201).json(book)), and res.status() accepts only integers from 100 to 999.

Route order and the 404 fall-through

Express tries middleware and routes in the order in which they were registered, and the first handler that ends the response wins. Two consequences follow.

Specific routes before general ones. /books/latest also matches /books/:id, so whichever is registered first answers. Put literal paths ahead of parameterised ones, and narrower paths ahead of wildcards.

Nothing matched means 404. When a request has gone through every layer without being answered, Express hands it to its final handler, which sends 404 Not Found with an HTML body such as Cannot GET /nope. Express does not treat this as an error; it is the absence of a match. To produce your own response, add a middleware after all routes, with no path and the normal three-argument signature, and, after it, a four-argument error-handling middleware.

order.mjs
import express from 'express';

const app = express();

// 1. A specific route first ...
app.get('/books/latest', (req, res) => {
  res.json({ id: 42, title: 'Latest arrival' });
});

// 2. ... then the parameterised one. Swap them and /books/latest would match :id.
app.get('/books/:id', (req, res) => {
  res.json({ id: req.params.id });
});

// 3. Nothing matched: a final middleware turns the fall-through into a 404.
app.use((req, res) => {
  res.status(404).json({
    type: 'about:blank',
    title: 'Not Found',
    status: 404,
    detail: `No route for ${req.method} ${req.originalUrl}`,
  });
});

// 4. Any error that reaches this four-argument handler ends here.
app.use((err, req, res, next) => {
  if (res.headersSent) {
    next(err);
    return;
  }
  res.status(err.status ?? 500).json({ title: 'Error', status: err.status ?? 500 });
});

app.listen(3000, (error) => {
  if (error) throw error;
  console.log('Listening on http://localhost:3000');
});

Observed behaviour:

Request Response

GET /books/latest

{"id":42,"title":"Latest arrival"}

GET /books/7

{"id":"7"}; note that req.params.id is a string

GET /nope

404 and a problem-details JSON body, from the middleware placed after the routes

Practical points:

  • The 404 middleware must be registered last, before only the error handler. Registered earlier, it answers every request that reaches it and hides every later route.

  • To return 405 Method Not Allowed, register a catch-all with app.all('/books', handler) for the path after its verb routes, and have the handler set the Allow header and call res.sendStatus(405).

  • A trailing slash and letter case are ignored by default: /Books/ matches /books. The strict routing and case sensitive routing settings change that, and are described on Route Paths and Parameters.

  • Express 5 brings app.router back as a property, so tooling that inspects the router (for instance to list the routes) can reach it again.

Official example: resource routing

The official resource example registers the routes of a collection with a small helper, and shows an interesting path, /users/:a..:b{.:format}: two named parameters separated by two literal dots, and an optional format suffix in braces. This version applies the same ideas to books. It is Express 5 syntax; in Express 4 the optional suffix was written with a question mark (:format?), which now throws a path error.

resource.mjs
import express from 'express';

const app = express();

// An ad-hoc helper that registers the usual routes of a collection.
function resource(path, controller) {
  app.get(path, controller.index);
  app.get(`${path}/:a..:b{.:format}`, (req, res) => {
    const a = Number.parseInt(req.params.a, 10);
    const b = Number.parseInt(req.params.b, 10);
    controller.range(req, res, a, b, req.params.format);
  });
  app.get(`${path}/:id`, controller.show);
  app.delete(`${path}/:id`, (req, res) => {
    controller.destroy(req, res, Number.parseInt(req.params.id, 10));
  });
}

const books = [
  { title: 'Dune' },
  { title: 'Emma' },
  { title: 'Ulysses' },
  { title: 'Middlemarch' },
  { title: 'Persuasion' },
];

const BookController = {
  index(req, res) {
    res.json(books);
  },
  show(req, res) {
    const book = books[Number(req.params.id)];
    if (!book) {
      res.status(404).json({ error: 'Cannot find book' });
      return;
    }
    res.json(book);
  },
  destroy(req, res, id) {
    const exists = id in books;
    delete books[id];
    res.status(exists ? 200 : 404).send(exists ? 'destroyed' : 'Cannot find book');
  },
  range(req, res, a, b, format) {
    const slice = books.slice(a, b + 1);
    if (format === 'json') {
      res.json(slice);
      return;
    }
    res.send(`<ul>${slice.map((book) => `<li>${book.title}</li>`).join('')}</ul>`);
  },
};

resource('/books', BookController);

app.listen(3000, (error) => {
  if (error) throw error;
  console.log('Listening on http://localhost:3000');
});

Observed behaviour:

Request Response

GET /books/1

{"title":"Emma"}

GET /books/1..3

<ul><li>Emma</li><li>Ulysses</li><li>Middlemarch</li></ul>

GET /books/1..3.json

[{"title":"Emma"},{"title":"Ulysses"},{"title":"Middlemarch"}]

DELETE /books/1

destroyed

GET /books/9

404 and {"error":"Cannot find book"}

Order matters here too. /books/:a..:b{.:format} is registered before /books/:id, otherwise 1..3 would be captured as an id. The helper uses delete(...), which is the Express 5 name; app.del() was removed.

Official example: params

app.param(name, callback) registers a callback that runs once per request, before the first route handler that has a parameter of that name, however many routes in the request cycle declare it. The callback receives (req, res, next, value, name). The official params example uses it to convert two parameters to integers and to load a user. An array of names registers the same callback for each name.

params.mjs
import express from 'express';

const app = express();

class HttpError extends Error {
  constructor(status, message) {
    super(message);
    this.status = status; // finalhandler uses it as the response status
  }
}

const books = [{ title: 'Dune' }, { title: 'Emma' }, { title: 'Ulysses' }, { title: 'Persuasion' }];

// Runs once per request for every route that declares :from or :to.
app.param(['from', 'to'], (req, res, next, value, name) => {
  const number = Number.parseInt(value, 10);
  if (Number.isNaN(number)) {
    next(new HttpError(400, `failed to parse ${name}: ${value}`));
    return;
  }
  req.params[name] = number;
  next();
});

// Load the book once; the handler can rely on req.book.
app.param('book', (req, res, next, id) => {
  const book = books[id];
  if (!book) {
    next(new HttpError(404, 'failed to find book'));
    return;
  }
  req.book = book;
  next();
});

app.get('/', (req, res) => {
  res.send('Visit /book/0 or /books/0-2');
});

app.get('/book/:book', (req, res) => {
  res.json(req.book);
});

app.get('/books/:from-:to', (req, res) => {
  const { from, to } = req.params;
  res.json(books.slice(from, to + 1).map((book) => book.title));
});

app.listen(3000, (error) => {
  if (error) throw error;
  console.log('Listening on http://localhost:3000');
});

Observed behaviour:

Request Response

GET /book/1

{"title":"Emma"}

GET /book/9

404 (the status is taken from err.status); outside production the body is the stack trace, which starts with Error: failed to find book

GET /books/0-2

["Dune","Emma","Ulysses"]

GET /books/a-2

400; outside production the stack trace starts with Error: failed to parse from: a

When NODE_ENV is not production, the default error handler (finalhandler) sends the stack trace as the response body; with NODE_ENV=production it sends only the status text, such as Not Found, and never the error message. A status from err.status or err.statusCode is honoured when it is between 400 and 599.

Express 5 changes how app.param() behaves: the form that took a single function (app.param(fn)) was removed, and so was a leading colon in the name (app.param(':name')). router.param() rejects an array of names in 5.x, so register one callback per name there. Param callbacks are local to the router that defines them: they do not fire for parameters of routes in another router, nor for parameters that appear only in a mount path.

Official example: route-specific middleware

The official route-middleware example shows that a route’s handler list is a convenient place for authorisation. Functions that load a record or check a permission are written once and listed before the final handler, and a factory turns configuration into a middleware (restrictTo('admin')). Failing a check calls next(err). The official example passes plain errors, which the default handler turns into 500; here the errors carry an HTTP status.

routemw.mjs
import express from 'express';

const app = express();

const users = [
  { id: 0, name: 'ana', role: 'member' },
  { id: 1, name: 'luis', role: 'member' },
  { id: 2, name: 'marta', role: 'admin' },
];

class HttpError extends Error {
  constructor(status, message) {
    super(message);
    this.status = status;
  }
}

// Route-level middleware: plain functions placed before the final handler.
function loadUser(req, res, next) {
  const user = users[Number(req.params.id)];
  if (!user) {
    next(new HttpError(404, `Failed to load user ${req.params.id}`));
    return;
  }
  req.user = user;
  next();
}

function restrictToSelf(req, res, next) {
  if (req.authenticatedUser.id === req.user.id) {
    next();
  } else {
    next(new HttpError(403, 'Forbidden'));
  }
}

// A factory returns a configured middleware.
function restrictTo(role) {
  return (req, res, next) => {
    if (req.authenticatedUser.role === role) {
      next();
    } else {
      next(new HttpError(403, 'Forbidden'));
    }
  };
}

// Fake authentication. A real application would verify a session or a token.
app.use((req, res, next) => {
  req.authenticatedUser = users[Number(req.get('x-demo-user') ?? 0)];
  next();
});

app.get('/user/:id', loadUser, (req, res) => {
  res.send(`Viewing user ${req.user.name}`);
});

app.get('/user/:id/edit', loadUser, restrictToSelf, (req, res) => {
  res.send(`Editing user ${req.user.name}`);
});

app.delete('/user/:id', loadUser, restrictTo('admin'), (req, res) => {
  res.send(`Deleted user ${req.user.name}`);
});

app.listen(3000, (error) => {
  if (error) throw error;
  console.log('Listening on http://localhost:3000');
});

Observed behaviour:

Request Response

GET /user/1/edit, authenticated as user 0 (the default)

403 Forbidden

GET /user/1/edit with x-demo-user: 1

Editing user luis

DELETE /user/1, authenticated as user 0

403 Forbidden

DELETE /user/1 with x-demo-user: 2 (an admin)

Deleted user luis

GET /user/8

404; outside production the stack trace starts with Error: Failed to load user 8, and in production the body is Not Found

The x-demo-user header is only a stand-in for authentication; a real application would verify a session or a token, as shown on Authentication and Authorization. A header that the client sends must never decide who a user is.

Express 4 versus Express 5

Topic Express 4 Express 5

DELETE helper

app.del() (deprecated alias)

Removed; use app.delete().

Bare wildcard

app.all('*', ...), app.get('/files/*', ...)

Invalid; use /*splat (the value in req.params.splat is an array) or app.use().

Optional parameter

/books/:id?

/books{/:id} (braces); see Route Paths and Parameters.

Async handlers

A rejected promise had to be caught and forwarded

Rejections and thrown errors reach next(err) automatically.

app.param(fn), app.param(':id')

Allowed

Removed.

req.params

An ordinary object

An object with a null prototype; unmatched optional parameters are absent.

res.send(status), res.json(obj, status), res.redirect(url, status)

Deprecated forms

Removed; set the status first with res.status().

QUERY routes

Not available

app.query() from 5.3, on a Node.js line that supports the verb.

The full migration, with the codemods that rewrite most of these, is on Migrating and What Changed.

What changed recently

  • Express 5.3.0 (2026-10-09) added app.query() for the HTTP QUERY method and fixed req.fresh for it, together with dependency security updates.

  • Express 5.1.0 (2025-03-31) made Express 5 the default latest release on npm; routes written for 5.x use path-to-regexp 8 syntax.

  • Express 4.22.3 (2026-09-14) is the latest 4.x. Version 4 keeps receiving fixes, but it has neither app.query() nor automatic forwarding of rejected promises.

Where the books are outdated

The requester-provided books that this section draws on were all written for Express 4. Where routing is concerned, their samples need the following adjustments for Express 5.

In the books Today

Routes registered with app.get() and the other verbs, with var, CommonJS and callbacks

The verbs are unchanged. Use ESM, const and async functions; Express 5 forwards their errors.

app.del() for DELETE routes

Removed in Express 5: use app.delete().

app.all('*', ...) and app.get('/api/*', ...) as catch-alls

Invalid path syntax in Express 5: use /*splat, /api/{*splat} or app.use().

Optional parameters such as /:id? and pattern characters (question mark, plus sign, asterisk) inside a string path

Rewritten with braces and named wildcards.

Chained routes with router.route() (Monteiro 2014)

Still supported and unchanged; app.route() is its application-level twin.

res.send(404), res.json(obj, status) and other status-as-argument forms

Removed: use res.status(404).json(...).

bodyParser() as application middleware before the routes

Removed from Express long ago: use express.json() and express.urlencoded().