Express.js for REST APIs: Routing, Middleware Order, Error Handling and What Changed in Express 5

Key takeaways

Express is a thin layer over Node's HTTP server: a router plus an ordered chain of middleware functions. Once you understand how that chain runs — and what Express 5 changed about async errors and route paths — most Express bugs (hanging requests, 'headers already sent', errors that crash the process) stop being mysterious.

Introduction

Express is a small framework. It gives you three things on top of Node’s http module: a router that maps method and path to a handler, an ordered chain of middleware functions that every request passes through, and helpers on req and res (res.json(), req.params, res.status()). Everything else — body parsing, CORS, authentication, validation, logging — is middleware you add.

That minimalism is why Express is still the most common starting point for Node.js APIs, and also why Express apps go wrong in characteristic ways. There is no built-in validation, no structure, and the order in which you call app.use() is the program. This guide covers the chain model first, because nearly every confusing Express bug is a chain bug.

This article targets Express 5, which has been the latest version on npm since 2025. Differences from Express 4 are called out where they matter.


Install and a first server

npm init -y
npm install express
// app.js
const express = require('express');
const app = express();

app.use(express.json());   // parse JSON bodies into req.body

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

const PORT = process.env.PORT || 3000;
app.listen(PORT, () => console.log(`Listening on :${PORT}`));

Run it with node --watch app.js (Node 18.11+) to restart on file changes; nodemon is no longer necessary for that.


How the middleware chain works

Every app.use(), app.get(), router.post() and so on appends a layer to one list. For each request, Express walks the list from the top and runs each layer whose method and path match. A middleware function receives (req, res, next) and must do exactly one of two things:

  • End the response (res.json(), res.send(), res.end(), res.redirect()), or
  • Call next() to pass control to the next matching layer.
app.use((req, res, next) => {             // 1. runs for every request
    req.startedAt = Date.now();
    next();
});

app.use('/api', (req, res, next) => {     // 2. only for paths starting with /api
    if (!req.headers.authorization) {
        return res.status(401).json({ error: 'Missing token' });   // ends here
    }
    next();
});

app.get('/api/users', (req, res) => {     // 3. the route handler
    res.json({ users: [] });
});

app.use((req, res) => {                   // 4. nothing above matched: 404
    res.status(404).json({ error: 'Not found' });
});

app.use((err, req, res, next) => {        // 5. error handler: four arguments
    console.error(err);
    res.status(500).json({ error: 'Internal Server Error' });
});

Three rules fall out of this model, and each corresponds to a bug I have debugged more than once:

Order is behavior. express.json() registered after a route means that route sees req.body === undefined. An auth middleware registered after express.static() does not protect static files. A 404 handler placed above your routes answers every request with 404.

Doing neither hangs the request. If a middleware forgets to call next() on one branch, the client waits until its timeout, and nothing is logged, because from Express’s point of view nothing went wrong. When a request hangs with no error, look for a middleware with an if that does not end in next() or a response on every path.

Doing both crashes. Sending a response and then continuing — typically a missing return before res.status(404).json(...) — leads to a second res.json() call and Error [ERR_HTTP_HEADERS_SENT]: Cannot set headers after they are sent to the client. The client got the first response, so this often shows up only in logs. Write return res... for early exits, always.

Error-handling middleware is recognized purely by having four parameters. (err, req, res, next) is an error handler; (req, res, next) is not, even if you name the first argument err. Calling next(err) with any argument skips all normal middleware and jumps to the next error handler.


Routing

app.get('/users', listUsers);
app.post('/users', createUser);
app.get('/users/:id', getUser);
app.patch('/users/:id', updateUser);
app.delete('/users/:id', deleteUser);

app.get('/users/:userId/posts/:postId', (req, res) => {
    res.json(req.params);   // { userId: '7', postId: '42' } — always strings
});

app.get('/search', (req, res) => {
    const { q = '', page = '1' } = req.query;   // also strings
    res.json({ q, page: Number(page) });
});

