Middleware

Express.js Fundamentals — Middleware

Express.js Fundamentals

Chapter 3 of 8  ·  Middleware

Middleware

Middleware is the mechanism that makes Express powerful without being large. Everything that happens between a request arriving and a response leaving — logging, parsing the body, checking authentication, setting security headers, rate limiting — is middleware. A route handler is itself middleware; it just happens to send a response rather than passing control onward. Once you see Express as a pipeline of functions that each do one thing and hand off to the next, every other concept in the framework snaps into place.

The Middleware Signature

Every middleware function has exactly the same signature: three parameters for normal middleware, four for error-handling middleware.

// Normal middleware: (req, res, next) function myMiddleware(req, res, next) { // req — the incoming request (enhanced by Express over Node's IncomingMessage) // res — the outgoing response (enhanced over Node's ServerResponse) // next — a function: call it to pass control to the next middleware/route // Three things you can do here: // 1. Pass through: next() → next middleware runs // 2. Send a response: res.json(...) → request is done, nothing else runs // 3. Pass an error: next(err) → skip to error-handling middleware next(); // ← if you forget this and don't send a response, the request hangs } // Error-handling middleware: exactly 4 params — err is first // Express detects the 4-param signature and only calls this when next(err) is invoked function errorHandler(err, req, res, next) { res.status(err.status ?? 500).json({ error: err.message }); }

The Middleware Pipeline

HTTP Request arrives at Node's http server │ ▼ ┌──────────────────────────────────────────────────────┐ │ Express pipeline │ │ │ │ app.use(logger) ──── runs, calls next() ────► │ │ app.use(express.json) ──── parses body, next() ───► │ │ app.use(authenticate) ──── validates token ────────► │ │ │ invalid? res.send(401) │ │ │ valid? next() │ │ app.get('/data', handler) ─┘ sends response │ │ │ │ app.use(errorHandler) ◄─── only reached via next(err) │ └──────────────────────────────────────────────────────┘ │ ▼ HTTP Response sent Each function either calls next() to continue or sends a response to stop. Forgetting next() on a non-responding middleware = request hangs forever.

Registering Middleware

app.use() is the general way to register middleware. A path prefix is optional; omitting it means "run for every request". Route methods (app.get(), etc.) can also accept middleware functions before their final handler — these are called route-level middleware.

// Application-level — runs for every request (no path) app.use(logger); app.use(express.json()); // Application-level — runs only for requests under /api app.use('/api', rateLimiter); // Route-level — middleware runs before the route handler, for this route only app.get('/admin', requireAdmin, adminHandler); // Multiple middleware in an array (common for reuse) const authGuard = [authenticate, requireAdmin]; app.get('/admin/users', authGuard, listAdminUsers); // Router-level — middleware scoped to a router instance const apiRouter = express.Router(); apiRouter.use(authenticate); // applies to all routes on this router apiRouter.get('/profile', getProfile);

Built-in Middleware

Express ships with three built-in middleware functions. Everything else is third-party.

express.json()

Parses Content-Type: application/json request bodies and populates req.body. Without it, req.body is undefined.

app.use(express.json({ limit: '1mb' }))

The limit option guards against enormous payloads. Default is 100kb.

express.urlencoded()

Parses HTML form submissions (Content-Type: application/x-www-form-urlencoded) and populates req.body.

app.use(express.urlencoded({ extended: false }))

extended: false uses the querystring library (simple). true uses qs (supports nested objects).

express.static()

Serves files from a directory. A request for /logo.png will serve public/logo.png if it exists.

app.use(express.static('public'))

Handles ETags, Last-Modified, and 304 Not Modified automatically. Returns 404 (continues to next middleware) if the file doesn't exist.

express.raw() / express.text()

express.raw() — populates req.body as a Buffer for binary uploads or webhook payloads that need raw bytes (e.g. verifying Stripe HMAC signatures).

express.text() — populates req.body as a plain string.

Both accept the same limit option as express.json().

Essential Third-Party Middleware

