Free Ebook cover Express.js Beyond Basics: Middleware, Architecture, and Maintainability

Express.js Beyond Basics: Middleware, Architecture, and Maintainability

New course

13 pages

Middleware Design in Express.js: Composition, Order, and Reuse

Capítulo 2

Estimated reading time: 10 minutes

+ Exercise

Middleware as a Pipeline (and Why Order Is a Feature)

In Express.js, middleware forms a pipeline: each function can read/modify req and res, then either pass control forward with next() or end the request by sending a response. The key idea is that middleware runs in a specific order, and that order is part of your application’s behavior.

Think of the request lifecycle as a sequence:

  • Global middleware (applies to all requests that reach it)
  • Router-level middleware (applies to a subset of routes mounted under a path)
  • Route-specific middleware (applies to one route handler)
  • Error middleware (runs only when an error is passed to next(err) or thrown in an async handler)

Express executes middleware in registration order. A later middleware will not run if an earlier middleware ends the response or never calls next().

Ordering Rules You Should Rely On

  • Top-to-bottom registration: app.use() and router.use() are executed in the order they are declared.
  • Path scoping: app.use('/api', router) only runs for paths starting with /api.
  • Error middleware signature: error handlers must be (err, req, res, next); they are skipped during normal flow.
  • First response wins: once you call res.send()/res.json()/res.end(), you must not attempt to send another response.

Categories of Middleware

1) Global Middleware

Global middleware is registered on the app instance and affects all downstream routes. Use it for cross-cutting concerns like request IDs, logging, security headers, parsing, and setting shared context.

import express from 'express';
import crypto from 'crypto';

const app = express();

// Global: attach a request id and expose it to downstream middleware
app.use((req, res, next) => {
  const requestId = crypto.randomUUID();
  res.locals.requestId = requestId;
  res.setHeader('x-request-id', requestId);
  next();
});

// Global: JSON parsing (must run before handlers that read req.body)
app.use(express.json());

Step-by-step ordering check:

Continue in our app.

You can listen to the audiobook with the screen off, receive a free certificate for this course, and also have access to 5,000 other free online courses.

Or continue reading below...
Download App

Download the app

  • If a route needs req.body, ensure express.json() is registered before the router/route.
  • If you want every response to have a header, set it in global middleware before routes send responses.
  • If you want to log response status, attach a listener like res.on('finish') in global middleware (no need to wrap every route).

2) Router-Level Middleware

Router-level middleware is scoped to a router. This is ideal for “all endpoints under /api/admin require auth” or “all endpoints under /api require JSON”.

import { Router } from 'express';

const adminRouter = Router();

adminRouter.use((req, res, next) => {
  // Example: require an admin flag set by earlier auth middleware
  if (!req.user?.isAdmin) return res.status(403).json({ error: 'forbidden' });
  next();
});

adminRouter.get('/stats', (req, res) => {
  res.json({ ok: true, requestId: res.locals.requestId });
});

app.use('/admin', adminRouter);

Router-level middleware helps keep route files clean and reduces repetition, while still keeping ordering explicit: middleware declared on the router runs before that router’s routes.

3) Route-Specific Middleware

Route-specific middleware is attached directly to a route. Use it when only one endpoint needs a check or transformation (e.g., validate a payload for one route).

app.post(
  '/reports',
  (req, res, next) => {
    if (!req.body?.title) return res.status(400).json({ error: 'title required' });
    next();
  },
  (req, res) => {
    res.status(201).json({ id: 'rpt_123', title: req.body.title });
  }
);

Because middleware is composable, you can extract that inline function into a reusable module and share it across routes.

4) Error Middleware

Error middleware centralizes error-to-response mapping. It runs when you call next(err) or when an async handler throws/rejects (if you wrap it appropriately).

// Error middleware must have 4 args
app.use((err, req, res, next) => {
  const requestId = res.locals.requestId;

  // Avoid leaking internal details
  const status = err.statusCode || 500;
  const message = status >= 500 ? 'internal_error' : err.message;

  // If headers already sent, delegate to Express default handler
  if (res.headersSent) return next(err);

  res.status(status).json({ error: message, requestId });
});

Ordering requirement: error middleware should be registered after all routes, otherwise it won’t catch errors from routes declared later.