Route and query parameters are always strings (or, for repeated query keys, arrays of strings). req.params.id === 7 is always false. Convert and validate them explicitly; see the validation section below.

Routers

// routes/users.js
const express = require('express');
const router = express.Router();

router.get('/', listUsers);
router.get('/:id', getUser);
router.post('/', createUser);

module.exports = router;
// app.js
app.use('/api/users', require('./routes/users'));

A router is a mini-app with its own middleware chain, mounted at a prefix. Inside it, paths are relative to the mount point, and req.baseUrl holds the prefix. Middleware added with router.use() only runs for requests that reach that router, which is the clean way to apply auth to one group of routes.

Route path syntax changed in Express 5

Express 5 uses path-to-regexp v8, which removed the loose pattern syntax Express 4 accepted:

// Express 4 style — throws at startup in Express 5:
// "Missing parameter name at index 6: /old/*; visit https://git.new/pathToRegexpError for info"
app.get('/old/*', handler);

// Express 5: wildcards must be named, and match one or more segments
app.get('/files/*splat', (req, res) => {
    res.json(req.params);   // GET /files/a/b.txt -> { splat: ['a', 'b.txt'] }
});

// Optional segments use braces instead of '?'
app.get('/posts{/:id}', handler);   // matches /posts and /posts/42

Regular-expression characters inside string paths are no longer supported either; pass an actual RegExp if you need one. The error is thrown when the route is registered, so a missed wildcard fails at startup rather than silently matching nothing — the upgrade is noisy but safe.


Built-in and common middleware

app.use(express.json({ limit: '100kb' }));          // JSON bodies (default limit 100kb)
app.use(express.urlencoded({ extended: false }));    // HTML form bodies
app.use(express.static('public', { maxAge: '1d' })); // static files

express.json() only parses requests whose Content-Type is application/json. A client posting JSON as text/plain — fetch does this when you pass a string body without setting a header — gets req.body === undefined. In Express 5 the value is undefined rather than {} when no parser ran, so req.body.name throws a TypeError instead of quietly being undefined, which at least points at the problem.

The body size limit matters: without it, a client can send a very large JSON payload that your process must buffer and parse on the event loop. The default of 100 KB is reasonable for most APIs; raise it per route where you genuinely need more.

Widely used third-party middleware:

npm install helmet cors morgan express-rate-limit
const helmet = require('helmet');
const cors = require('cors');
const morgan = require('morgan');
const { rateLimit } = require('express-rate-limit');

app.use(helmet());   // security headers (CSP, HSTS, X-Content-Type-Options, ...)
app.use(cors({ origin: ['https://app.example.com'], credentials: true }));
app.use(morgan('combined'));
app.use('/api/auth', rateLimit({ windowMs: 15 * 60 * 1000, limit: 20 }));

cors() with no options allows every origin. That is fine for a public read-only API, but combined with cookie-based auth it is how cross-site request problems start; list the origins you actually serve. And express-rate-limit stores counters in process memory by default, so with several instances or cluster workers each one has its own counter — use a shared store such as Redis when you scale out.


A small REST API with validation

const express = require('express');
const app = express();
app.use(express.json());

const users = new Map([[1, { id: 1, name: 'Alice', email: '[email protected]' }]]);
let nextId = 2;

function parseId(req, res, next) {
    const id = Number(req.params.id);
    if (!Number.isInteger(id) || id <= 0) {
        return res.status(400).json({ error: 'id must be a positive integer' });
    }
    req.userId = id;
    next();
}

function validateUser(req, res, next) {
    const { name, email } = req.body ?? {};
    const errors = [];
    if (typeof name !== 'string' || name.trim() === '') errors.push('name is required');
    if (typeof email !== 'string' || !email.includes('@')) errors.push('email is invalid');
    if (errors.length) return res.status(400).json({ errors });
    req.valid = { name: name.trim(), email };
    next();
}

app.get('/api/users', (req, res) => {
    const limit = Math.min(Number(req.query.limit) || 20, 100);
    res.json({ users: [...users.values()].slice(0, limit) });
});