PackageWhat it doesInstall
morgan HTTP request logger. Formats: 'dev' (coloured, short), 'combined' (Apache log format for production), 'tiny'. npm i morgan
helmet Sets 11 security-related HTTP headers in one call (X-Frame-Options, Content-Security-Policy, Strict-Transport-Security, etc.). npm i helmet
cors Handles Cross-Origin Resource Sharing headers. Without it, browsers block cross-origin requests to your API. npm i cors
express-rate-limit Limits repeated requests per IP. Essential for login endpoints and public APIs. npm i express-rate-limit
compression Gzip/Brotli compresses responses. Transparent to clients; reduces transfer size by 60–80% for JSON/HTML. npm i compression
// src/app.js — wiring up common third-party middleware const express = require('express'); const morgan = require('morgan'); const helmet = require('helmet'); const cors = require('cors'); const rateLimit = require('express-rate-limit'); const app = express(); // Order matters: security and logging first, body parsers before route handlers app.use(helmet()); // security headers app.use(cors({ origin: process.env.ALLOWED_ORIGIN })); // CORS app.use(morgan('dev')); // request logging app.use(express.json({ limit: '1mb' })); // parse JSON bodies app.use(express.urlencoded({ extended: false })); // parse form bodies // Rate limit — apply to all /api routes const limiter = rateLimit({ windowMs: 15 * 60 * 1000, // 15 minutes max: 100, // 100 requests per window per IP standardHeaders: true, legacyHeaders: false, }); app.use('/api', limiter); module.exports = app;

Writing Your Own Middleware

A middleware is just a function. It can attach data to req, mutate headers on res, abort the request early, or do nothing and call next(). The key patterns:

// Pattern 1: Attach data to req for downstream handlers function requestId(req, res, next) { req.id = crypto.randomUUID(); res.setHeader('X-Request-Id', req.id); next(); } // Pattern 2: Guard — abort early if a condition isn't met function requireApiKey(req, res, next) { const key = req.headers['x-api-key']; if (!key || key !== process.env.API_KEY) { return res.status(401).json({ error: 'Invalid or missing API key' }); } next(); } // Pattern 3: Measure something (listen on res 'finish' for post-response data) function responseTimer(req, res, next) { const start = process.hrtime.bigint(); res.on('finish', () => { const ms = Number(process.hrtime.bigint() - start) / 1_000_000; console.log(`${req.method} ${req.path}${res.statusCode} (${ms.toFixed(1)}ms)`); }); next(); } // Pattern 4: Middleware factory — returns a configured middleware function function allowRoles(...roles) { return function (req, res, next) { if (!req.user) return res.status(401).json({ error: 'Not authenticated' }); if (!roles.includes(req.user.role)) { return res.status(403).json({ error: 'Insufficient permissions' }); } next(); }; } // Usage: factory produces middleware configured at registration time app.get('/admin', allowRoles('admin'), adminHandler); app.get('/reports', allowRoles('admin', 'manager'), reportsHandler);

Error-Handling Middleware

Call next(err) from any middleware or route handler to skip straight to error-handling middleware. Express identifies error handlers by their 4-parameter signature — even if you don't use next inside it, the parameter must be declared.

// Triggering an error handler app.get('/risky', async (req, res, next) => { try { const data = await someDbQuery(); res.json(data); } catch (err) { next(err); // ← hands err to the error-handling middleware below } }); // Express 5 note: async handlers that throw are caught automatically — // next(err) is called for you. In Express 4 the try/catch above is required // (or use the 'express-async-errors' package to patch it). // Creating custom error objects (attach a status code) function createError(status, message) { const err = new Error(message); err.status = status; return err; } app.get('/secret', (req, res, next) => { if (!req.user) return next(createError(401, 'Login required')); res.json({ secret: '42' }); }); // The error handler — 4 params, placed last in app.js app.use((err, req, res, next) => { // next must be declared even if unused const status = err.status ?? 500; res.status(status).json({ error: status < 500 ? err.message : 'Internal server error', // Never expose stack traces in production }); if (status >= 500) console.error(err); // log 5xx internally });

Middleware Ordering in app.js

The order in which you call app.use() is the order middleware executes. Here's the recommended sequence for a production app:

// Recommended order in app.js // 1. Security headers — before anything else can run app.use(helmet()); // 2. CORS — must respond to OPTIONS preflight before other middleware consumes the request app.use(cors(corsOptions)); // 3. Request logging — log every request, including ones that fail body parsing app.use(morgan('dev')); // 4. Rate limiting — before body parsing (don't waste CPU parsing rejected requests) app.use('/api', limiter); // 5. Body parsers — must run before any route that reads req.body app.use(express.json()); app.use(express.urlencoded({ extended: false })); // 6. Application middleware (auth, request ID, etc.) app.use(requestId); app.use('/api', authenticate); // 7. Routes and routers app.use('/api/users', usersRouter); app.use('/api/articles', articlesRouter); // 8. 404 handler — after all routes, before error handler app.use((_req, res) => res.status(404).json({ error: 'Not found' })); // 9. Error handler — must be last app.use((err, req, res, _next) => { /* ... */ });

⚠ The Three Middleware Mistakes

1. Forgetting next(): If your middleware doesn't call next() and doesn't send a response, the request hangs until the client times out. No error, no warning — it just silently stalls.

2. Calling next() after sending a response: Sending a response and then calling next() causes the "Cannot set headers after they are sent" error. Always return after res.json() / res.send() if there's more code in the function.

3. Wrong parameter count on error handlers: Express counts parameters to distinguish normal middleware from error handlers. A function with 3 params is never treated as an error handler, even if you name it errorHandler. The 4-param signature (err, req, res, next) is mandatory — even if you never call next inside it.

💡 Middleware Is Just a Function — So Compose It

Because middleware is a plain function, you can compose it like any other function. Arrays of middleware are accepted by app.use() and route methods. Middleware factories (functions that return middleware) let you parameterise behaviour at registration time. This is how cors(options), rateLimit(options), and helmet(options) all work — each is a factory that returns a configured middleware function. Write your own the same way: if a middleware needs options, make it a factory.

Coding Challenges

Challenge 1 — Middleware Stack from Scratch

Without using any third-party packages (only Express), build an app.js that mounts three custom middleware functions before any route: requestId — attaches a crypto.randomUUID() to req.id and sets an X-Request-Id response header; responseTimer — listens for the 'finish' event on res and logs [METHOD] /path → STATUS (Xms); bodyGuard — for POST/PUT/PATCH requests, if the Content-Type header is present but is not application/json, respond immediately with 415 Unsupported Media Type. Add two routes: GET /ping returns { pong: true }; POST /echo returns the parsed body. Write supertest tests verifying: X-Request-Id header is present on GET; POST with JSON body works; POST with Content-Type: text/plain gets 415; each request gets a unique requestId.

View sample solution ↗

Challenge 2 — Middleware Factory

Write a middleware factory requireRole(...roles) that: checks req.headers['x-user-role'] (a test stand-in for a real auth system — Chapter 7 does real JWT auth); returns 401 if the header is missing; returns 403 if the role isn't in the allowed list; calls next() if authorised, attaching req.userRole. Mount it selectively: GET /public has no auth; GET /user-area requires role user or admin; GET /admin-only requires role admin. Write supertest tests covering: public route needs no header; user-area works for both user and admin but fails with no header or wrong role; admin-only fails for user role; 401 vs 403 are used correctly.

View sample solution ↗

Challenge 3 — Error-Handling Pipeline

Build an app demonstrating the full error-handling pipeline. Write a helper createError(status, message). Add three routes: GET /safe works normally; GET /client-error calls next(createError(400, 'Bad input')); GET /server-error calls next(createError(500, 'DB exploded')) (simulating an internal error). Write an error-handling middleware (4-param) that: for 4xx errors, responds with { error: err.message }; for 5xx, responds with { error: "Internal server error" } (never expose the real message in production). Add a 404 handler between the routes and the error handler. Write supertest tests verifying: /safe returns 200; /client-error returns 400 with the real message; /server-error returns 500 with the sanitised message (not "DB exploded"); an unknown path returns 404 (not 500).

View sample solution ↗