Middleware in Express: From Basics to Real-World Use

I explain how Express middleware works — the req/res/next signature, execution order, built-in and third-party options, and real patterns for auth and logging.

Middleware in Express: From Basics to Real-World Use

If you’ve built anything with Express, you’ve seen (req, res, next) hundreds of times. You probably copied the pattern first, dropped a next() call somewhere in the middle, and moved on. That works — until a request hangs silently, an error skips your handler entirely, or middleware runs in the wrong order with no obvious reason why.

The signature looks simple. What it does under the hood is the part most tutorials skip.

I’m going to walk through the middleware model from the signature up — what each parameter actually gives you, why registration order matters more than most docs let on, and how the error-handling variant differs from everything else. By the end, you’ll be able to write middleware that does exactly what you intend and debug it when it doesn’t.

Series: Part 3 of 4 in the Express series.

On this page

What the three-parameter signature gives you

According to the Express documentation, every middleware function receives three things: the request object (req), the response object (res), and the next function in the application’s request-response cycle. Each has a distinct job.

req is the incoming HTTP request. It carries the URL, method, headers, query params, parsed body, and route parameters. When you access req.body or req.params.id, that data came from req.

res is the outgoing response. You send data back to the client through it — res.json(), res.status(404).send('Not found'), res.redirect(). Once you call a method that writes the response, the request-response cycle is over.

next is not part of Node.js or the HTTP spec — it’s a function Express injects specifically to pass control forward. Calling next() moves the request to the next middleware in the chain. Not calling it leaves the request hanging indefinitely with no timeout.

Here’s a minimal middleware function that logs the incoming method and URL, then passes the request forward:

function requestLogger(req, res, next) {
  console.log(`${req.method} ${req.url}`);
  next();
}

app.use(requestLogger);

Middleware can also modify req and res before passing them on. That’s exactly how express.json() works internally — it reads the raw request body, parses it, attaches the result to req.body, then calls next(). Every downstream middleware and route handler sees the parsed body without knowing how it got there.

Registration order is execution order

Express runs middleware in the order you register it. This sounds obvious, but it causes real bugs when you don’t think through the sequence upfront.

app.use(express.json());    // 1. parse JSON bodies
app.use(requestLogger);     // 2. log the request
app.use(authMiddleware);    // 3. verify the token

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

app.use(errorHandler);      // 5. error handler always last

If authMiddleware runs before express.json(), req.body is undefined when your auth logic reads the token from the body. If errorHandler is registered before your routes, errors thrown in those routes never reach it. The order isn’t just convention — it’s the architecture.

A middleware registered without a path runs for every request:

app.use(requestLogger); // logs every request to every route

One registered with a path prefix runs only for matching routes:

app.use('/api', rateLimiter); // only for /api/* requests

Before you continue: If you register a logger after a route handler that calls res.json(), will the logger ever run for that route?

It won’t. Once res.json() sends the response, the request-response cycle is done. Any middleware registered after that point in the stack is never reached for that request. The request travels down the stack in registration order and stops as soon as something ends the cycle.

Built-in middleware you already have

Express ships with five built-in middleware functions. Three appear in nearly every project:

express.json() parses incoming requests with a JSON body and attaches the result to req.body. It was added in Express 4.16.0, replacing the external body-parser package for most JSON use cases:

app.use(express.json());

app.post('/orders', (req, res) => {
  const { item, quantity } = req.body; // available here after parsing
  res.status(201).json({ created: true });
});

express.urlencoded({ extended: true }) handles HTML form submissions where the content type is application/x-www-form-urlencoded. Pass extended: true to support nested objects; extended: false for flat key-value pairs.

express.static('public') serves files from a directory directly. A request for /styles.css maps to public/styles.css without any route handler needed.

Third-party middleware for common needs

The Express ecosystem covers most recurring infrastructure needs.

Request logging with morgan. Morgan is the standard HTTP request logger for Express apps. The 'dev' format gives colour-coded output with method, URL, status code, and response time — useful during development:

import morgan from 'morgan';

app.use(morgan('dev'));
// GET /api/users 200 12.443 ms - 348

For production, 'combined' outputs the Apache combined log format with remote IP, user agent, and referrer — useful when shipping logs to an aggregation service.

Security headers with helmet. Helmet sets 13 HTTP response headers with a single call. These include Content-Security-Policy, Strict-Transport-Security, X-Content-Type-Options, and others that guard against common web vulnerabilities:

import helmet from 'helmet';

app.use(helmet());

