Skip to main content

🔗 Custom Middleware Development

Middleware is the beating heart of Express. Every request flows through a pipeline of small functions before it ever reaches your route handler — logging, parsing, authenticating, validating. In this lesson you'll learn to read that pipeline and write your own reusable middleware for the tasks every real API needs.

🎯 Learning Objectives

By the end of this lesson, you will be able to:

  • Describe the middleware pipeline and the role of req, res, and next
  • Write purpose-specific middleware for logging, authentication, validation, and rate limiting
  • Apply middleware globally, per-path, and per-route, and organize it with routers
  • Use the middleware factory pattern to build configurable, reusable middleware
  • Handle asynchronous middleware safely and unit-test middleware in isolation

Estimated Time: 45–60 minutes  •  Difficulty: Intermediate

Hands-on: Build a request-timing logger and a configurable role-authorization middleware factory.

In This Lesson

The Middleware Pipeline

Middleware is any function that sits between the incoming request and the outgoing response. Express hands each request to your middleware functions in the order you registered them. Each function can inspect the request, modify it, end the response early, or call next() to pass control to the next function in line.

flowchart LR A[Request] --> B[express.json] B --> C[Logger] C --> D[Auth] D --> E[Validation] E --> F[Route Handler] F --> G[Response]
🏭 The assembly-line analogy. Think of middleware as an assembly line. Raw materials (the request) enter the factory. Each workstation (a middleware function) does one job — it can modify the item and pass it on, reject a defective item (send an error), or finish the product early and ship it (send a response). The final assembly is your route handler. Different products (routes) can run down different lines with different stations.

This ordered, composable design is why Express feels so flexible: complex behavior emerges from stacking simple, single-purpose functions.

Anatomy of a Middleware Function

A middleware function has a fixed signature — three parameters — and follows a predictable rhythm:

function myMiddleware(req, res, next) {
  // 1. Inspect the request
  console.log(`${req.method} ${req.url}`);

  // 2. Optionally attach data for later middleware/handlers
  req.requestTime = Date.now();

  // 3. Either end the cycle:  res.send(...)  /  res.json(...)
  //    OR hand off to the next middleware:
  next();
}

app.use(myMiddleware); // register it globally

📖 The three (or four) parameters

req — the request object (URL, headers, body, params).
res — the response object (status, headers, body you send back).
next — a function; call it to run the next middleware. Call next(err) to skip straight to error handling.
err — a fourth, leading parameter that marks error-handling middleware.

The golden rule: every middleware must either send a response or call next(). Do neither and the request hangs forever. Do both and you'll get an "headers already sent" error.

Error-handling middleware

Express recognizes error middleware purely by its arity — four parameters instead of three. It only runs when something calls next(err), and it must be registered last:

// Four parameters (err first) = error handler. Register it AFTER all routes.
function errorHandler(err, req, res, next) {
  console.error(err.stack);
  res.status(err.statusCode || 500).json({
    error: {
      message: process.env.NODE_ENV === 'production'
        ? 'An unexpected error occurred'
        : err.message,
    },
  });
}

app.use(errorHandler);

⚠️ Order is everything

  • Middleware runs top-to-bottom in registration order.
  • Once a middleware sends a response without calling next(), the chain stops.
  • next(anything) jumps to the nearest error handler, skipping normal middleware.
  • Middleware placed after your routes only runs if a route calls next().

Purpose-Specific Middleware

Let's write the middleware every real API eventually needs. Each is small, focused, and reusable.

Request logging

A logger records what came in and how long it took. Listening on the response's finish event lets you measure duration accurately:

const requestLogger = (req, res, next) => {
  const start = Date.now();
  res.on('finish', () => {
    const ms = Date.now() - start;
    console.log(`${new Date().toISOString()} ${req.method} ${req.originalUrl} → ${res.statusCode} (${ms}ms)`);
  });
  next();
};

app.use(requestLogger);

Authentication (JWT)

Auth middleware verifies a token and attaches the decoded user to req so downstream handlers know who's calling:

const jwt = require('jsonwebtoken');