app.get('/api/users/:id', parseId, (req, res) => {
    const user = users.get(req.userId);
    if (!user) return res.status(404).json({ error: 'User not found' });
    res.json(user);
});

app.post('/api/users', validateUser, (req, res) => {
    const user = { id: nextId++, ...req.valid };
    users.set(user.id, user);
    res.status(201).location(`/api/users/${user.id}`).json(user);
});

app.delete('/api/users/:id', parseId, (req, res) => {
    if (!users.delete(req.userId)) return res.status(404).json({ error: 'User not found' });
    res.status(204).end();
});

A few choices in this example are deliberate:

  • Validation is middleware, so handlers only see data that has already been checked. For real schemas, a library such as Zod or Joi replaces the hand-written checks, and the pattern stays the same: parse req.body into a validated object, reject with 400 otherwise.
  • Copy only the fields you expect (req.valid) instead of spreading req.body into your model. Spreading the whole body is how clients end up setting fields like isAdmin or id that were never meant to be writable — the “mass assignment” bug.
  • Clamp limit. An unbounded ?limit= lets a client ask for your entire table in one request.
  • Status codes carry meaning: 201 with a Location header for creation, 204 with no body for deletion, 400 for bad input, 404 for a missing resource. Clients and API tooling rely on them.

Error handling, and the biggest Express 5 change

In Express 4, only synchronous errors thrown inside a handler reach your error middleware. An async handler that throws or rejects produces an unhandled promise rejection instead, which since Node 15 crashes the process by default:

// Express 4: this rejection never reaches the error handler
app.get('/users/:id', async (req, res) => {
    const user = await db.findUser(req.params.id);   // throws on DB error
    res.json(user);
});

The standard Express 4 workaround was a wrapper that forwards rejections to next:

const asyncHandler = (fn) => (req, res, next) =>
    Promise.resolve(fn(req, res, next)).catch(next);

app.get('/users/:id', asyncHandler(async (req, res) => { /* ... */ }));

Express 5 does this for you. If a handler or middleware returns a rejected promise, Express calls next(err) with the rejection reason. The same route that crashed a process on Express 4 produces a normal 500 through your error handler on Express 5. That alone is a good reason to upgrade, and it removes the most common source of “the server died overnight” in Express apps. It only applies to the promise the handler returns — a promise you start and forget inside the handler (a callback-style API, a setTimeout, an un-awaited call) is still your responsibility.

A central error handler

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

app.get('/api/orders/:id', async (req, res) => {
    const order = await db.findOrder(req.params.id);
    if (!order) throw new HttpError(404, 'Order not found');
    res.json(order);
});

// Registered last, after all routes
app.use((err, req, res, next) => {
    if (res.headersSent) return next(err);   // let Express close the connection

    const status = err.status ?? err.statusCode ?? 500;
    if (status >= 500) console.error(err);   // log unexpected errors with stack

    res.status(status).json({
        error: status >= 500 ? 'Internal Server Error' : err.message,
    });
});

Two details worth keeping:

  • Do not send internal error messages to clients for 5xx errors. Database errors often include table names, query fragments or connection strings. Log the full error server-side and return a generic message.
  • Check res.headersSent. If the error happened while streaming a response, you cannot send a new status; delegating to Express’s default handler closes the connection properly.

express.json() also reports malformed JSON through this handler, as an error with status: 400 and type: 'entity.parse.failed', so the handler above correctly returns 400 for a syntax error in the request body.


Request and response essentials

Property / methodWhat it gives you
req.paramsRoute parameters (strings)
req.queryParsed query string. Express 5 uses the simple parser by default: ?a[b]=1 gives { 'a[b]': '1' }, not a nested object
req.bodyParsed body, or undefined if no parser matched
req.get('Header')Case-insensitive header lookup
req.ipClient IP; behind a proxy, correct only with trust proxy set
res.status(code)Sets the status; Express 5 throws on invalid codes
res.json(obj)Serializes and sends with Content-Type: application/json
res.sendFile(path)Streams a file; requires an absolute path or a root option
res.redirect(url)302 by default; pass 301/307/308 explicitly