Out of the box, helmet() uses secure defaults for all 13 headers. Individual headers can be disabled or reconfigured when your app needs different values — for example, loosening Content-Security-Policy to allow an external script.

CORS control. If your API handles requests from a different origin (a frontend on another domain), you need the cors package:

import cors from 'cors';

app.use(cors({ origin: 'https://yourdomain.com' }));

Without it, browsers block cross-origin requests at the preflight stage. The API call silently fails on the client side with no response body to inspect.

Check this before moving on

  • express.json() is registered before any route that reads req.body
  • helmet() runs before any response-sending middleware so headers are set on every response
  • morgan() is near the top of the stack so it logs all requests, including ones rejected by auth middleware

Writing your own middleware

Custom middleware is any function with the (req, res, next) signature. Two patterns appear often in real apps.

Attaching data to the request. Middleware can add properties to req that downstream handlers use. A request ID makes it easier to trace logs across services:

import { randomUUID } from 'node:crypto';

function attachRequestId(req, res, next) {
  const id = randomUUID();
  req.id = id;
  res.setHeader('X-Request-Id', id);
  next();
}

app.use(attachRequestId);

Every route handler and downstream middleware can now read req.id without knowing how it was generated.

Blocking requests that don’t meet a condition. An auth guard checks for a valid token and returns early if it’s missing:

function requireAuth(req, res, next) {
  const token = req.headers.authorization?.split(' ')[1];

  if (!token || !isValidToken(token)) {
    return res.status(401).json({ error: 'Unauthorized' });
  }

  next();
}

// apply only to protected routes
app.use('/api/admin', requireAuth);

The return before res.status(401) is deliberate. It stops the function after sending the response. Without it, next() could still be called after the response is written, causing an error about headers already being sent.

Error-handling middleware needs four parameters

Here’s the part that surprises most people: Express identifies error-handling middleware purely by the number of parameters in the function signature.

An error handler must define exactly four parameters — (err, req, res, next) — in that order. If you write a three-parameter function, Express treats it as regular middleware and ignores it during error propagation. The fourth parameter doesn’t even need to be used — it just needs to be declared.

// four parameters — Express recognises this as an error handler
app.use((err, req, res, next) => {
  console.error(err.stack);

  if (res.headersSent) {
    return next(err); // delegate to Express's default handler
  }

  res.status(err.status ?? 500).json({
    error: err.message ?? 'Internal server error',
  });
});

Register the error handler after all routes and other middleware. If you place it before routes, it never receives errors from those routes.

To trigger it from a route, pass the error to next:

app.get('/report', async (req, res, next) => {
  try {
    const data = await generateReport();
    res.json(data);
  } catch (err) {
    next(err); // skip to the error handler
  }
});

In Express 5, async route handlers automatically forward rejected promises to the error handler, so the try/catch wrapper is no longer needed. In Express 4, it’s still required — an unhandled rejection in a route handler crashes the process without reaching your error middleware.

Where things go wrong

A few mistakes appear consistently in Express codebases.

Forgetting next(). A middleware that neither sends a response nor calls next() leaves the request open indefinitely. There’s no built-in timeout. The client just waits.

Three-parameter error handlers. This one is easy to miss. You write a function that checks err, add it with app.use(), and nothing works. Express looked at the function, counted three parameters, and registered it as regular middleware. Add the fourth parameter — even if it’s just _next — and it works.

Error handler placed before routes. If your error handler is registered at line 10 and your routes start at line 20, errors from those routes flow past the error handler without being caught. Put the error handler at the very end.

Calling both next() and a response method. Once you call res.json() or any response-writing method, Express can’t write headers again. If next() runs afterward and reaches another handler that also tries to respond, you get a “Cannot set headers after they are sent” error. Use return to exit after sending a response.

Your app is a pipeline, not a collection of routes

Routes are one kind of middleware. The JSON parser is middleware. The error handler is middleware. Every app.use() call is a piece of the same request pipeline, running in the order you registered it.

That framing makes debugging easier. When a request behaves unexpectedly, trace it through the stack in registration order: which function ran, which called next(), which ended the response, which received an error. The pipeline is deterministic — if you know the order, you know what happened.

If you want middleware on specific routes only, pass it inline: app.get('/private', requireAuth, handler). Both the middleware and the handler are part of the same pipeline — they just start at a later point in it.

Understanding how async functions interact with the event loop helps with async middleware, especially around error forwarding in Express 4 where unhandled promise rejections don’t automatically reach the error handler. If you’re using TypeScript, the TypeScript fundamentals guide covers how to annotate req, res, and next using Express’s exported types.

Sources