const authenticate = (req, res, next) => {
  const header = req.headers.authorization;
  if (!header) {
    return res.status(401).json({ message: 'Authorization header is missing' });
  }
  const [scheme, token] = header.split(' ');
  if (scheme !== 'Bearer' || !token) {
    return res.status(401).json({ message: 'Use: Authorization: Bearer <token>' });
  }
  try {
    req.user = jwt.verify(token, process.env.JWT_SECRET);
    next();
  } catch (err) {
    const message = err.name === 'TokenExpiredError' ? 'Token has expired' : 'Invalid token';
    return res.status(401).json({ message });
  }
};

Request validation

Validation middleware rejects bad input before it reaches your business logic, returning a helpful 400:

const validateUserCreation = (req, res, next) => {
  const { name, email, password } = req.body;
  const errors = [];

  if (!name || name.length < 2) {
    errors.push({ field: 'name', message: 'Name must be at least 2 characters' });
  }
  if (!/^\S+@\S+\.\S+$/.test(email || '')) {
    errors.push({ field: 'email', message: 'Email format is invalid' });
  }
  if (!password || password.length < 8 || !/[A-Z]/.test(password) || !/[0-9]/.test(password)) {
    errors.push({ field: 'password', message: 'Password needs 8+ chars, an uppercase letter and a number' });
  }

  if (errors.length) {
    return res.status(400).json({ success: false, error: { message: 'Validation failed', details: errors } });
  }
  next();
};

app.post('/api/users', validateUserCreation, (req, res) => {
  res.status(201).json({ success: true, message: 'User created' });
});

💡 Don't reinvent the wheel

Many common needs already have battle-tested packages. Writing your own teaches you how they work — but reach for these in production:

PurposePackage
Loggingmorgan, winston, pino
Authenticationpassport, express-jwt
Validationexpress-validator, joi, zod
Rate limitingexpress-rate-limit
Security headers / CORShelmet, cors

Global, Path & Route Scoping

Middleware can apply to everything, to a path prefix, or to a single route. Choosing the right scope keeps your app efficient and secure:

const express = require('express');
const app = express();

// GLOBAL — runs for every request
app.use(express.json());
app.use(requestLogger);

// PATH-SPECIFIC — only requests starting with /api
app.use('/api', apiKeyValidator);

// ROUTE-SPECIFIC — only this endpoint, and in this order
app.get('/users/:id', authenticate, (req, res) => {
  res.json(req.user);
});

// MULTIPLE per route — they run left to right
app.post('/users', authenticate, validateUserCreation, (req, res) => {
  res.status(201).json({ created: true });
});

// ROUTER-LEVEL — applies to every route in the router
const router = express.Router();
router.use(someSharedMiddleware);
router.get('/a', handlerA);
router.get('/b', handlerB);
app.use('/feature', router);
flowchart TD A[Express App] --> B[Global middleware] B --> C[/api router/] C --> D[API-key check] C --> E[/users router/] C --> F[/products router/] E --> G[GET /users] E --> H[POST /users] F --> I[GET /products]

A clean project mirrors this tree in its file layout: a thin app.js wires global middleware and mounts routers, while each router file owns its own resource-specific middleware.

// routes/users.js
const express = require('express');
const router = express.Router();
const { authenticate, authorize } = require('../middleware/auth');
const { validateUser } = require('../middleware/validation');

router.get('/', authenticate, authorize(['admin']), userController.getAll);
router.get('/me', authenticate, userController.getCurrent);
router.post('/', validateUser, userController.create);
router.put('/:id', authenticate, validateUser, userController.update);

module.exports = router;

The Factory Pattern

Sometimes middleware needs configuration — "allow only these roles", "cache for this many seconds". You can't pass arguments to a middleware directly (Express calls it with req, res, next), so you write a factory: a function that returns a middleware, capturing the config in a closure.

// A factory that builds role-based authorization middleware
const authorize = (allowedRoles = []) => {
  const roles = Array.isArray(allowedRoles) ? allowedRoles : [allowedRoles];
  return (req, res, next) => {
    if (!req.user) {
      return res.status(401).json({ message: 'Not authenticated' });
    }
    if (roles.length && !roles.includes(req.user.role)) {
      return res.status(403).json({ message: 'Insufficient permissions' });
    }
    next();
  };
};

