Building a REST API with Node.js · Lesson 12 of 30
Express Middleware for Production REST APIs
On this page
In Express, almost everything is middleware: functions that run in order for each request, can read and change the request and response, and either respond or pass control on with next(). A production API is mostly a well-ordered stack of small middleware: correlation ids, logging, security headers, body limits, authentication, routes, and error handling.
In this lesson
- How the middleware chain works
- Request ids and access logs
- Security headers and body size limits
- 404 and error handlers, in the right order
The order of a production stack
- requestIdtag every request so logs can be correlated
- access logrecord method, path, status, time when the response finishes
- helmetsecure HTTP headers
- express.json({ limit })parse bodies, reject huge ones
- routesauth, validation, handlers
- 404 handlernothing matched
- error handler(err, req, res, next) – always last
The stack, in code
// server.js
import express from 'express';
import helmet from 'helmet';
import { randomUUID } from 'node:crypto';
const app = express();
app.disable('x-powered-by');
// 1. correlation id: reuse the caller's, or create one
app.use((req, res, next) => {
req.id = req.get('X-Request-Id') ?? randomUUID();
res.set('X-Request-Id', req.id);
next();
});
// 2. access log, written when the response is finished
app.use((req, res, next) => {
res.on('finish', () => console.log(`[${req.id}] ${req.method} ${req.originalUrl} → ${res.statusCode}`));
next();
});
// 3. security headers (Content-Security-Policy, X-Content-Type-Options, …)
app.use(helmet());
// 4. JSON bodies, with a size limit
app.use(express.json({ limit: '10kb' }));
// 5. routes
app.post('/api/notes', (req, res) => res.status(201).json({ id: 1, text: req.body.text }));
app.get('/api/slow-dependency', async () => {
throw new Error('Inventory service timed out'); // async errors reach the handler in Express 5
});
// 6. nothing matched
app.use((req, res) => res.status(404).json({ error: 'Not found', requestId: req.id }));
// 7. errors – four arguments, registered last
app.use((err, req, res, next) => {
const status = err.status ?? err.statusCode ?? 500; // body-parser sets 400 / 413
if (status >= 500) console.error(`[${req.id}] ${err.message}`);
res.status(status).json({ error: status >= 500 ? 'Internal server error' : err.message, requestId: req.id });
});
app.listen(process.env.PORT ?? 3000);
$ curl -X POST http://localhost:3000/api/notes -H 'Content-Type: application/json' -H 'X-Request-Id: req-1001' -d '{"text":"hello"}'
HTTP/1.1 201 Created
X-Request-Id: req-1001
Strict-Transport-Security: max-age=31536000; includeSubDomains
X-Content-Type-Options: nosniff
Content-Type: application/json; charset=utf-8
{
"id": 1,
"text": "hello"
}
$ curl -X POST http://localhost:3000/api/notes -H 'Content-Type: application/json' -H 'X-Request-Id: req-1002' -d "{\"text\":\"$(printf 'x%.0s' $(seq 1 11000))\"}"
HTTP/1.1 413 Payload Too Large
X-Request-Id: req-1002
Strict-Transport-Security: max-age=31536000; includeSubDomains
X-Content-Type-Options: nosniff
Content-Type: application/json; charset=utf-8
{
"error": "request entity too large",
"requestId": "req-1002"
}
$ curl http://localhost:3000/api/slow-dependency -H 'X-Request-Id: req-1003'
HTTP/1.1 500 Internal Server Error
X-Request-Id: req-1003
Strict-Transport-Security: max-age=31536000; includeSubDomains
X-Content-Type-Options: nosniff
Content-Type: application/json; charset=utf-8
{
"error": "Internal server error",
"requestId": "req-1003"
}
$ curl http://localhost:3000/api/nope -H 'X-Request-Id: req-1004'
HTTP/1.1 404 Not Found
X-Request-Id: req-1004
Strict-Transport-Security: max-age=31536000; includeSubDomains
X-Content-Type-Options: nosniff
Content-Type: application/json; charset=utf-8
{
"error": "Not found",
"requestId": "req-1004"
}
Server log:
[req-1001] POST /api/notes → 201
[req-1002] POST /api/notes → 413
[req-1003] Inventory service timed out
[req-1003] GET /api/slow-dependency → 500
[req-1004] GET /api/nope → 404- The request id appears in the response header, the error body and every log line – when a customer reports a problem, their request id finds the exact logs.
- helmet added headers such as
X-Content-Type-Options: nosniffandStrict-Transport-Security. - The oversized body was rejected with
413 Payload Too Largebefore reaching the route – limits protect memory. - The thrown async error became a clean 500; the real message only went to the log.
Order matters
Middleware runs in the order it's registered. The error handler (four parameters) must come after all routes; the 404 handler after the routes but before the error handler; request ids and logging first, so even failing requests are logged.
Practice
Try it yourself
Write a middleware that rejects requests without an
X-Api-Keyheader ofdemo-keywith 401, and apply it only to/api/admin.Show solution
// server.js import express from 'express'; const app = express(); function requireApiKey(req, res, next) { if (req.get('X-Api-Key') !== 'demo-key') return res.status(401).json({ error: 'Invalid or missing API key' }); next(); } app.get('/api/public', (req, res) => res.json({ ok: true })); app.use('/api/admin', requireApiKey); // only for this path app.get('/api/admin/stats', (req, res) => res.json({ users: 1240 })); app.listen(process.env.PORT ?? 3000);$ curl http://localhost:3000/api/admin/stats HTTP/1.1 401 Unauthorized Content-Type: application/json; charset=utf-8 { "error": "Invalid or missing API key" } $ curl http://localhost:3000/api/admin/stats -H 'X-Api-Key: demo-key' HTTP/1.1 200 OK Content-Type: application/json; charset=utf-8 { "users": 1240 }
Quick check
How does Express recognise an error handler?
It has four parameters: (err, req, res, next).
Why log in res.on('finish')?
So the log has the final status code.
What does express.json({ limit }) protect against?
Huge bodies exhausting memory – oversized requests get 413.
Summary
- Middleware runs in order; each either responds or calls next().
- Production basics: request ids, access logs, helmet, body limits, 404 and error handlers.
- Put the error handler last and never leak internal errors to clients.
Finished this lesson?
Mark it complete to track your progress, then carry on with the next lesson.