🔗 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, andnext - 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.
🏭 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:
| Purpose | Package |
|---|---|
| Logging | morgan, winston, pino |
| Authentication | passport, express-jwt |
| Validation | express-validator, joi, zod |
| Rate limiting | express-rate-limit |
| Security headers / CORS | helmet, 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);
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:
- Write
requestTimermiddleware that records the start time and, onres'sfinishevent, logsMETHOD URL → STATUS (Nms). - Write an
authorize(roles)factory that returns middleware allowing the request only ifreq.user.roleis inroles(respond403otherwise,401if noreq.user). - Add a fake
attachUsermiddleware that setsreq.user = { role: 'editor' }so you can test without real auth. - Protect
GET /adminwithauthorize(['admin'])andGET /postswithauthorize(['admin', 'editor']). Confirm the editor is blocked from/adminbut 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
reqwithout 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
- Express — Using Middleware
- Express — Writing Middleware
- morgan — HTTP request logger
- Passport — authentication middleware
- express-validator
🚀 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.