// Each call produces a fresh, configured middleware
app.get('/api/admin', authenticate, authorize('admin'), adminController.dashboard);
app.get('/api/reports', authenticate, authorize(['admin', 'manager']), reportController.list);

The same pattern powers configurable rate limiters, feature flags, and caches. Here's a compact in-memory rate limiter built as a factory (use express-rate-limit in production, but this shows the mechanics):

const rateLimit = ({ windowMs = 60_000, max = 100, message = 'Too many requests' } = {}) => {
  const hits = new Map(); // key → { count, resetAt }

  return (req, res, next) => {
    const key = req.ip;
    const now = Date.now();
    let entry = hits.get(key);

    if (!entry || now > entry.resetAt) {
      entry = { count: 0, resetAt: now + windowMs };
    }
    entry.count += 1;
    hits.set(key, entry);

    res.setHeader('X-RateLimit-Limit', max);
    res.setHeader('X-RateLimit-Remaining', Math.max(0, max - entry.count));

    if (entry.count > max) {
      return res.status(429).json({ success: false, error: { message } });
    }
    next();
  };
};

// Loose global limit, strict login limit
app.use(rateLimit({ windowMs: 15 * 60_000, max: 100 }));
app.post('/api/auth/login', rateLimit({ windowMs: 60 * 60_000, max: 5, message: 'Too many login attempts' }), loginHandler);

✅ Why factories win