Designing Composable Middleware

Clear Contracts: Inputs, Outputs, and Responsibilities

Composable middleware is easiest to reuse when it has a clear contract:

  • Reads: which fields it expects on req, res.locals, headers, etc.
  • Writes: what it adds/changes (e.g., res.locals.user, req.user).
  • Control flow: when it calls next(), when it ends the response, and what errors it passes to next(err).

Keep middleware small: one responsibility, minimal side effects, and no hidden dependencies on global variables.

Pure-ish Middleware: Minimize Side Effects

Middleware can’t be fully pure (it interacts with I/O), but you can keep it “pure-ish” by:

  • Deriving values from req and writing them to res.locals (or a typed req field) without performing unrelated actions.
  • Injecting dependencies (e.g., a service) via a factory rather than importing singletons everywhere.
// Dependency-injected middleware factory
export function makeLoadUser({ userService }) {
  return async function loadUser(req, res, next) {
    try {
      const token = req.headers.authorization?.replace('Bearer ', '');
      if (!token) return next();

      const user = await userService.findByToken(token);
      if (user) res.locals.user = user;
      next();
    } catch (err) {
      next(err);
    }
  };
}

This middleware’s contract is explicit: it may set res.locals.user, and it never sends a response itself.

Parameterized Middleware Factories

A middleware factory is a function that returns middleware. This is the standard way to make reusable, configurable middleware without copy/paste.

Example: Require a Role

export function requireRole(role) {
  return function requireRoleMiddleware(req, res, next) {
    const user = res.locals.user;
    if (!user) return res.status(401).json({ error: 'unauthorized' });
    if (user.role !== role) return res.status(403).json({ error: 'forbidden' });
    next();
  };
}

// Usage
app.get('/billing', requireRole('billing_admin'), (req, res) => {
  res.json({ ok: true });
});

Step-by-step:

  • Ensure an upstream middleware sets res.locals.user (or req.user).
  • Use return res.status(...) to stop execution after sending the response.
  • Call next() only when the request should continue.

Example: Validation Middleware Factory

export function validateBody(requiredFields) {
  return function validateBodyMiddleware(req, res, next) {
    const missing = requiredFields.filter((f) => req.body?.[f] == null);
    if (missing.length) {
      return res.status(400).json({ error: 'missing_fields', fields: missing });
    }
    next();
  };
}

app.post('/projects', validateBody(['name']), (req, res) => {
  res.status(201).json({ id: 'p1', name: req.body.name });
});

Conditional Middleware (Run Only When Needed)

Sometimes you want middleware to run only under certain conditions (feature flags, content type, route patterns, environment). You can implement conditional middleware as a small wrapper.

export function when(predicate, middleware) {
  return function conditional(req, res, next) {
    if (!predicate(req, res)) return next();
    return middleware(req, res, next);
  };
}

const requireJson = (req) => req.is('application/json');

app.post(
  '/events',
  when(requireJson, express.json()),
  (req, res) => res.json({ received: true })
);

Tip: conditional wrappers should always forward next correctly and should not swallow errors. If the wrapped middleware is async and throws, ensure it’s handled (see async pitfalls below).

Sharing Context Safely

Using res.locals for Request-Scoped Data

res.locals is a request-scoped object intended for passing data between middleware and handlers. It avoids global state and makes dependencies explicit.

// Attach context early
app.use((req, res, next) => {
  res.locals.startTime = Date.now();
  next();
});

// Use it later
app.use((req, res, next) => {
  res.on('finish', () => {
    const ms = Date.now() - res.locals.startTime;
    // log ms, requestId, statusCode, etc.
  });
  next();
});

Contract pattern: document in code comments or module docs what keys your middleware sets on res.locals (e.g., user, requestId, startTime).

Attaching Typed Metadata to req (TypeScript Pattern)

In TypeScript, it’s common to attach metadata like req.user or req.context. Do it with module augmentation so downstream code is type-safe and the contract is visible.

// types/express.d.ts
import 'express-serve-static-core';

declare module 'express-serve-static-core' {
  interface Request {
    user?: { id: string; role: string };
    requestId?: string;
  }
}
// middleware/requestId.ts
import crypto from 'crypto';