The query parser change is one of the quieter Express 5 breaking changes. Code that relied on qs-style nested parsing (?filter[status]=active) receives flat keys after the upgrade. You can restore it with app.set('query parser', 'extended'), but the simple parser is also safer, because deeply nested query objects are a common vector for injection into database query builders.


File uploads

const multer = require('multer');
const upload = multer({
    dest: 'uploads/',
    limits: { fileSize: 5 * 1024 * 1024, files: 1 },
    fileFilter: (req, file, cb) => cb(null, ['image/png', 'image/jpeg'].includes(file.mimetype)),
});

app.post('/api/avatar', upload.single('avatar'), (req, res) => {
    if (!req.file) return res.status(400).json({ error: 'PNG or JPEG required' });
    res.status(201).json({ stored: req.file.filename });
});

Always set limits; without them, a client can upload until your disk fills. The mimetype comes from the client and can be anything, so treat it as a hint: if the file type matters for security, check the file’s content, and never serve uploaded files from a path where they could be executed or rendered as HTML on your domain. For anything beyond small files, uploading directly to object storage with a pre-signed URL keeps the bytes off your Node process entirely.


TypeScript

npm install -D typescript @types/express @types/node
import express, { Request, Response, NextFunction } from 'express';

interface CreateUserBody { name: string; email: string }

const app = express();
app.use(express.json());

app.post('/api/users', (req: Request<{}, unknown, CreateUserBody>, res: Response) => {
    const { name, email } = req.body;
    res.status(201).json({ name, email });
});

app.use((err: Error, req: Request, res: Response, next: NextFunction) => {
    res.status(500).json({ error: 'Internal Server Error' });
});

The generic parameter on Request is a type assertion about the input, not validation: TypeScript trusts that req.body matches CreateUserBody, and nothing checks it at runtime. That false sense of safety is common in TypeScript Express code. Pair the types with a runtime schema (Zod’s z.infer gives you the type from the same schema) so the compiler and the actual request agree.


Production settings

app.set('trust proxy', 1);        // behind one reverse proxy / load balancer
app.disable('x-powered-by');      // helmet() also removes it

const server = app.listen(PORT);
server.keepAliveTimeout = 65_000; // longer than the load balancer's idle timeout
server.headersTimeout = 66_000;

process.on('SIGTERM', () => {
    server.close(() => process.exit(0));   // stop accepting, finish in-flight requests
});
  • trust proxy: behind nginx or a cloud load balancer, every request comes from the proxy’s IP. Without this setting, req.ip is the proxy, rate limiting treats all users as one client, and req.secure is false even on HTTPS. Set it to the number of proxies in front of you rather than true, because true trusts any X-Forwarded-For value a client sends, which lets them spoof their IP.
  • Keep-alive timeouts: Node’s default keep-alive timeout is 5 seconds, shorter than common load balancer idle timeouts (60 seconds on AWS ALB). The mismatch causes sporadic 502 errors; see the Node.js performance guide for the details.
  • Graceful shutdown: containers and process managers send SIGTERM on deploy. Closing the server lets in-flight requests finish instead of being cut off mid-response.
  • NODE_ENV=production: Express uses it to enable view caching and to hide stack traces in its default error handler.

Upgrading from Express 4 to 5

Express 4Express 5
Async errors need a wrapperRejected promises go to next(err) automatically
app.get('/*', ...)app.get('/*splat', ...) (named wildcard)
'/posts/:id?''/posts{/:id}'
req.body is {} without a parserreq.body is undefined
Extended (qs) query parserSimple query parser by default
res.send(200), app.del(), req.param()Removed: use res.sendStatus(), app.delete(), req.params/req.query
Node 0.10+Node 18+

Most of the migration is mechanical, and the path changes fail loudly at startup. The two that can change behavior silently are the query parser and req.body being undefined; search for nested query access and for code that assumes req.body is always an object.


Next in the series