One factory replaces a dozen near-identical copies. The configuration lives in the call site (authorize('admin')), so intent is obvious, and the closure keeps per-instance state (like the rate-limiter's map) neatly encapsulated.

Asynchronous Middleware

Middleware often does async work — a database lookup, an external API call. The danger: if a rejected promise isn't caught, older Express versions silently hang the request. There are two robust approaches.

1. try/catch with async/await

const loadUser = async (req, res, next) => {
  try {
    const user = await User.findById(req.params.id);
    if (!user) {
      const err = new Error('User not found');
      err.statusCode = 404;
      throw err;
    }
    req.user = user;
    next();
  } catch (err) {
    next(err); // hand the error to the error-handling middleware
  }
};

2. An asyncHandler wrapper (removes the boilerplate)

// One helper wraps any async handler and forwards rejections to next()
const asyncHandler = (fn) => (req, res, next) =>
  Promise.resolve(fn(req, res, next)).catch(next);

app.get('/api/users/:id', asyncHandler(async (req, res) => {
  const user = await User.findById(req.params.id);
  if (!user) {
    const err = new Error('User not found');
    err.statusCode = 404;
    throw err; // caught by asyncHandler, sent to error middleware
  }
  res.json(user);
}));

💡 Express 5 does this for you

Express 5 (now the default on npm install express) automatically catches rejected promises from async route handlers and forwards them to your error handler — so an explicit asyncHandler wrapper is no longer strictly required. It's still handy on Express 4 codebases and makes the intent explicit, so you'll see it everywhere.

Testing Middleware

Because middleware is just a function of (req, res, next), it's easy to unit-test with fakes. Here's an auth middleware tested with Jest — no server required:

const jwt = require('jsonwebtoken');
const authenticate = require('../middleware/authenticate');

jest.mock('jsonwebtoken');

describe('authenticate middleware', () => {
  let req, res, next;

  beforeEach(() => {
    req = { headers: {} };
    res = { status: jest.fn().mockReturnThis(), json: jest.fn() };
    next = jest.fn();
  });

  test('returns 401 when no Authorization header', () => {
    authenticate(req, res, next);
    expect(res.status).toHaveBeenCalledWith(401);
    expect(next).not.toHaveBeenCalled();
  });

  test('calls next and sets req.user on a valid token', () => {
    const user = { id: '123', role: 'user' };
    req.headers.authorization = 'Bearer valid.token';
    jwt.verify.mockReturnValue(user);

    authenticate(req, res, next);

    expect(req.user).toEqual(user);
    expect(next).toHaveBeenCalled();
    expect(res.status).not.toHaveBeenCalled();
  });
});

For end-to-end confidence, integration tests with supertest exercise the middleware inside a real Express app:

const request = require('supertest');
const express = require('express');
const authenticate = require('../middleware/authenticate');

const app = express();
app.get('/protected', authenticate, (req, res) => res.json({ user: req.user }));

test('rejects requests without a token', async () => {
  const res = await request(app).get('/protected');
  expect(res.status).toBe(401);
});

📖 Match the test to the middleware

Validation and pure logic → fast unit tests with fake req/res. Auth, error handling, and rate limiting → integration tests that check real status codes and headers. Aim high on coverage here: middleware is shared infrastructure, so a bug affects every route that uses it.

Hands-on Exercise

🏋️ Build a Timing Logger and a Role Guard

Objective: Write two pieces of middleware and wire them into a small app.

Instructions:

  1. Write requestTimer middleware that records the start time and, on res's finish event, logs METHOD URL → STATUS (Nms).
  2. Write an authorize(roles) factory that returns middleware allowing the request only if req.user.role is in roles (respond 403 otherwise, 401 if no req.user).
  3. Add a fake attachUser middleware that sets req.user = { role: 'editor' } so you can test without real auth.
  4. Protect GET /admin with authorize(['admin']) and GET /posts with authorize(['admin', 'editor']). Confirm the editor is blocked from /admin but allowed on /posts.
💡 Hint

The factory is a function that returns the (req, res, next) function. Register requestTimer and attachUser globally with app.use() before your routes so every request passes through them first.

✅ Example solution
const express = require('express');
const app = express();

// 1. Timing logger
const requestTimer = (req, res, next) => {
  const start = Date.now();
  res.on('finish', () => {
    console.log(`${req.method} ${req.originalUrl} → ${res.statusCode} (${Date.now() - start}ms)`);
  });
  next();
};

// 3. Fake auth for testing
const attachUser = (req, res, next) => {
  req.user = { id: 1, role: 'editor' };
  next();
};

// 2. Authorization factory
const authorize = (roles = []) => (req, res, next) => {
  if (!req.user) return res.status(401).json({ message: 'Not authenticated' });
  if (!roles.includes(req.user.role)) {
    return res.status(403).json({ message: 'Insufficient permissions' });
  }
  next();
};

app.use(requestTimer);
app.use(attachUser);

app.get('/admin', authorize(['admin']), (req, res) => res.json({ ok: 'admin area' }));
app.get('/posts', authorize(['admin', 'editor']), (req, res) => res.json({ ok: 'posts' }));

app.listen(3000, () => console.log('http://localhost:3000'));
// GET /admin  → 403 (editor blocked)
// GET /posts  → 200 (editor allowed)

🎯 Quick Quiz

Question 1: A middleware function neither sends a response nor calls next(). What happens?

Question 2: How does Express know a function is error-handling middleware?

Question 3: Why is authorize(['admin']) written as a factory that returns a middleware, rather than a plain middleware?

Best Practices

✅ Do

  • Keep each middleware focused on one job
  • Always send a response or call next() — exactly one
  • Attach shared data to req (e.g. req.user) for later middleware
  • Use factories for anything configurable
  • Register the error handler last, with four parameters
  • Wrap async handlers (or rely on Express 5's built-in catching)

⚠️ Don't

  • Don't call next() and send a response — you'll get "headers already sent"
  • Don't assume a property exists on req without checking (order isn't always guaranteed)
  • Don't do heavy synchronous work in middleware — it blocks the event loop
  • Don't swallow async errors — always route them to next(err)
  • Don't log secrets (tokens, passwords) — redact them

Summary & Quiz

🎉 Key Takeaways

  • Middleware is a pipeline of (req, res, next) functions Express runs in registration order.
  • Every middleware must send a response or call next() — never both, never neither.
  • Common needs — logging, auth, validation, rate limiting — are all just middleware.
  • Scope middleware globally, per-path, or per-route, and organize it with routers.
  • The factory pattern makes middleware configurable; async middleware needs careful error forwarding.

📚 Further Reading

🚀 What's Next?

You've seen middleware forward errors with next(err) — but where do those errors go, and how should they be shaped, logged, and returned? Next, Error Handling Strategies builds a complete, production-grade error pipeline.

🎉 Well done!

Your handlers are clean and your cross-cutting concerns live in reusable middleware. Now let's handle failure gracefully.