export function requestId(req, res, next) {
  req.requestId = crypto.randomUUID();
  res.setHeader('x-request-id', req.requestId);
  next();
}
// usage
app.use(requestId);
app.get('/me', (req, res) => {
  res.json({ userId: req.user?.id, requestId: req.requestId });
});

Guideline: prefer either res.locals or req for a given piece of metadata, not both, to avoid confusion. Use res.locals when the data is primarily for response/templating/logging; use req when it’s part of request identity/authorization and used broadly.

Common Pitfalls and How to Avoid Them

Pitfall: Multiple Responses (“Cannot set headers after they are sent”)

This happens when code sends a response and then continues executing, or when both a middleware and a handler send responses.

// Buggy: missing return
app.get('/x', (req, res, next) => {
  if (!req.query.id) res.status(400).json({ error: 'id required' });
  next(); // still runs, may send again
});

Fix by returning after sending, or by using else:

app.get('/x', (req, res, next) => {
  if (!req.query.id) return res.status(400).json({ error: 'id required' });
  next();
});

Pitfall: Forgotten return in Async Middleware

In async middleware, forgetting to return after sending a response can lead to subsequent code running and throwing, which then triggers error middleware after a response was already sent.

app.post('/y', async (req, res, next) => {
  try {
    if (!req.body) return res.status(400).json({ error: 'body required' });
    // ... more awaits
    res.json({ ok: true });
  } catch (e) {
    next(e);
  }
});

Notice the consistent return when responding early.

Pitfall: Hidden Dependencies Between Middleware

A route that assumes res.locals.user exists will break if the “load user” middleware isn’t mounted (or is mounted after the route). Make dependencies explicit by composing middleware arrays and exporting them together.

// authPipeline.ts
import { makeLoadUser } from './loadUser.js';
import { requireRole } from './requireRole.js';

export function makeAdminPipeline({ userService }) {
  return [
    makeLoadUser({ userService }),
    requireRole('admin')
  ];
}

// usage
app.get('/admin/audit', ...makeAdminPipeline({ userService }), (req, res) => {
  res.json({ ok: true });
});

This pattern makes ordering and dependencies visible at the call site.

Pitfall: Not Handling Async Errors

If an async middleware throws and you don’t catch it, Express may not route it to your error handler depending on your version and patterns. A common approach is to wrap async handlers so rejections go to next.

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

app.get('/data', asyncHandler(async (req, res) => {
  const data = await loadData();
  res.json(data);
}));

Pitfall: Calling next() After Sending a Response

Calling next() after res.send() often triggers downstream middleware that may attempt to write headers/body again.

// Avoid this pattern
app.use((req, res, next) => {
  res.status(404).json({ error: 'not_found' });
  // next(); // don't
});

If you need centralized 404 handling, place it after all routes and do not call next() unless you intend to delegate to an error handler (typically not needed for 404).

Composition Patterns You Can Standardize

Middleware Arrays for Readability

Express accepts multiple middleware functions per route. Group them into arrays to make the pipeline explicit and reusable.

const createProjectPipeline = [
  express.json(),
  validateBody(['name']),
  requireRole('project_admin')
];

app.post('/projects', ...createProjectPipeline, (req, res) => {
  res.status(201).json({ id: 'p1', name: req.body.name });
});

“Enrichment then Use” Pattern

A reliable approach is to separate middleware that enriches the request (adds context) from middleware/handlers that uses that context.

StageExampleWrites
EnrichmentrequestId, loadUserreq.requestId, res.locals.user
PolicyrequireRole, rateLimitmay respond with 401/403/429
Business handlercreateProjectsends success response
Error mappingerror middlewaresends error response

This reduces hidden dependencies and clarifies where side effects (responses) are allowed.

Now answer the exercise about the content:

In an Express.js app, a route depends on req.body being populated. Which middleware setup best ensures this works reliably and avoids order-related issues?

You are right! Congratulations, now go to the next page

You missed! Try again.

Middleware runs top-to-bottom in registration order. If a handler reads req.body, body parsing like express.json() must be mounted before that router/route; otherwise req.body may be undefined.

Next chapter

Routing Architecture: Modular Routers and Clear Boundaries

Arrow Right Icon
Download the app to earn free Certification and listen to the courses in the background, even with the screen off.