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.
The Middleware Pipeline
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.
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
| Package | What it does | Install |
|---|---|---|
| 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 |
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:
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.
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:
⚠ 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.
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.
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).