12. Middleware and Server Frameworks with Express
Express is the most widely used web framework for Node.js: a thin layer over the http module that adds routing, request and response helpers and, above all, a middleware pipeline through which every request flows. This chapter starts with the application object and routing (12.1), then looks closely at the middleware pipeline, including error handling (12.2), and finally builds real services: REST endpoints, validation, sessions, authentication, uploads, server-rendered views, SPA hosting, testing, and a comparison with Koa, Fastify and NestJS (12.3). It builds directly on the raw HTTP server from 11.6 and on the HTTP, cookie, session and authentication concepts from Chapter 10. All examples target Express 5 with ES modules; where Express 4 behaves differently, the difference is pointed out.
<a id="121-express-fundamentals"></a>
12.1 Express Fundamentals
This subchapter introduces Express itself: why a framework is worth having on top of the http module, how an application is created and started, the request and response objects it provides, how routes and parameters are matched, how to modularise routes with express.Router, and how to organise and configure a real project.
Motivation for a Web Framework
The server built by hand in 11.6 worked, but most of its code had nothing to do with the business problem (orders). It parsed URLs, matched paths with regular expressions, collected and size-limited request bodies, checked Content-Type, serialised JSON with the right Content-Length, and mapped errors to status codes. Every HTTP service needs exactly the same plumbing. Writing it again for each project is slow, and each hand-written copy has its own bugs: a missing body limit, a forgotten charset, a route that matches more than intended.
A web framework packages this plumbing once, tests it across millions of deployments, and leaves you to write only the parts that are specific to your application. An analogy: the http module gives you an engine and a chassis. You can drive it, but you must wire the dashboard, lights and brakes yourself. A framework is the finished car: you still choose where to go, but the controls are standard and already work.
The same endpoint, with and without a framework.
// Raw node:http (11.6): routing, serialisation and headers are all manual
import { createServer } from 'node:http';
createServer((req, res) => {
const url = new URL(req.url, 'http://localhost');
const match = /^\/api\/orders\/(?<id>\d+)$/.exec(url.pathname); // hand-written routing
if (req.method === 'GET' && match) {
const order = orders.get(Number(match.groups.id));
const body = JSON.stringify(order ?? { error: 'order not found' });
res.writeHead(order ? 200 : 404, {
'Content-Type': 'application/json; charset=utf-8',
'Content-Length': Buffer.byteLength(body), // bytes, not characters
});
return res.end(body);
}
res.writeHead(404).end();
}).listen(3000);
// Express: the same endpoint
import express from 'express';
const app = express();
app.get('/api/orders/:id', (req, res) => {
const order = orders.get(Number(req.params.id)); // :id extracted by the router
if (!order) return res.status(404).json({ error: 'order not found' });
res.json(order); // sets Content-Type, Content-Length and ETag
});
app.listen(3000);
What a framework takes off your hands.
| Concern | Raw node:http | Express |
|---|---|---|
| Routing | Regular expressions and if chains | app.get('/orders/:id', ...), routers, mounting |
| Path and query parameters | new URL(), manual extraction | req.params, req.query |
| Request bodies | Collect chunks, enforce limits, parse | express.json(), express.urlencoded() |
| Responses | writeHead, JSON.stringify, byte lengths | res.json(), res.send(), res.status() |
| Static files | Streams, MIME types, ranges, caching | express.static() |
| Cross-cutting logic (logging, auth, CORS) | Wrapping the handler by hand | Middleware: app.use(...) |
| Error handling | One big try/catch | Error-handling middleware |
| Ecosystem | Few reusable pieces | Thousands of middleware packages |
Library or framework? A library is code you call; a framework calls your code. This is called inversion of control (or the "Hollywood principle": don't call us, we'll call you). With Express you do not write the request loop; you register functions and Express decides when to run them. Frameworks differ in how much they decide for you:
minimal, unopinionated opinionated, batteries included
<------------------------------------------------------------------------------------------------>
node:http Express, Koa, Hono Fastify NestJS, AdonisJS
(nothing) routing + middleware + schemas, plugins, + dependency injection, modules,
fast serialisation decorators, project conventions
Express sits near the minimal end: it prescribes no folder structure, no database, no validation library and no template engine. That freedom is why it fits almost any project, and also why every team must make (and document) its own structural choices (see "Project Structure and Configuration").
A short history.
| Year | Event |
|---|---|
| 2010 | TJ Holowaychuk releases Express, inspired by Ruby's Sinatra and built on the Connect middleware library |
| 2014 | Express 4: the Connect dependency and bundled middleware are removed and become separate packages; the modern express.Router and app.route() APIs appear |
| 2016 | Express joins the Node.js Foundation (since 2019 the OpenJS Foundation) |
| 2024 | Express 5.0 is released after roughly ten years in development: native promise support, a new path syntax, removal of deprecated APIs |
| 2025 | Express 5 becomes the default latest version on npm |
Express remains relevant even where newer frameworks are faster: most tutorials and Stack Overflow answers use it, its middleware ecosystem is the largest, NestJS runs on it by default, and its (req, res, next) middleware model shaped Koa, Hono and many others.
What it costs. A framework adds dependencies (supply-chain surface, 11.2), a small performance overhead compared with raw http, and behaviour that happens "behind your back" (automatic ETags, content-type guessing, path stripping when routers are mounted). Knowing the raw layer from 11.6 is what lets you understand and debug that behaviour.
Key Takeaways
- Every HTTP service needs routing, body parsing, response helpers, static files and error handling; a framework provides them once, well tested.
- Express is a minimal, unopinionated framework: it calls your functions (inversion of control) but does not impose structure or libraries.
- Express 5 is the current major version; it adds native promise support and changes the route path syntax.
- Express runs on the same
req/resobjects as thehttpmodule, so knowledge of the raw layer remains useful.
Practice
- (Beginner) Rewrite the hand-written order server from 11.6 (list, get by ID, create) in Express and compare the line counts. List which concerns disappeared from your code and where they went.
- (Intermediate) Use
curl -iagainst both versions and compare the response headers. Which headers does Express add on its own, and what does each one do?- (Interview) "What is the difference between a library and a framework, and where does Express sit?" Hint: think about who owns the control flow, and compare how much Express prescribes with a framework such as NestJS.
Application Object and Server Bootstrapping
Calling express() creates an application object, conventionally named app. It has three roles at once:
- It is a request handler function with the signature
(req, res, next), which can be passed tohttp.createServer(). - It is a registry of middleware and routes (
app.use,app.get,app.post, ...). - It is a settings store (
app.set,app.get(name),app.enable,app.disable) and holds application-wide data inapp.locals.
import express from 'express';
import { createServer } from 'node:http';
const app = express();
console.log(typeof app); // 'function': app is itself a request handler
// These two lines are equivalent; app.listen() is a shortcut
const server = app.listen(3000); // returns the underlying http.Server
const same = createServer(app).listen(3001);
The explicit createServer(app) form is needed when you want HTTPS (https.createServer(tlsOptions, app)), when a WebSocket server must share the same port (10.5), or when tests need to control the server. Express does not replace Node's server; it only provides the function that handles each request.
Bootstrapping order. Express runs middleware and routes in the order they were registered, so the order of statements in the set-up code is the request pipeline:
1. create the app const app = express()
2. settings app.set('trust proxy', 1), app.disable('x-powered-by')
3. global middleware logging, security headers, CORS, body parsers
4. routes and routers app.use('/api/books', booksRouter)
5. 404 handler runs only if no route answered
6. error handler (err, req, res, next) => ...
7. start listening app.listen(port) (in a separate file: see below)
Application settings. Settings change how Express behaves. The most important ones:
| Setting | Default | Effect |
|---|---|---|
env | process.env.NODE_ENV or 'development' | Enables view caching and less verbose error pages when 'production' |
trust proxy | false | Whether to believe X-Forwarded-* headers for req.ip, req.protocol, req.secure |
x-powered-by | enabled | Sends X-Powered-By: Express; disable it (it only helps attackers fingerprint the server) |
etag | 'weak' | Automatic ETag on res.send/res.json responses, enabling 304 Not Modified |
query parser | 'simple' (Express 5), 'extended' (Express 4) | How req.query is parsed (see "Routing, Parameters and Query Strings") |
json spaces | undefined | Indentation for res.json(), handy in development |
case sensitive routing | disabled | Whether /Books and /books are different routes |
strict routing | disabled | Whether /books and /books/ are different routes |
views, view engine, view cache | ./views, none, on in production | Template rendering (12.3) |
Trusting the proxy. In production an Express app usually sits behind a reverse proxy or load balancer (nginx, a cloud load balancer, 10.1). The TCP connection then comes from the proxy, not the browser, and the real client data travels in headers:
browser 198.51.100.7 --HTTPS--> load balancer 10.0.0.2 --HTTP--> Express app
adds X-Forwarded-For: 198.51.100.7
X-Forwarded-Proto: https
Without trust proxy, req.ip is 10.0.0.2 and req.secure is false, so rate limiting counts every user as one client and Secure session cookies are never sent. Setting it to true trusts any X-Forwarded-For value, which a client can forge. Set it to the exact number of proxy hops (1), or to the proxy's addresses ('loopback, 10.0.0.0/8').
Separating the app from the server. Keep the code that builds the application separate from the code that starts it. Tests can then import the app without opening a port (see "Testing Express Applications"):
// src/app.js: builds and configures the application, never listens
import express from 'express';
import { booksRouter } from './books/books.router.js';
export function createApp() {
const app = express();
app.set('trust proxy', 1); // one reverse proxy in front of us
app.disable('x-powered-by');
app.locals.appName = 'Bookshelf'; // available in every template (12.3)
app.use(express.json());
app.use('/api/books', booksRouter);
app.use((req, res) => res.status(404).json({ error: 'not found' }));
return app;
}
// src/server.js: the process entry point
import { createApp } from './app.js';
const PORT = Number(process.env.PORT ?? 3000);
const app = createApp();
const server = app.listen(PORT, (err) => {
if (err) { // Express 5: listen errors (EADDRINUSE, EACCES) arrive here
console.error('cannot start server:', err.message);
process.exit(1);
}
console.log(`listening on http://localhost:${PORT} (${app.get('env')})`);
});
server.keepAliveTimeout = 65_000; // longer than the load balancer's idle timeout
server.headersTimeout = 66_000; // must be greater than keepAliveTimeout
function shutdown(signal) { // graceful shutdown (11.5)
console.log(`${signal} received, closing server`);
server.close((closeErr) => process.exit(closeErr ? 1 : 0)); // stop accepting, finish in-flight requests
setTimeout(() => process.exit(1), 10_000).unref(); // hard deadline if some request never ends
}
process.on('SIGTERM', shutdown);
process.on('SIGINT', shutdown);
app.locals holds values for the whole application lifetime (application name, configuration needed by templates). Per-request data belongs in res.locals (next topic), never in app.locals, because app.locals is shared by all concurrent requests.
Key Takeaways
express()returns a function(req, res, next)that also stores routes, middleware and settings;app.listen()is shorthand forhttp.createServer(app).listen().- Registration order is execution order: settings, global middleware, routes, 404 handler, error handler.
- Configure
trust proxyprecisely when behind a proxy, or client IPs, protocol and secure cookies will be wrong (or spoofable). - Build the app in one module (
app.js) and start it in another (server.js) so tests and tools can use the app without a network port.
Practice
- (Beginner) Create an app with
json spacesset to2in development only (useapp.get('env')), and verify the difference withcurlwhile changingNODE_ENV.- (Intermediate) Put your app behind a minimal proxy (nginx or a second Express app using
http-proxy-middleware) and logreq.ip,req.protocolandreq.securewithtrust proxyset tofalse,1andtrue. Then send a forgedX-Forwarded-Forheader and explain each result.- (Interview) "Why do many Express projects split
app.jsandserver.js?" Hint: think about what a test runner, a serverless adapter and an HTTPS or WebSocket set-up each need from the application.
Request and Response Abstractions
Express does not create new request and response objects. For each request it takes Node's http.IncomingMessage and http.ServerResponse (11.6) and changes their prototype so that they also inherit Express helpers:
req --> app.request (Express: req.get, req.accepts, req.ip, req.query, ...)
--> http.IncomingMessage.prototype (Node: method, url, headers, socket)
--> stream.Readable.prototype (the body is still a readable stream)
res --> app.response (Express: res.status, res.json, res.send, res.cookie, ...)
--> http.ServerResponse.prototype (Node: writeHead, setHeader, write, end)
--> http.OutgoingMessage.prototype (writable stream interface)
The consequence is important: everything from Node still works. You can stream from req, call res.write(), or pipeline() a file into res. Express helpers are conveniences on top.
The request object.
| Property or method | Example value for GET /api/books/42?fields=title | Notes |
|---|---|---|
req.method | 'GET' | From Node |
req.originalUrl | '/api/books/42?fields=title' | Full URL as received |
req.baseUrl | '/api/books' | Mount path of the current router |
req.path | '/42' | Path relative to the mount point, without query |
req.params | { id: '42' } | Named route parameters (always strings) |
req.query | { fields: 'title' } | Parsed query string (read-only getter in Express 5) |
req.body | undefined for GET | Set by body-parsing middleware; undefined if none ran |
req.get('User-Agent') | 'curl/8.7.1' | Case-insensitive header lookup |
req.ip, req.ips | '198.51.100.7' | Respect trust proxy |
req.protocol, req.secure | 'https', true | Respect trust proxy |
req.hostname | 'api.example.com' | From Host (or X-Forwarded-Host when trusted) |
req.accepts('json', 'html') | 'json' | Content negotiation against the Accept header |
req.is('application/json') | 'application/json' or false | Checks the request's Content-Type |
req.fresh, req.stale | true / false | Whether a conditional request can get 304 |
req.cookies, req.signedCookies | { theme: 'dark' } | Only with cookie-parser (12.3) |
The response object.
| Method | Effect |
|---|---|
res.status(code) | Sets the status code; chainable: res.status(201).json(obj) |
res.json(value) | Serialises to JSON, sets Content-Type: application/json and sends |
res.send(body) | Sends a string (text/html by default), a Buffer (application/octet-stream) or an object (JSON) |
res.sendStatus(code) | Sends the status with its reason phrase as body (res.sendStatus(204)) |
res.set(name, value) / res.get(name) | Sets or reads a response header (res.set({ ... }) for several) |
res.type('csv') | Sets Content-Type from an extension or MIME type |
res.location(url) | Sets the Location header (for 201 Created) |
res.redirect([status,] url) | Redirects (default 302); use 303 after a POST |
res.cookie(name, value, options) / res.clearCookie(name, options) | Sets or deletes a cookie (12.3) |
res.sendFile(path, options) | Streams a file with the correct type, ranges and caching headers |
res.download(path, filename) / res.attachment(filename) | Sets Content-Disposition: attachment |
res.format({ ... }) | Responds differently depending on Accept |
res.render(view, locals) | Renders a template (12.3) |
res.locals | Per-request data shared between middleware and templates |
res.headersSent | true once headers have been written |
Content negotiation. One URL can serve several representations (10.1). res.format() picks the best match and also sets Vary: Accept so caches keep the variants apart:
app.get('/api/books/:id', (req, res) => {
const book = books.get(Number(req.params.id));
if (!book) return res.sendStatus(404);
res.format({
'application/json': () => res.json(book),
'text/csv': () => res.type('csv').send(`id,title\n${book.id},"${book.title.replaceAll('"', '""')}"\n`),
default: () => res.status(406).send('Not Acceptable'), // no acceptable type
});
});
Streaming large responses. Because res is still a writable stream, large data should be piped instead of built in memory:
import { createReadStream } from 'node:fs';
import { pipeline } from 'node:stream/promises';
app.get('/exports/books.ndjson', async (req, res) => {
res.attachment('books.ndjson'); // Content-Disposition: attachment; filename="books.ndjson"
res.type('application/x-ndjson'); // after attachment(), which guesses a type from the extension
await pipeline(createReadStream('./data/books.ndjson'), res); // backpressure handled; errors reject (12.2)
});
Send exactly once. Every request must receive exactly one response. Sending twice throws ERR_HTTP_HEADERS_SENT ("Cannot set headers after they are sent to the client"); never sending leaves the client waiting until it times out. Sending does not stop the function, so the most common bug is a missing return:
app.get('/api/books/:id', (req, res) => {
const book = books.get(Number(req.params.id));
if (!book) {
res.status(404).json({ error: 'not found' }); // BUG: no return, execution continues...
}
res.json(book); // ...and this throws ERR_HTTP_HEADERS_SENT
});
// Fix: return res.status(404).json(...)
res.locals. An object that lives for one request. Middleware can put data there (the current user, a CSRF token, a request ID) for later middleware and templates to read, without conflicting with other requests.
Key Takeaways
- Express augments Node's
IncomingMessageandServerResponsethrough their prototypes; all Node APIs, including streams, remain available. reqadds parsed parameters, query, body (via middleware), header access, client info and content negotiation;resadds status, JSON, files, redirects, cookies and rendering.req.ip,req.protocolandreq.securedepend ontrust proxy;req.bodyexists only if a body parser ran.- Send exactly one response per request, and
returnafter sending in branches.
Practice
- (Beginner) Write a
/debug/requestroute that returns, as JSON,method,originalUrl,baseUrl,path,params,query,ip,protocoland theAcceptheader. Call it with various URLs and headers.- (Intermediate) Implement
GET /api/booksthat returns JSON, CSV or an HTML table depending on theAcceptheader, and check theVaryheader in the response. Then add?format=csvas an override and discuss what it means for caching.- (Interview) "A route sometimes logs
ERR_HTTP_HEADERS_SENT. What are the usual causes?" Hint: look for missingreturnstatements, middleware that both responds and callsnext(), and asynchronous code that responds after an error handler has already answered.
Routing, Parameters and Query Strings
A route combines an HTTP method, a path pattern and one or more handler functions. When a request arrives, Express walks through the registered routes in order and runs the handlers of the first route whose method and path both match (unless a handler passes control on with next()).
app.get('/books', listBooks); // GET only
app.post('/books', createBook); // POST only
app.all('/health', healthCheck); // every method
Express also answers HEAD automatically for GET routes, and replies to OPTIONS with an Allow header listing the methods registered for a path.
Path patterns (Express 5). Express 5 uses version 8 of the path-to-regexp library, which has a stricter syntax than Express 4:
| Pattern | Matches | req.params |
|---|---|---|
/books | /books (and /books/ unless strict routing) | {} |
/books/:id | /books/42, /books/dune | { id: '42' } |
/books/:bookId/reviews/:reviewId | /books/7/reviews/3 | { bookId: '7', reviewId: '3' } |
/files/:name.:ext | /files/report.pdf | { name: 'report', ext: 'pdf' } |
/books{/:format} | /books and /books/csv (braces mark an optional part) | {} or { format: 'csv' } |
/files/*path | /files/a/b/c.txt (not /files itself) | { path: ['a', 'b', 'c.txt'] } (wildcard values are arrays of segments) |
/{*path} | Every path, including / | {} for /, otherwise { path: [...] } |
/^\/v(\d+)\/books$/ (a RegExp object) | /v2/books | { 0: '2' } |
Migrating from Express 4.
| Express 4 | Express 5 | Note |
|---|---|---|
/files/* | /files/*path | Wildcards must be named |
/books/:id? | /books{/:id} | Optional segments use braces |
/books/:id(\\d+) | /books/:id plus validation, or a RegExp object | Inline regular expressions in strings are no longer supported |
/* as catch-all | /{*path} | Also matches / |
The characters ( ) [ ] ? + ! are reserved in Express 5 path strings and must be escaped with \\ if you need them literally.
Order matters. Because the first match wins, specific routes must come before general ones:
app.get('/books/new', showNewBookForm); // must be registered first...
app.get('/books/:id', showBook); // ...or this would match /books/new with id = 'new'
Route parameters are strings. req.params.id for /books/42 is the string '42', never the number 42. Always convert and validate before use; Number('4x2') is NaN and Number('') is 0. To share parameter handling between routes, router.param() (or app.param()) runs a callback whenever a named parameter appears in a matched route:
router.param('bookId', async (req, res, next, value) => {
const id = Number(value);
if (!Number.isInteger(id) || id <= 0) {
return res.status(400).json({ error: 'bookId must be a positive integer' });
}
const book = await books.findById(id);
if (!book) return res.status(404).json({ error: 'book not found' });
req.book = book; // loaded once, reused by every route below
next();
});
router.get('/:bookId', (req, res) => res.json(req.book));
router.get('/:bookId/reviews', async (req, res) => res.json(await reviews.forBook(req.book.id)));
Query strings. For GET /books?author=Herbert&tag=scifi&tag=classic&page=2, Express parses the query into req.query. Two parsers are available:
query parser setting | ?tag=a&tag=b&page=2 | ?filter[year][gte]=1960 |
|---|---|---|
'simple' (Express 5 default, Node's querystring) | { tag: ['a', 'b'], page: '2' } | { 'filter[year][gte]': '1960' } |
'extended' (Express 4 default, the qs library) | { tag: ['a', 'b'], page: '2' } | { filter: { year: { gte: '1960' } } } |
false | {} (parsing disabled) | {} |
Express 5 switched to the simple parser because nested objects created from URLs were a recurring source of security bugs (unexpected types, deeply nested input). Enable it with app.set('query parser', 'extended') only if you need it.
The type-confusion trap. Query values can be a string, an array of strings, undefined or (with the extended parser) an object, depending on what the client sends:
app.get('/search', (req, res) => {
const term = req.query.q.toLowerCase(); // crashes on /search (undefined) and on /search?q=a&q=b (array)
// ...
});
app.get('/search', (req, res) => {
const raw = req.query.q;
const term = typeof raw === 'string' ? raw.trim().toLowerCase() : ''; // accept only a single string
const page = Math.max(1, Number.parseInt(req.query.page ?? '1', 10) || 1);
res.json({ term, page });
});
In 12.3 this kind of normalisation is done declaratively with validation schemas. Note also that in Express 5 req.query is a read-only getter: assigning req.query = ... throws in strict-mode code (ES modules are always strict), so validated values must be stored elsewhere (for example req.valid).
Key Takeaways
- A route is method + path pattern + handlers; the first matching route wins, so register specific paths before parameterised ones.
- Express 5 path syntax:
:nameparameters,{...}optional parts, named wildcards*name; regular expressions only asRegExpobjects. - Route parameters and query values are strings (or arrays) chosen by the client; convert and validate them.
- Express 5 uses the simple query parser by default and makes
req.queryread-only.
Practice
- (Beginner) Create routes for
/files/*pathand/books{/:format}and logreq.paramsfor several URLs, including edge cases such as/files/and/books/.- (Intermediate) Write
GET /api/booksthat supports?author=, repeated?tag=parameters,?page=and?limit=(maximum 100). Make it robust to missing, repeated and non-numeric values, and return400with a clear message for invalid input.- (Interview) "Why is
req.params.id === 42always false in Express, and what can go wrong withNumber(req.params.id)?" Hint: consider the types the router produces and whatNumberreturns for'','1e3','0x10'and' 7 '.
Route Handlers and express.Router
A route can have several handlers. Each one receives (req, res, next) and either responds or calls next() to pass control to the following handler. This lets small, reusable functions (authentication, validation, loading a resource) be combined per route:
app.get('/admin/reports', authenticate, requireRole('admin'), listReports);
app.post('/books', [authenticate, validateBook], createBook); // arrays are flattened, so lists can be reused
Skipping the rest of a route. next('route') skips the remaining handlers of the current route and continues with the next matching route. It is useful for conditional behaviour such as feature flags:
app.get('/checkout', (req, res, next) => {
if (!req.user?.betaFeatures) return next('route'); // skip renderNewCheckout
next();
}, renderNewCheckout);
app.get('/checkout', renderClassicCheckout); // reached by users without the flag
Grouping methods on one path. app.route() avoids repeating a path:
app.route('/books')
.get(listBooks)
.post(createBook);
app.route('/books/:id')
.get(getBook)
.patch(updateBook)
.delete(deleteBook)
.all((req, res) => res.set('Allow', 'GET, PATCH, DELETE').sendStatus(405)); // other methods: 405, not 404
Without the final .all(), a PUT /books/42 would fall through to the 404 handler, because Express does not generate 405 Method Not Allowed on its own.
express.Router: mini-applications. As an API grows, defining every route on app in one file becomes unmanageable. express.Router() creates an isolated group of routes and middleware, a "mini-app" without its own server or settings, that can be mounted at a path:
// src/books/books.router.js
import { Router } from 'express';
export const booksRouter = Router();
booksRouter.get('/', listBooks); // GET /api/v1/books
booksRouter.post('/', createBook); // POST /api/v1/books
booksRouter.get('/:id', getBook); // GET /api/v1/books/42
// Nested router: reviews belong to a book
const reviewsRouter = Router({ mergeParams: true }); // makes :bookId from the parent visible
reviewsRouter.get('/', (req, res) => res.json(reviews.forBook(Number(req.params.bookId))));
booksRouter.use('/:bookId/reviews', reviewsRouter);
// src/app.js
app.use('/api/v1/books', booksRouter);
app.use('/api/v1/users', usersRouter);
When a router is mounted, Express strips the mount path from the URL the router sees and records it in req.baseUrl. For GET /api/v1/books/42/reviews?sort=new:
app
+-- /api/v1/books --> booksRouter req.baseUrl = '/api/v1/books' req.path = '/42/reviews'
+-- /:bookId/reviews --> reviewsRouter
req.baseUrl = '/api/v1/books/42/reviews' req.path = '/'
req.params = { bookId: '42' } (only with mergeParams: true)
req.originalUrl = '/api/v1/books/42/reviews?sort=new' (never changes)
Because a router does not know where it is mounted, the same router can be reused at different paths, and API versioning becomes a matter of mounting: app.use('/api/v1', v1Router) and app.use('/api/v2', v2Router).
Router options.
| Option | Default | Effect |
|---|---|---|
mergeParams | false | Keep req.params from parent routers |
caseSensitive | false | Treat /Books and /books as different |
strict | false | Treat /books and /books/ as different |
next('router') leaves the current router entirely and continues with whatever comes after it in the parent, for example to bypass a versioned router for certain clients.
Key Takeaways
- A route can chain several handlers; each responds or calls
next(). Arrays of handlers can be reused. next('route')skips to the next matching route;next('router')exits the current router.app.route(path)groups handlers for one path; add.all()to return405for unsupported methods.express.Router()creates mountable mini-apps; mounting strips the prefix intoreq.baseUrl, andmergeParamsexposes parent parameters.
Practice
- (Beginner) Split an app with
/booksand/authorsroutes into two routers in separate files, mounted under/api/v1.- (Intermediate) Add nested routes
/api/v1/authors/:authorId/booksthat reuse a books router. Logreq.baseUrl,req.pathandreq.paramsat each level, with and withoutmergeParams.- (Interview) "How would you version a public REST API in Express so that v1 and v2 share most of their code?" Hint: consider mounting routers, a shared service layer, and a v2 router that delegates unchanged paths to v1 with
next('router')or reuses its handlers.
Project Structure and Configuration
Express prescribes no structure, so small projects often end up as one app.js with hundreds of routes, database queries inside handlers and settings read from process.env everywhere. That code is hard to test (every test needs HTTP and a database), hard to change (the same rule is copied into many handlers) and hard to deploy (missing configuration is only discovered when a request fails). A clear structure separates what the HTTP layer does from what the business rules are from how data is stored.
Layered architecture.
HTTP request
|
v
+-----------------+ paths, middleware, mapping URL -> handler
| router |
+--------+--------+
v
+-----------------+ reads req (params, query, body), calls a service,
| controller | turns the result into a response (often inline in small apps)
+--------+--------+
v
+-----------------+ business rules: "an ISBN must be unique", "only owners may delete"
| service | no req/res: usable from HTTP, CLI scripts, queues and tests
+--------+--------+
v
+-----------------+ data access: SQL, MongoDB, external APIs; one interface,
| repository | several implementations (real, in-memory for tests)
+-----------------+
The key rule: dependencies point downwards. Services never import Express; repositories never know about HTTP status codes. This is what makes each layer replaceable and testable.
Organising files. Two common layouts:
| Layout | Example | Strength | Weakness |
|---|---|---|---|
| By layer | routes/, controllers/, services/, models/ | Familiar, fine for small apps | One feature is spread over many folders |
| By feature (vertical slices) | books/, users/, orders/, each with its router, service, repository, schemas | Features stay together; easy to find, move or delete | Shared code needs a clear home (lib/, middleware/) |
A feature-based layout for a medium-sized API:
src/
app.js createApp(deps): middleware, routers, 404, error handler (no listen)
server.js loads config, creates real dependencies, listens, shuts down
config.js validated, frozen configuration object
lib/
http-error.js HttpError class (12.2)
logger.js
middleware/
authenticate.js
validate.js
error-handler.js
books/
books.router.js routes + handlers
books.service.js business rules
books.repository.js data access
books.schemas.js validation schemas (12.3)
users/
...
test/
books.test.js
Dependency injection with an app factory. Instead of each module importing a global database connection, createApp() receives its dependencies. Production passes real ones; tests pass in-memory ones:
// src/app.js
import express from 'express';
import { createBooksRouter } from './books/books.router.js';
import { createBookService } from './books/books.service.js';
export function createApp({ bookRepository, authenticate, logger }) {
const app = express();
app.use(express.json());
app.use('/api/v1/books', createBooksRouter({ books: createBookService(bookRepository), authenticate, logger }));
return app;
}
// src/server.js
import { config } from './config.js';
import { createApp } from './app.js';
import { createPgBookRepository } from './books/books.repository.js';
import { authenticateJwt } from './middleware/authenticate-jwt.js';
import { createPool } from './lib/db.js';
const pool = createPool(config.DATABASE_URL);
const app = createApp({
bookRepository: createPgBookRepository(pool), // tests pass an in-memory repository instead
authenticate: authenticateJwt, // ...and a fake authentication middleware
logger: console,
});
app.listen(config.PORT);
Configuration. Following the twelve-factor app principle, configuration that differs between environments (ports, database URLs, secrets, allowed origins) comes from environment variables, not from code. Node can load a .env file itself (node --env-file=.env, 11.5), so packages such as dotenv are optional. Environment variables are untyped strings, so validate them once at start-up and fail fast:
// src/config.js
import { z } from 'zod';
const schema = z.object({
NODE_ENV: z.enum(['development', 'test', 'production']).default('development'),
PORT: z.coerce.number().int().min(1).max(65535).default(3000), // "3000" -> 3000
DATABASE_URL: z.string().url(),
SESSION_SECRET: z.string().min(32, 'use at least 32 random characters'),
CORS_ORIGINS: z.string().default('')
.transform((s) => s.split(',').map((o) => o.trim()).filter(Boolean)), // "a,b" -> ['a', 'b']
LOG_LEVEL: z.enum(['fatal', 'error', 'warn', 'info', 'debug', 'trace']).default('info'),
});
const parsed = schema.safeParse(process.env);
if (!parsed.success) {
console.error('Invalid configuration:');
for (const issue of parsed.error.issues) console.error(` ${issue.path.join('.')}: ${issue.message}`);
process.exit(1); // crash at start-up, not on the first request that needs the value
}
export const config = Object.freeze(parsed.data);
{
"type": "module",
"scripts": {
"dev": "node --watch --env-file=.env src/server.js",
"start": "node src/server.js",
"test": "node --env-file=.env.test --test"
}
}
In production, the platform (Docker, Kubernetes, a PaaS) sets the variables, so start does not load a file. Commit a .env.example with placeholder values and never commit real secrets (Chapter 17).
What NODE_ENV=production changes. Express caches compiled templates and hides stack traces in its default error page; many libraries also enable optimisations. Set it in every production deployment, but do not use it as the only switch for application features: prefer explicit configuration variables.
Key Takeaways
- Separate routing/HTTP, business rules (services) and data access (repositories); dependencies point only downwards.
- Organising by feature keeps related code together as the application grows.
- An app factory that receives its dependencies makes the application easy to test and to wire differently per environment.
- Read configuration from environment variables, validate and type it once at start-up, freeze it, and fail fast when it is invalid.
Practice
- (Beginner) Write a
config.jsthat requiresPORTandDATABASE_URL, start the app without them and check that it exits with a clear message.- (Intermediate) Refactor a single-file Express app (routes with inline data access) into router, service and repository layers for one feature. Then write a test that uses the service with an in-memory repository, without HTTP.
- (Interview) "Where should business rules live in an Express application, and why not in route handlers?" Hint: think about reuse from other entry points (queues, CLI scripts), testability without HTTP, and what happens when the same rule is needed by two routes.
<a id="122-the-middleware-pipeline"></a>
12.2 The Middleware Pipeline
This subchapter explains the idea at the heart of Express: a request passes through a chain of middleware functions, each of which can inspect it, modify it, answer it or hand it on. It covers execution order, the levels at which middleware can be attached, the built-in and most common third-party middleware, how to write your own, and how errors (synchronous and asynchronous) travel through the pipeline.
Middleware Concept and Execution Order
Many concerns apply to many routes at once: logging, security headers, CORS, parsing bodies, checking authentication, compressing responses. Putting that code into every route handler would duplicate it everywhere. Middleware solves this by turning request handling into a pipeline of small functions, each responsible for one concern.
An analogy is airport departure. Every passenger passes check-in, security and passport control before reaching the gate. Each checkpoint can let you continue, turn you away, or call for help if something is wrong; and some checkpoints only apply to some passengers (passport control only for international flights). Middleware works the same way.
The signature. A middleware function receives the request, the response and a function next:
function middleware(req, res, next) {
// 1. inspect or modify req / res
// 2. then exactly one of:
// - send a response (the pipeline stops here)
// - call next() (continue with the next middleware or route)
// - call next(err) (skip to error-handling middleware)
}
Route handlers are middleware too; they are simply the last in line and normally respond instead of calling next().
request
|
v
+--------+ next() +--------------+ next() +--------------+ next() +---------------+
| logger | -------> | express.json | -------> | authenticate | -------> | route handler | --> res.json(...)
+--------+ +--------------+ +------+-------+ +---------------+
|
res.status(401)| or next(err)
v
response sent / error-handling middleware
Execution order follows registration order. Express keeps one ordered list of middleware and routes. For each request it walks the list from the top and runs every entry whose method and path match, until something responds:
import express from 'express';
const app = express();
app.use((req, res, next) => { console.log('1 app-level: every request'); next(); });
app.use('/api', (req, res, next) => { console.log('2 only paths under /api'); next(); });
app.get('/api/hello', (req, res) => {
console.log('3 route handler');
res.send('hi');
console.log('4 still runs: sending does not stop the function');
});
app.use((req, res) => { // reached only if nothing above responded
console.log('5 not found');
res.status(404).send('not found');
});
app.listen(3000);
// GET /api/hello -> 1, 2, 3, 4
// GET /other -> 1, 5
// GET /api/nope -> 1, 2, 5
Running code after the response. Express middleware is a linear chain: next() starts the next function but does not return a promise that waits for it. Code after await next() therefore runs too early:
app.use(async (req, res, next) => {
const start = performance.now();
await next(); // next() returns undefined: nothing is awaited
console.log(performance.now() - start); // about 0 ms, measured before the handler finished
});
// Correct: listen for the response's 'finish' event (or 'close' for aborted requests)
app.use((req, res, next) => {
const start = performance.now();
res.on('finish', () => {
console.log(`${req.method} ${req.originalUrl} ${res.statusCode} ${(performance.now() - start).toFixed(1)}ms`);
});
next();
});
Koa (12.3) uses a different design, the "onion" model, in which await next() really waits for everything downstream.
Two classic bugs.
| Bug | Symptom | Fix |
|---|---|---|
Middleware neither responds nor calls next() | The request hangs until the client or server times out | Every code path must end in a response, next() or next(err) |
Middleware responds and calls next() | ERR_HTTP_HEADERS_SENT when a later handler also responds | return after responding |
Key Takeaways
- Middleware is a function
(req, res, next)that handles one concern and then responds, callsnext()or callsnext(err). - Express runs matching middleware and routes in registration order; nothing after a response runs unless it was already called.
next()does not wait for downstream code; useres.on('finish')for work after the response.- A request hangs when no code path responds or calls
next; it fails when two paths respond.
Practice
- (Beginner) Write three middleware functions that log their names and register them so that a request to
/api/xlogs them in the order B, A, C. Explain the order using registration order and path matching.- (Intermediate) Implement a response-time logger that records method, URL, status and duration, and also logs requests whose client disconnected before the response finished (compare the
finishandcloseevents).- (Interview) "In Express, why does
await next()not let you measure how long the rest of the pipeline took?" Hint: look at whatnext()returns, and compare with Koa's composition of middleware as nested promises.
Application, Router and Route-Level Middleware
Middleware can be attached at different levels, which decides which requests it runs for. Choosing the right level keeps concerns local: security headers apply to everything, authentication to a group of routes, a specific validation to one route.
| Level | Registered with | Runs for |
|---|---|---|
| Application | app.use(fn) | Every request |
| Application, with path | app.use('/admin', fn) | Requests whose path is /admin or starts with /admin/ |
| Router | router.use(fn) | Every request that enters that router |
| Route | app.get('/x', fn, handler) | Only that method and path |
| Error-handling | app.use((err, req, res, next) => ...) | Only after next(err) or a thrown error (see below) |
Path prefixes. app.use('/admin', fn) matches /admin, /admin/ and /admin/users, but not /administrator: matching works on whole path segments. Inside fn, the prefix is stripped: req.baseUrl is '/admin' and req.path is the rest. Route methods (app.get(...)) instead match the whole path.
A realistic arrangement.
import express, { Router } from 'express';
const app = express();
app.use(helmet()); // application level: every response gets security headers
app.use(express.json());
const publicRouter = Router();
publicRouter.get('/health', (req, res) => res.json({ status: 'ok' }));
publicRouter.post('/auth/login', login);
const adminRouter = Router();
adminRouter.use(authenticate, requireRole('admin')); // router level: everything under /api/admin
adminRouter.get('/users', listUsers);
adminRouter.delete('/users/:id', auditLog('user.delete'), deleteUser); // route level: only this route
app.use('/api', publicRouter);
app.use('/api/admin', adminRouter);
Which middleware runs for each request:
| Request | helmet | json | authenticate + requireRole | auditLog |
|---|---|---|---|---|
GET /api/health | yes | yes | no | no |
GET /api/admin/users | yes | yes | yes | no |
DELETE /api/admin/users/7 | yes | yes | yes | yes |
GET /api/admin/nothing-here | yes | yes | yes (then 404) | no |
The router-level trap. Router-level middleware runs for every request that enters the router, even if none of its routes match. Mounting a protected router at a shared prefix therefore protects more than intended:
const privateRouter = Router();
privateRouter.use(authenticate); // runs for EVERY request entering this router
privateRouter.get('/orders', listOrders);
app.use('/api', privateRouter); // registered first, at /api...
app.use('/api', publicRouter); // ...so GET /api/health now requires a login: it enters privateRouter first
Fixes: mount routers with different authentication needs at distinct prefixes (/api/public, /api/private), register public routers first, or attach authentication at route level.
Key Takeaways
- Application-level middleware applies to all requests (optionally under a path prefix), router-level to requests entering the router, route-level to one route.
app.use(path, ...)matches path prefixes by segment and strips the prefix intoreq.baseUrl; route methods match the full path.- Router-level middleware runs even when no route in the router matches; mind where routers are mounted.
- Attach each concern at the narrowest level that covers all the routes that need it.
Practice
- (Beginner) Build the arrangement above with stub functions that log their names, and verify the table by sending each request.
- (Intermediate) Reproduce the router-level trap, then fix it in two different ways. Write down which fix you would prefer in a large codebase and why.
- (Interview) "Where would you put authentication middleware in an Express app with both public and private endpoints?" Hint: compare app-level with an allowlist, router-level with separate prefixes, and route-level; consider what happens when a developer adds a new route and forgets something.
Built-In Middleware
Express 3 shipped with about twenty bundled middleware functions from Connect. Express 4 removed almost all of them so they could evolve independently, and later re-added the most essential ones. Express 5 includes five:
| Middleware | Based on | Purpose |
|---|---|---|
express.json() | body-parser | Parse application/json bodies into req.body |
express.urlencoded() | body-parser | Parse HTML form posts (application/x-www-form-urlencoded) |
express.text() | body-parser | Read text/plain bodies as a string |
express.raw() | body-parser | Read bodies as a Buffer (for signatures and binary data) |
express.static(root) | serve-static | Serve files from a directory |
Why body parsing is opt-in. The request body is a stream (11.4). Reading it costs memory and time, the right format and size limit differ by route, and some routes (file uploads, proxies) must not have their body consumed at all. Express therefore leaves the decision to you.
Body parsers.
app.use(express.json({ limit: '100kb' })); // the default limit, stated explicitly
app.use(express.urlencoded({ extended: false, limit: '20kb' })); // HTML forms
| Option | Default | Meaning |
|---|---|---|
limit | '100kb' | Maximum body size; larger bodies are rejected with 413 |
type | the parser's MIME type | Which Content-Type values to parse (string, array or function) |
strict (json) | true | Accept only objects and arrays at the top level |
extended (urlencoded) | false in Express 5 | true uses qs and allows nested objects like a[b]=1 |
inflate | true | Accept gzip/deflate/brotli-encoded bodies |
verify | none | verify(req, res, buf) callback to inspect the raw bytes |
Each parser runs only when the request's Content-Type matches; otherwise it calls next() without touching the body. In Express 5, req.body is then undefined (in Express 4 it was {}), so code like const { title } = req.body throws a TypeError when a client forgets the Content-Type header. Validation (12.3) handles this.
Body-parser failures are passed to next(err) with a status and a type, which an error handler can map to clear responses:
err.type | err.status | Cause |
|---|---|---|
'entity.parse.failed' | 400 | Malformed JSON |
'entity.too.large' | 413 | Body above limit |
'encoding.unsupported' | 415 | Unknown Content-Encoding |
'charset.unsupported' | 415 | Unsupported charset |
'request.aborted' | 400 | Client disconnected during upload |
Per-route limits and the ordering trap. Once a body has been parsed, later parsers skip it. A global parser with a small limit therefore rejects large bodies before a route-specific parser with a larger limit is reached. Register the special route first, or apply the global parser only to the routers that need it:
app.post('/api/imports', express.json({ limit: '5mb' }), importBooks); // registered before the global parser
app.use(express.json({ limit: '100kb' })); // all other routes
Raw bodies for webhook signatures. Payment providers and similar services sign the exact bytes they send. Parsing and re-serialising JSON can change those bytes (key order, whitespace), so the signature must be checked on the raw body:
import { createHmac, timingSafeEqual } from 'node:crypto';
app.post('/webhooks/payments',
express.raw({ type: 'application/json', limit: '1mb' }), // req.body is a Buffer with the exact bytes
(req, res) => {
const expected = createHmac('sha256', process.env.WEBHOOK_SECRET).update(req.body).digest('hex');
const received = req.get('X-Signature') ?? '';
const valid = received.length === expected.length && // timingSafeEqual requires equal lengths
timingSafeEqual(Buffer.from(received), Buffer.from(expected));
if (!valid) return res.sendStatus(401);
const event = JSON.parse(req.body.toString('utf8')); // parse only after verifying
paymentEvents.enqueue(event);
res.sendStatus(204);
});
app.use(express.json()); // registered after: the webhook keeps its raw body
Static files. express.static(root, options) serves files from a directory, with correct MIME types, ETag/Last-Modified validation, Range requests and protection against path traversal (/../../etc/passwd is rejected).
import path from 'node:path';
const publicDir = path.join(import.meta.dirname, '..', 'public');
app.use('/static', express.static(publicDir, {
maxAge: '1d', // Cache-Control: public, max-age=86400
index: false, // do not serve index.html for directory requests
dotfiles: 'ignore', // the Express 5 default: .env, .git are invisible
setHeaders(res, filePath) {
if (filePath.endsWith('.pdf')) res.set('Content-Disposition', 'inline');
},
}));
| Option | Default | Meaning |
|---|---|---|
maxAge | 0 | Cache-Control max-age (milliseconds or a string such as '1y') |
immutable | false | Adds immutable to Cache-Control; use with fingerprinted file names |
index | 'index.html' | File served for directory paths; false to disable |
dotfiles | 'ignore' | 'allow', 'deny' (403) or 'ignore' (404) for names starting with . |
fallthrough | true | On a missing file call next(); false sends a 404 error instead |
etag, lastModified | true | Validation headers for conditional requests |
setHeaders | none | Function to add headers per file |
Several static directories can be stacked; Express tries them in registration order. Never point express.static at the project root, which would expose source code and configuration.
Key Takeaways
- Express 5 includes
json,urlencoded,text,rawandstatic; everything else is a separate package. - Body parsers run only for matching
Content-Types, enforce a size limit (default 100 KB) and leavereq.bodyundefinedotherwise. - The first parser to read a body wins: order route-specific parsers before global ones.
- Use
express.raw()when the exact bytes matter (webhook signatures); useexpress.static()with explicit caching and dotfile options.
Practice
- (Beginner) Send the same JSON body with and without
Content-Type: application/jsonand logreq.bodyin each case. Then send malformed JSON and a 200 KB body and note the status codes.- (Intermediate) Implement a webhook endpoint that verifies an HMAC signature over the raw body, plus a script that signs and sends test events. Show that pretty-printing the JSON before sending breaks verification.
- (Interview) "A route that accepts large JSON imports keeps returning 413 even though it uses
express.json({ limit: '10mb' }). Why?" Hint: think about which parser reads the body first and what later parsers do with an already parsed request.
Third-Party Middleware
Most functionality around Express lives in separate npm packages that follow the same (req, res, next) contract. The table lists the ones found in most production applications:
| Package | Purpose | Chapter reference |
|---|---|---|
helmet | Sets security headers (CSP, HSTS, X-Content-Type-Options, ...) | 17.1 |
cors | Answers CORS preflights and sets Access-Control-* headers | 5.4, 17.1 |
compression | gzip/brotli compression of responses | 10.1 |
pino-http, morgan | Request logging (structured JSON / text lines) | 11.6 |
cookie-parser | Parses the Cookie header into req.cookies, verifies signed cookies | 12.3 |
express-session | Server-side sessions | 10.2, 12.3 |
express-rate-limit | Limits requests per client and time window | 10.4 |
multer | multipart/form-data file uploads | 12.3 |
passport | Authentication strategies (local, OAuth, JWT, ...) | 12.3 |
http-proxy-middleware | Forwards requests to other services | 12.3 |
csrf-csrf, csrf-sync | CSRF tokens (the older csurf is deprecated) | 17.1 |
A typical production stack, in order. Order follows a simple logic: observe everything first, reject cheaply early, do expensive work late.
import express from 'express';
import helmet from 'helmet';
import cors from 'cors';
import compression from 'compression';
import pinoHttp from 'pino-http';
import { rateLimit } from 'express-rate-limit';
import cookieParser from 'cookie-parser';
export function createApp({ config, logger }) {
const app = express();
app.set('trust proxy', 1); // needed for correct req.ip in logs and rate limits
app.use(pinoHttp({ logger })); // 1. log every request, including rejected ones
app.use(helmet()); // 2. security headers on every response, even errors
app.use(cors({ // 3. answer preflights before auth and body parsing
origin: config.CORS_ORIGINS, // explicit allowlist (an array of origins)
credentials: true, // allow cookies; incompatible with origin '*'
maxAge: 600, // browsers may cache the preflight for 10 minutes
}));
app.use(compression()); // 4. omit if the reverse proxy already compresses
app.use('/api', rateLimit({ // 5. reject floods before doing real work
windowMs: 60_000,
limit: 120, // 120 requests per minute per IP
standardHeaders: 'draft-7', // RateLimit header as in the IETF draft
legacyHeaders: false, // no X-RateLimit-* headers
}));
app.use(cookieParser(config.COOKIE_SECRET)); // 6. cookies, then bodies
app.use(express.json({ limit: '100kb' }));
// 7. routers, 8. 404 handler, 9. error handler
return app;
}
helmet sets about a dozen headers with safe defaults, among them Content-Security-Policy, Strict-Transport-Security, X-Content-Type-Options: nosniff, X-Frame-Options: SAMEORIGIN, Referrer-Policy: no-referrer and Cross-Origin-Opener-Policy, and removes X-Powered-By. The default CSP is strict; pages that load scripts or images from other origins need adjustments, which are merged with the defaults:
app.use(helmet({
contentSecurityPolicy: {
directives: {
'script-src': ["'self'", 'https://cdn.example.com'],
'img-src': ["'self'", 'data:', 'https://images.example.com'],
},
},
}));
cors accepts a function for dynamic decisions, for example allowing any subdomain of your own site:
app.use(cors({
origin(origin, callback) {
// origin is undefined for same-origin requests and non-browser clients (curl, servers)
const allowed = !origin || /^https:\/\/([a-z0-9-]+\.)?example\.com$/.test(origin);
callback(null, allowed); // true -> echo the origin; false -> no CORS headers
},
credentials: true,
}));
Rate limiting with several instances. By default express-rate-limit counts in the memory of one process. With several instances behind a load balancer each keeps its own count, so the effective limit is multiplied. Use a shared store (for example rate-limit-redis, Chapter 13) and stricter limits on sensitive routes such as login.
Choosing a package. Middleware runs on every request and often handles untrusted input, so evaluate it like any dependency (11.2): maintenance activity and open security advisories, Express 5 compatibility, number of transitive dependencies, and whether the problem is small enough to solve in twenty lines yourself.
Key Takeaways
- Most Express features come from third-party middleware with the same
(req, res, next)contract. - Order the stack deliberately: logging, security headers, CORS, compression, rate limiting, cookies, bodies, routes, 404, errors.
- Configure
helmetandcorsexplicitly for your origins; never combinecredentials: truewith a wildcard origin. - Rate-limit counters must live in a shared store when the app runs on several instances.
Practice
- (Beginner) Add
helmetto an app and compare the response headers before and after withcurl -I. Look up what three of the new headers do.- (Intermediate) Configure
corsfor a front end athttp://localhost:5173that sends cookies. From the browser console, verify that aPUTwith a JSON body triggers a preflight that succeeds, and that the same request from another origin fails.- (Interview) "Why does the position of the rate limiter and the logger in the middleware stack matter?" Hint: consider which requests each one sees if placed before or after authentication, body parsing and static files, and what you lose in logs if the limiter rejects first.
Writing Custom Middleware
Application-specific cross-cutting concerns, such as loading the current user, checking roles, attaching request IDs or switching to maintenance mode, are best written as your own middleware. A few patterns cover nearly every case.
1. A plain function. For behaviour that needs no configuration:
import { randomUUID } from 'node:crypto';
const VALID_ID = /^[\w-]{8,64}$/;
export function requestId(req, res, next) {
const incoming = req.get('X-Request-Id');
req.id = incoming && VALID_ID.test(incoming) ? incoming : randomUUID(); // never log arbitrary client input
res.set('X-Request-Id', req.id); // lets clients and proxies correlate logs
next();
}
2. A factory for configurable middleware. A function that takes options and returns the middleware. Options are checked once at start-up, not on every request:
export function requireRole(...roles) {
if (roles.length === 0) throw new Error('requireRole() needs at least one role'); // fails at boot
return (req, res, next) => {
if (!req.user) return res.status(401).json({ error: 'authentication required' });
if (!roles.includes(req.user.role)) return res.status(403).json({ error: 'insufficient permissions' });
next();
};
}
router.delete('/:id', requireRole('admin', 'editor'), deleteBook);
export function maintenanceMode({ isEnabled, allowPaths = ['/health'] }) {
return (req, res, next) => {
if (!isEnabled() || allowPaths.some((p) => req.path.startsWith(p))) return next();
res.set('Retry-After', '300').status(503).json({ error: 'down for maintenance, try again later' });
};
}
app.use(maintenanceMode({ isEnabled: () => featureFlags.get('maintenance') }));
3. Asynchronous middleware. Express 5 forwards a rejected promise to the error handler automatically (see "Handling Async Errors"), so async middleware can simply await:
export function loadCurrentUser(users) {
return async (req, res, next) => {
const userId = req.session?.userId;
if (userId) req.user = (await users.findById(userId)) ?? undefined; // a DB failure goes to the error handler
next();
};
}
4. Composition. Middleware lists are just arrays, so related steps can be bundled under one name:
export const adminOnly = [authenticate, requireRole('admin')];
router.get('/stats', adminOnly, getStats);
router.delete('/users/:id', adminOnly, auditLog('user.delete'), deleteUser);
Where to store per-request data.
| Location | Typical content | Visible to |
|---|---|---|
req.user, req.id, req.valid | Data about the request and caller | All later middleware and handlers |
res.locals | Data for rendering the response (flash messages, CSRF token, current user for templates) | Later middleware, handlers and templates (merged into res.render locals) |
app.locals | Application-wide constants | Everything, all requests: never per-request data |
In TypeScript, properties added to req must be declared through declaration merging, so every file sees the same type:
// src/types/express.d.ts
declare global {
namespace Express {
interface Request {
id: string;
user?: { id: string; role: 'admin' | 'editor' | 'member' };
}
}
}
export {};
Checklist for good middleware.
- Every code path ends with a response,
next()ornext(err), andreturns after responding. - It does one thing and is named after it (
requireRole, notchecks). - Configuration is passed to a factory and validated at start-up.
- It does not trust client input it attaches to
reqor logs (validate IDs, limit lengths). - Expensive work runs only when needed (check the path or method first).
- It is exported separately so it can be unit tested (see "Testing Express Applications").
Key Takeaways
- Write middleware as plain functions for fixed behaviour and as factories for configurable behaviour.
asyncmiddleware is safe in Express 5 because rejections reach the error handler.- Arrays of middleware compose naturally into reusable bundles such as
adminOnly. - Put request data on
req, view data onres.locals, and only constants onapp.locals; declare added properties in TypeScript with declaration merging.
Practice
- (Beginner) Write a middleware that rejects requests without an
X-Api-Keyheader matching a value from configuration, returning401with a JSON error.- (Intermediate) Write a factory
cacheControl({ maxAge, private: isPrivate })that setsCache-Controlonly for successfulGETresponses. (Hint: headers must be set before the response is sent; compare setting them up front with checkingres.statusCodelater.)- (Interview) "Design a middleware that enforces per-user feature flags for a set of routes. What would its API look like, and how would you test it?" Hint: think about a factory taking the flag name, where the flag data comes from (cached), what status to return when the flag is off, and how to inject a fake flag store in tests.
Error-Handling Middleware
Errors happen in every layer: malformed JSON, failed validation, a missing record, a database timeout, a bug. Without a plan, each handler formats errors differently, some leak stack traces, and some forget to respond at all. Express provides one central place for all of this: error-handling middleware.
The four-argument signature. Express recognises an error handler by its arity: a function declared with exactly four parameters.
app.use((err, req, res, next) => { // four parameters: this is an error handler
res.status(500).json({ error: 'internal server error' });
});
Express checks fn.length, so all four parameters must be declared even if next is unused. A default value or rest parameter reduces length ((err, req, res, next = noop) => {} has length 3) and silently turns the handler into normal middleware.
How errors travel. When a middleware calls next(err) or throws (synchronously, or by rejecting a promise in Express 5), Express skips all remaining normal middleware and routes and jumps to the next error-handling middleware:
normal chain: logger --> json --> auth --> route handler
| |
next(err) ----+ +---- throw / rejected promise
v v
error chain: (all normal middleware below is skipped)
errorReporter(err, ...) --next(err)--> errorResponder(err, ...) --> response
next('route') and next('router') are the only string values with special meaning; any other argument passed to next is treated as an error.
An error class carrying HTTP information. Throwing plain Errors loses the intended status. A small class lets any layer say what went wrong; the error handler decides how to present it:
// src/lib/http-error.js
export class HttpError extends Error {
constructor(status, message, { code, details, headers, cause } = {}) {
super(message, { cause });
this.name = 'HttpError';
this.status = status;
this.code = code; // stable machine-readable code, e.g. 'BOOK_NOT_FOUND'
this.details = details; // e.g. a list of validation issues
this.headers = headers; // e.g. { 'Retry-After': '30' }
this.expose = status < 500; // client errors may be shown; server errors may not
}
}
(The http-errors package, used internally by Express, provides the same idea: createError(404, 'book not found').)
A complete error handler. It normalises different error kinds, logs according to severity, hides internals in production and answers with a consistent format. Here the format is Problem Details (RFC 9457, 10.4):
// src/middleware/error-handler.js
import { HttpError } from '../lib/http-error.js';
export function notFound(req, res, next) { // normal middleware, registered after all routes
next(new HttpError(404, `no route for ${req.method} ${req.path}`, { code: 'ROUTE_NOT_FOUND' }));
}
function normalize(err) {
if (err instanceof HttpError) return err;
if (err.type === 'entity.parse.failed') return new HttpError(400, 'malformed JSON body', { code: 'INVALID_JSON' });
if (err.type === 'entity.too.large') return new HttpError(413, 'request body too large', { code: 'BODY_TOO_LARGE' });
const status = Number.isInteger(err.status) && err.status >= 400 && err.status < 600 ? err.status : 500;
const message = status < 500 && err.expose ? err.message : 'internal server error'; // http-errors convention
return new HttpError(status, message, { cause: err });
}
export function errorHandler({ isProduction }) {
return (err, req, res, next) => {
if (res.headersSent) return next(err); // response already started: let Express abort the connection
const httpErr = normalize(err);
const log = req.log ?? console; // pino-http attaches req.log
if (httpErr.status >= 500) log.error({ err, requestId: req.id }, 'request failed');
else log.warn({ status: httpErr.status, code: httpErr.code, requestId: req.id }, httpErr.message);
if (httpErr.headers) res.set(httpErr.headers);
res.status(httpErr.status).type('application/problem+json').json({
type: 'about:blank',
title: httpErr.message,
status: httpErr.status,
code: httpErr.code,
errors: httpErr.details, // validation issues, if any
instance: req.originalUrl,
requestId: req.id, // users can quote it to support
...(!isProduction && { stack: err.stack }),// never send stack traces in production
});
};
}
// src/app.js (end of the pipeline)
app.use('/api/v1/books', booksRouter);
app.use(notFound); // after all routes
app.use(errorHandler({ isProduction: config.NODE_ENV === 'production' })); // last
Handlers can now simply throw:
router.get('/:id', async (req, res) => {
const book = await books.findById(Number(req.params.id));
if (!book) throw new HttpError(404, 'book not found', { code: 'BOOK_NOT_FOUND' });
res.json(book);
});
Express's default error handler. If no custom handler responds, Express's built-in one does. It uses err.status (or err.statusCode) if it is a valid 4xx/5xx code, otherwise 500; sets err.headers; sends an HTML page with the stack trace in development and only the status message in production; and, if headers were already sent, destroys the connection. A custom handler should keep that last behaviour by delegating with next(err) when res.headersSent is true.
404 is not an error handler. "No route matched" is detected by an ordinary middleware registered after all routes (notFound above). It runs only when nothing responded, and passes a 404 error into the error chain so all errors share one format.
Chaining error handlers. Several error handlers can be stacked, for example one that reports to an error-tracking service and calls next(err), followed by the one that responds. Error handlers can also be attached to a router to give part of an API a different format (an HTML error page for /admin, JSON for /api).
Key Takeaways
- Error-handling middleware has exactly four declared parameters and is registered after all routes.
next(err)or a thrown error skips normal middleware and jumps to the next error handler.- Use an error class carrying status, code and details; normalise library errors (body parser, validation) in one place.
- Log server errors with stack traces, return a consistent format, hide internals in production, and delegate to Express when headers were already sent.
Practice
- (Beginner) Add
notFoundanderrorHandlerto an app, then trigger a 404, malformed JSON, a thrownHttpError(409)and aTypeError, and compare the four responses.- (Intermediate) Add a second error handler in front of the responder that counts errors by status code and code in memory, exposed at
GET /internal/error-stats. Verify that the counting handler never sends a response itself.- (Interview) "Why does Express use the number of declared parameters to identify error handlers, and what problems can this cause?" Hint: consider
Function.prototype.length, default parameters, wrappers that use(...args), and how minifiers or decorators might change the signature.
Handling Async Errors
Most handlers are asynchronous: they await databases, files and other services. The question is what happens when an awaited operation fails.
The problem in Express 4. Express 4 called handlers synchronously and caught only synchronous exceptions. An async function never throws synchronously: it returns a rejected promise, which Express 4 ignored:
// Express 4
app.get('/books/:id', async (req, res) => {
const book = await db.findBook(req.params.id); // the database is down: the promise rejects
res.json(book);
});
// Result: no response is ever sent (the client hangs until timeout), and Node reports an
// unhandled rejection, which crashes the process by default since Node 15.
The usual Express 4 workarounds were a try/catch in every handler, or a wrapper:
// Express 4: forward rejections to next() explicitly
const asyncHandler = (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next);
app.get('/books/:id', asyncHandler(async (req, res) => {
res.json(await db.findBook(req.params.id));
}));
The express-async-errors package did the same by patching Express globally.
Express 5: promises are first-class. Express 5 checks whether a middleware, route handler or router.param callback returns a promise and, if it rejects, calls next(err) with the rejection reason (or a generic error if the reason is falsy). Handlers can simply throw:
// Express 5: no wrapper needed
app.get('/books/:id', async (req, res) => {
const book = await db.findBook(req.params.id); // a rejection here reaches the error handler
if (!book) throw new HttpError(404, 'book not found');
res.json(book);
});
What Express 5 still cannot catch. Express only sees the promise returned by the function it called. Errors that occur outside that promise chain escape it:
import fs from 'node:fs';
// 1. Callback APIs: the callback runs later, on a new call stack, outside any promise Express sees
app.get('/legacy', (req, res) => {
fs.readFile('missing.txt', (err, data) => {
if (err) throw err; // uncaughtException: the process crashes instead of a 500
res.send(data);
});
});
// Fix: forward explicitly, or use the promise API
app.get('/legacy', (req, res, next) => {
fs.readFile('missing.txt', (err, data) => (err ? next(err) : res.send(data)));
});
app.get('/modern', async (req, res) => res.send(await fs.promises.readFile('missing.txt')));
// 2. Fire-and-forget promises: started but never awaited
app.post('/orders', async (req, res) => {
const order = await orders.create(req.body);
sendConfirmationEmail(order); // a rejection here is unhandled
res.status(201).json(order);
});
// Fix: handle the failure where the work is started
// sendConfirmationEmail(order).catch((err) => req.log.error({ err }, 'confirmation email failed'));
// or better, put the job in a queue that retries.
// 3. Timers and event emitters
app.get('/later', (req, res) => {
setTimeout(() => { throw new Error('boom'); }, 10); // escapes everything
});
Streams. Errors in streams are emitted as 'error' events, not thrown. Use pipeline from node:stream/promises, which turns stream errors into a rejected promise that Express 5 forwards:
import { createReadStream } from 'node:fs';
import { pipeline } from 'node:stream/promises';
app.get('/reports/:name', async (req, res) => {
const file = resolveReportPath(req.params.name); // validates the name, prevents path traversal
res.type('pdf');
await pipeline(createReadStream(file), res); // ENOENT, read errors, client aborts -> rejection
});
If the file is missing, the error occurs before any bytes are sent, and the error handler can still answer (map err.code === 'ENOENT' to 404 in normalize). If it fails half-way, res.headersSent is true and the error handler must delegate so the connection is closed, signalling to the client that the response is incomplete.
Requests that never finish. A handler awaiting a promise that never settles (a hung connection to another service) produces neither a response nor an error. Defend with time limits at each level:
// Outgoing calls: always set a timeout
const stock = await fetch(`${INVENTORY_URL}/stock/${id}`, { signal: AbortSignal.timeout(2_000) });
// Per-request deadline middleware
export function deadline(ms) {
return (req, res, next) => {
const timer = setTimeout(() => {
if (!res.headersSent) next(new HttpError(503, 'request timed out', { code: 'TIMEOUT' }));
}, ms);
res.on('close', () => clearTimeout(timer)); // response finished or client disconnected
next();
};
}
app.use('/api', deadline(10_000));
Node's server also has server.requestTimeout (default 300 seconds) and server.headersTimeout as a last resort.
Process-level safety nets. Errors that escape everything end in process.on('uncaughtException') and process.on('unhandledRejection'). Use them only to log and exit (11.5); the process supervisor then restarts it. Do not try to keep serving: the application may be in an inconsistent state.
Key Takeaways
- Express 4 ignored rejected promises; handlers needed try/catch or an
asyncHandlerwrapper. - Express 5 forwards rejected promises from middleware, handlers and
paramcallbacks to the error handler, soasynchandlers can simply throw. - Callbacks, timers, event emitters and un-awaited promises are outside Express's reach: forward with
next(err), use promise APIs,pipeline, or.catch()where work starts. - Put timeouts on outgoing calls and requests, and keep process-level handlers as log-and-exit safety nets.
Practice
- (Beginner) Write an
asyncroute that awaits a rejected promise and observe the behaviour in Express 5. Then install Express 4 in a separate folder and compare.- (Intermediate) Find the error-handling problems in a route that reads a file with
fs.readFilecallbacks, fires an un-awaited analytics call and pipes a stream with.pipe(). Rewrite it so that every failure reaches the error handler or is logged.- (Interview) "A colleague says Express 5 means we no longer need to think about async errors. Do you agree?" Hint: list which error sources go through the promise returned by the handler and which do not, and what happens to a request whose awaited promise never settles.
<a id="123-building-services-with-express"></a>
12.3 Building Services with Express
This subchapter combines the building blocks of 12.1 and 12.2 into complete services: a REST resource with validation, cookie- and session-based state, authentication with Passport and JWT, file uploads, server-rendered pages, hosting single-page applications and fronting other services, automated testing, and finally a comparison with the main alternative frameworks.
Implementing REST Endpoints
Chapter 10.4 described what a good REST API looks like: resources identified by URIs, HTTP methods mapped to operations, meaningful status codes, pagination and consistent errors. This topic implements one complete resource, a collection of books, using the project structure from 12.1 and the error handling from 12.2.
The contract first. Before writing code, define the endpoints and their responses:
| Method and path | Purpose | Success | Typical errors |
|---|---|---|---|
GET /api/v1/books?page=&limit=&author= | List, paginated and filtered | 200 with { data, page, limit, total } | 422 invalid query |
GET /api/v1/books/:id | Read one | 200 | 404 |
POST /api/v1/books | Create | 201 + Location header | 401, 409 duplicate ISBN, 422 |
PATCH /api/v1/books/:id | Partial update | 200 with the updated book | 401, 404, 422 |
DELETE /api/v1/books/:id | Delete | 204 without body | 401, 404 |
Repository: data access behind an interface. An in-memory implementation is enough to build and test the API; a SQL or MongoDB version (Chapter 13) exposes the same methods.
// src/books/books.repository.js
export function createInMemoryBookRepository() {
const rows = new Map();
let nextId = 1;
return {
async list({ offset, limit, author }) {
let items = [...rows.values()];
if (author) items = items.filter((b) => b.author.toLowerCase().includes(author.toLowerCase()));
return { items: items.slice(offset, offset + limit), total: items.length };
},
async findById(id) { return rows.get(id) ?? null; },
async findByIsbn(isbn) { return [...rows.values()].find((b) => b.isbn === isbn) ?? null; },
async insert(data) {
const book = { id: nextId++, tags: [], ...data, createdAt: new Date().toISOString() };
rows.set(book.id, book);
return book;
},
async update(id, changes) {
const current = rows.get(id);
if (!current) return null;
const book = { ...current, ...changes, updatedAt: new Date().toISOString() };
rows.set(id, book);
return book;
},
async remove(id) { return rows.delete(id); },
};
}
Service: business rules, no HTTP objects. The service decides what is allowed. It throws HttpError (12.2) for simplicity; a stricter design throws domain errors (NotFoundError, ConflictError) and lets the error handler map them to status codes.
// src/books/books.service.js
import { HttpError } from '../lib/http-error.js';
export function createBookService(repo) {
async function get(id) {
const book = await repo.findById(id);
if (!book) throw new HttpError(404, `book ${id} not found`, { code: 'BOOK_NOT_FOUND' });
return book;
}
return {
get,
list: (query) => repo.list(query),
async create(data) {
if (await repo.findByIsbn(data.isbn)) {
throw new HttpError(409, 'a book with this ISBN already exists', { code: 'DUPLICATE_ISBN' });
}
return repo.insert(data);
},
async update(id, changes) {
await get(id); // 404 if missing
return repo.update(id, changes);
},
async remove(id) {
if (!(await repo.remove(id))) throw new HttpError(404, `book ${id} not found`, { code: 'BOOK_NOT_FOUND' });
},
};
}
Schemas: the shape of valid input. They are explained in the next topic; here they define what each endpoint accepts.
// src/books/books.schemas.js
import { z } from 'zod';
export const bookIdParams = z.object({ id: z.coerce.number().int().positive() });
export const bookListQuery = z.object({
page: z.coerce.number().int().min(1).default(1),
limit: z.coerce.number().int().min(1).max(100).default(20),
author: z.string().trim().max(100).optional(),
});
export const bookCreateBody = z.object({
title: z.string().trim().min(1).max(200),
author: z.string().trim().min(1).max(100),
isbn: z.string().regex(/^(97[89])?\d{9}[\dX]$/, 'invalid ISBN-10 or ISBN-13'),
year: z.number().int().min(1450).max(new Date().getFullYear() + 1),
tags: z.array(z.string().trim().toLowerCase().min(1).max(30)).max(10).optional(),
}).strict(); // unknown fields are an error, not silently stored
export const bookPatchBody = bookCreateBody.partial()
.refine((value) => Object.keys(value).length > 0, 'send at least one field to update');
Router: HTTP in, HTTP out. Each handler reads validated input, calls the service and chooses status code and headers. Because Express 5 forwards rejected promises, there is no try/catch.
// src/books/books.router.js
import { Router } from 'express';
import { validate } from '../middleware/validate.js';
import { bookIdParams, bookListQuery, bookCreateBody, bookPatchBody } from './books.schemas.js';
export function createBooksRouter({ books, authenticate }) {
const router = Router();
router.get('/', validate({ query: bookListQuery }), async (req, res) => {
const { page, limit, author } = req.valid.query;
const { items, total } = await books.list({ offset: (page - 1) * limit, limit, author });
res.json({ data: items, page, limit, total });
});
router.get('/:id', validate({ params: bookIdParams }), async (req, res) => {
res.json(await books.get(req.valid.params.id));
});
router.post('/', authenticate, validate({ body: bookCreateBody }), async (req, res) => {
const book = await books.create(req.valid.body);
res.status(201)
.location(`${req.baseUrl}/${book.id}`) // where the new resource lives
.json(book);
});
router.patch('/:id', authenticate, validate({ params: bookIdParams, body: bookPatchBody }), async (req, res) => {
res.json(await books.update(req.valid.params.id, req.valid.body));
});
router.delete('/:id', authenticate, validate({ params: bookIdParams }), async (req, res) => {
await books.remove(req.valid.params.id);
res.sendStatus(204); // success, no body
});
return router;
}
Trying it out.
TOKEN=... # obtained from the login endpoint ("Authentication with Passport and JWT")
curl -si -X POST localhost:3000/api/v1/books \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d '{"title":"Dune","author":"Frank Herbert","isbn":"9780441172719","year":1965}'
# HTTP/1.1 201 Created
# Location: /api/v1/books/1
# {"id":1,"tags":[],"title":"Dune",...}
curl -s 'localhost:3000/api/v1/books?author=herbert&limit=10'
# {"data":[{"id":1,...}],"page":1,"limit":10,"total":1}
curl -s -X PATCH localhost:3000/api/v1/books/1 -H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' -d '{"price": 9}'
# 422 application/problem+json: unrecognized key "price"
Details that make an API pleasant.
- Consistent envelopes: lists always return
{ data, ... }with pagination metadata; single resources return the object itself; errors always use the problem format from 12.2. - Correct status codes:
201+Locationfor creation,204for deletion,409for conflicts with existing state,422for well-formed but invalid input. - Idempotency:
GET,PUTandDELETEmust be safe to retry (10.1). ForPOST, support anIdempotency-Keyheader if clients may retry payments or orders. - Caching: Express adds weak
ETags tores.jsonresponses automatically, soGETrequests withIf-None-Matchreceive304 Not Modifiedwithout extra code.
Key Takeaways
- Define the contract (paths, methods, status codes, payloads) before implementing.
- Split each resource into repository (data), service (rules) and router (HTTP); handlers stay short.
- Use precise status codes and headers:
201withLocation,204,404,409,422. - In Express 5, async handlers can throw
HttpErrors and rely on the central error handler.
Practice
- (Beginner) Add
PUT /api/v1/books/:idthat replaces a whole book (all fields required), and explain how its semantics differ fromPATCH.- (Intermediate) Add sorting (
?sort=yearor?sort=-year, with an allowlist of sortable fields) and cursor-based pagination (?after=<id>). Return aLinkheader with the next page URL usingres.links().- (Interview) "Two clients load the same book and both send a
PATCH. How do you prevent the second one from silently overwriting the first?" Hint: think about a version number orETagreturned with the resource, anIf-Matchrequest header, and the412 Precondition Failedstatus.
Request Validation and Sanitization
Everything in a request (path, query, headers, cookies, body) is chosen by the client, and some clients are attackers. The type annotations in your code and the form on your front end do not constrain what arrives: anyone can send any bytes with curl. Validation at the boundary turns untrusted input into data with a known shape before any business logic sees it.
Three related but distinct activities are often confused:
| Activity | Question | Example | Where |
|---|---|---|---|
| Validation | Is this input acceptable? | year is an integer between 1450 and next year | At the boundary; reject with 400/422 |
| Normalisation (sanitisation) | Can I put it into a canonical form? | Trim whitespace, lower-case emails, Unicode NFC | At the boundary, as part of parsing |
| Output encoding | Is this safe in the context where I output it? | HTML-escape a title in a template, parameterise SQL | Where data leaves (templates, queries) |
A common mistake is to "sanitise" input by HTML-escaping it on arrival. The stored data is then wrong for every other use (JSON APIs, emails, search) and still unsafe in other contexts (URLs, JavaScript). Validate and normalise on input; encode on output (17.1).
Schema validation with Zod. A schema declares the expected shape once and produces both validation and a typed, normalised result. Unknown keys are stripped by default (or rejected with .strict()), strings can be trimmed, and query strings can be coerced to numbers:
import { z } from 'zod';
const registerBody = z.object({
email: z.string().trim().toLowerCase().email().max(254),
password: z.string().min(12).max(128),
displayName: z.string().trim().normalize('NFC').min(1).max(50),
birthYear: z.number().int().min(1900).max(new Date().getFullYear()).optional(),
});
const result = registerBody.safeParse({ email: ' Ada@Example.COM ', password: 'short', displayName: 'Ada', role: 'admin' });
// result.success === false
// result.error.issues: [{ path: ['password'], message: 'Too small: expected string to have >=12 characters', ... }]
// On success, result.data would contain email 'ada@example.com' and no 'role' key (stripped).
A reusable validation middleware. It validates any combination of params, query and body, collects all problems into one 422 response, and stores the parsed values in req.valid. (req.query cannot be replaced, because it is a read-only getter in Express 5.)
// src/middleware/validate.js
import { HttpError } from '../lib/http-error.js';
export function validate(schemas) {
return (req, res, next) => {
const valid = {};
const issues = [];
for (const part of ['params', 'query', 'body']) {
if (!schemas[part]) continue;
const result = schemas[part].safeParse(req[part] ?? {}); // missing body -> {} -> "required" errors
if (result.success) valid[part] = result.data;
else issues.push(...result.error.issues.map((i) => ({ in: part, path: i.path.join('.'), message: i.message })));
}
if (issues.length > 0) {
return next(new HttpError(422, 'request validation failed', { code: 'VALIDATION_FAILED', details: issues }));
}
req.valid = { ...req.valid, ...valid };
next();
};
}
Response for an invalid request:
{
"type": "about:blank",
"title": "request validation failed",
"status": 422,
"code": "VALIDATION_FAILED",
"errors": [
{ "in": "body", "path": "year", "message": "Too big: expected number to be <=2027" },
{ "in": "body", "path": "isbn", "message": "invalid ISBN-10 or ISBN-13" }
],
"instance": "/api/v1/books"
}
Common rules.
| Input | Rules |
|---|---|
| Strings | Maximum length always; trim; pattern or enum where possible |
| Numbers from query/params | Coerce, require integer, set min/max (pagination limits!) |
| IDs | Exact format (positive integer, UUID, ObjectId) |
| Emails, URLs | Format check plus length; restrict URL schemes to https: |
| Arrays | Maximum item count and per-item rules |
| Objects | Strip or reject unknown keys; limit nesting |
| Dates | ISO 8601 format and a sensible range |
| Free text | Length limit; for rich text, an HTML allowlist sanitiser (below) |
Mass assignment. Passing req.body directly to the database lets clients write fields they should never control:
// Vulnerable: the body {"displayName": "Ada", "role": "admin"} makes the caller an administrator
app.patch('/api/me', authenticate, async (req, res) => {
res.json(await users.update(req.user.id, req.body));
});
// Safe: only fields in the schema survive
const updateMeBody = z.object({ displayName: z.string().trim().min(1).max(50), bio: z.string().max(500) })
.partial().strict();
app.patch('/api/me', authenticate, validate({ body: updateMeBody }), async (req, res) => {
res.json(await users.update(req.user.id, req.valid.body));
});
Prototype pollution. JSON.parse turns a key named "__proto__" into an ordinary property. A naive recursive merge then writes into Object.prototype, changing every object in the process:
function naiveMerge(target, source) {
for (const key of Object.keys(source)) {
if (typeof source[key] === 'object' && source[key] !== null) {
target[key] ??= {};
naiveMerge(target[key], source[key]); // key '__proto__': target.__proto__ is Object.prototype
} else {
target[key] = source[key];
}
}
}
naiveMerge({}, JSON.parse('{"__proto__": {"isAdmin": true}}'));
console.log({}.isAdmin); // true: every object now looks like an admin
Defences: validate with schemas that strip unknown keys before merging; skip __proto__, constructor and prototype in merge utilities; use Object.create(null) or Map for dictionaries built from input; and keep libraries such as lodash up to date. Node's --disable-proto=delete flag removes the __proto__ accessor entirely.
Rich text. When users may submit limited HTML (comments, biographies), escaping would destroy the formatting and storing it raw enables XSS. Use an allowlist sanitiser:
import sanitizeHtml from 'sanitize-html';
const cleanBio = sanitizeHtml(req.valid.body.bio, {
allowedTags: ['b', 'i', 'em', 'strong', 'a', 'p', 'ul', 'ol', 'li'],
allowedAttributes: { a: ['href'] },
allowedSchemes: ['https', 'mailto'], // blocks javascript: URLs
});
express-validator. A popular alternative expresses rules as a chain of middleware per field:
import { body, validationResult, matchedData } from 'express-validator';
router.post('/register',
body('email').trim().isEmail().normalizeEmail(),
body('password').isLength({ min: 12, max: 128 }),
(req, res) => {
const result = validationResult(req);
if (!result.isEmpty()) return res.status(422).json({ errors: result.array() });
const { email, password } = matchedData(req); // only fields that were validated
// ...
});
| Approach | Strengths | Weaknesses |
|---|---|---|
| Zod (or Valibot, ArkType) | One schema for validation and TypeScript types; reusable on the front end | Not Express-specific: you write the small middleware |
| express-validator | Express-native, many built-in validators and sanitisers | Rules spread over middleware chains; no types derived |
| JSON Schema with Ajv | Language-neutral, can be shared with OpenAPI documentation (10.4) | Verbose; types need a generator |
Validation in depth. Boundary validation checks shape. Business rules that need state (ISBN unique, stock available) belong in the service, and database constraints (unique indexes, foreign keys, NOT NULL) are the last line of defence against race conditions between check and insert.
Key Takeaways
- Treat every part of the request as untrusted; validate at the boundary with schemas and reject with clear
422responses. - Normalise input (trim, case, Unicode) during validation; encode output for its context instead of escaping input.
- Allowlist fields to prevent mass assignment, and strip dangerous keys to prevent prototype pollution.
- Keep shape validation at the edge, business rules in services and integrity constraints in the database.
Practice
- (Beginner) Write a Zod schema for a "create event" request (title, ISO start date, optional end date after start, up to 20 attendee emails) and test it with valid and invalid objects.
- (Intermediate) Extend
validateto accept aheadersschema (for example a requiredIdempotency-Keyin UUID format), and add tests showing that unknown body fields are rejected when the schema is strict.- (Interview) "Should you HTML-escape user input before storing it in the database?" Hint: consider how many output contexts the same data appears in (HTML, JSON, CSV, email, URLs), and what goes wrong when data is escaped twice or for the wrong context.
Cookie and Session Middleware
HTTP is stateless (10.2): each request arrives without memory of the previous ones. Cookies give the browser a way to send a value back on every request, and sessions use a cookie containing only an ID to find server-side state. Chapter 10 explained the mechanisms and their security attributes; this topic shows how Express applications use them.
Reading and writing cookies. Express can set cookies on its own (res.cookie), but reading them requires cookie-parser, which parses the Cookie header into req.cookies and verifies signed cookies into req.signedCookies:
import cookieParser from 'cookie-parser';
app.use(cookieParser(config.COOKIE_SECRET)); // secret enables signed cookies
app.post('/preferences/theme', express.urlencoded({ extended: false }), (req, res) => {
const theme = req.body.theme === 'dark' ? 'dark' : 'light'; // allowlist the value
res.cookie('theme', theme, {
maxAge: 365 * 24 * 60 * 60 * 1000, // milliseconds in Express (Max-Age in seconds on the wire)
sameSite: 'lax',
secure: true,
httpOnly: false, // deliberately readable by front-end JavaScript
});
res.redirect(303, '/settings');
});
app.get('/settings', (req, res) => {
res.render('settings', { theme: req.cookies.theme ?? 'light' });
});
res.cookie option | Cookie attribute | Note |
|---|---|---|
maxAge / expires | Max-Age / Expires | Without either, a session cookie (deleted when the browser closes) |
httpOnly | HttpOnly | Hidden from JavaScript; use for all credentials |
secure | Secure | Only sent over HTTPS |
sameSite | SameSite | 'strict', 'lax' or 'none' (the last requires secure) |
domain, path | Domain, Path | Scope of the cookie |
signed | (value prefixed with s: and an HMAC) | Integrity, not secrecy |
partitioned | Partitioned | CHIPS: third-party cookies isolated per top-level site |
Signed cookies append an HMAC computed with the secret. If a client changes the value, req.signedCookies.name is false instead of the tampered value. The content is still readable by anyone, so never put secrets in it.
Deleting cookies. res.clearCookie(name, options) sends an expired cookie. It only removes the original if path and domain match those used to set it; a mismatch leaves the old cookie in place, which is a common cause of "logout does not work" bugs.
Sessions with express-session. express-session creates a session ID, stores it in a signed cookie, and loads the session data from a store into req.session on every request. Changes are saved automatically at the end of the request. 10.2 showed a production configuration with a Redis store; the key options are:
import session from 'express-session';
app.use(session({
store: sessionStore, // Redis, PostgreSQL, ... (the default MemoryStore leaks and is per-process)
secret: config.SESSION_SECRET, // signs the ID cookie; an array allows key rotation
name: '__Host-sid', // not the default 'connect.sid', which reveals the stack
resave: false, // do not rewrite unchanged sessions
saveUninitialized: false, // no session (and no cookie) until something is stored
cookie: { httpOnly: true, secure: true, sameSite: 'lax', maxAge: 30 * 60 * 1000 },
}));
Login and logout. The session API is callback-based; small promise helpers keep handlers readable:
// src/lib/session.js
const call = (req, method) => new Promise((resolve, reject) =>
req.session[method]((err) => (err ? reject(err) : resolve())));
export const regenerateSession = (req) => call(req, 'regenerate');
export const saveSession = (req) => call(req, 'save');
export const destroySession = (req) => call(req, 'destroy');
// src/auth/auth.router.js
router.post('/login', loginLimiter, validate({ body: loginBody }), async (req, res) => {
const { email, password } = req.valid.body;
const user = await users.verifyCredentials(email, password); // password hashing: 10.3
if (!user) throw new HttpError(401, 'invalid email or password'); // same message for both cases
await regenerateSession(req); // new session ID: defeats session fixation (10.2)
req.session.userId = user.id; // store only what you need, not the whole user object
await saveSession(req); // persisted before the client gets the cookie
res.json({ id: user.id, displayName: user.displayName });
});
router.post('/logout', async (req, res) => {
await destroySession(req); // removes the data from the store
res.clearCookie('__Host-sid', { path: '/', httpOnly: true, secure: true, sameSite: 'lax' });
res.sendStatus(204);
});
// src/middleware/require-session.js
export function requireSession(users) {
return async (req, res, next) => {
if (!req.session.userId) throw new HttpError(401, 'login required');
const user = await users.findById(req.session.userId);
if (!user) { // user deleted since login
await destroySession(req);
throw new HttpError(401, 'login required');
}
req.user = user;
next();
};
}
req.session API | Purpose |
|---|---|
req.session.<key> = value | Store data (must be JSON-serialisable for most stores) |
req.sessionID | The session ID |
req.session.regenerate(cb) | New ID, empty data: call on login and privilege changes |
req.session.destroy(cb) | Delete the session from the store |
req.session.save(cb) | Save immediately (before redirects, for example) |
req.session.cookie.maxAge | Adjust the lifetime of this session (for "remember me") |
Client-side sessions. cookie-session stores the (signed) data in the cookie itself, with no server store. It suits small, non-sensitive state, but the 4 KB limit applies, data travels with every request, and sessions cannot be revoked server-side.
CSRF. Cookies are sent automatically, so a malicious site can make the browser send authenticated requests to your app (cross-site request forgery, 17.1). SameSite=Lax blocks cookies on cross-site POSTs in modern browsers, and is the first defence. For defence in depth, check the Origin header on state-changing requests, or use tokens (csrf-csrf, csrf-sync):
export function sameOriginOnly(allowedOrigins) {
const allowed = new Set(allowedOrigins);
return (req, res, next) => {
if (['GET', 'HEAD', 'OPTIONS'].includes(req.method)) return next(); // safe methods change nothing
const referer = req.get('Referer');
const origin = req.get('Origin') ?? (referer && URL.canParse(referer) ? new URL(referer).origin : undefined);
if (!origin || !allowed.has(origin)) {
return next(new HttpError(403, 'cross-site request blocked', { code: 'CSRF_ORIGIN' }));
}
next();
};
}
app.use(sameOriginOnly(['https://app.example.com']));
Key Takeaways
res.cookiesets cookies;cookie-parserreads them and verifies signatures (integrity, not confidentiality).clearCookiemust use the samepathanddomainas the original cookie.express-sessionneeds a real store, a strong secret, secure cookie flags andsaveUninitialized: false; regenerate the session on login and destroy it on logout.- Protect cookie-authenticated, state-changing routes against CSRF with
SameSiteplus an origin check or tokens.
Practice
- (Beginner) Implement a page-view counter stored in a signed cookie. Edit the cookie in DevTools and observe that
req.signedCookiesreportsfalse.- (Intermediate) Implement login, logout and a protected
/api/meroute withexpress-sessionand a Redis store. Verify with DevTools that the session ID changes at login and that the old ID no longer works after logout.- (Interview) "Users report that after logging out they are still logged in when they reload the page. What would you check?" Hint: compare the attributes used to set and clear the cookie, whether the session is destroyed in the store, and whether a cached page or a second cookie (different path or domain) is involved.
Authentication with Passport and JWT
Authentication in a web service has two parts: verifying credentials once (a password, an OAuth login with Google, a passkey) and recognising the caller on every later request (a session cookie or a token). Chapter 10.3 explained the concepts: password hashing, sessions versus JWTs, access and refresh tokens, OAuth 2.0 and OpenID Connect. This topic implements them in Express with two widely used tools: Passport, which standardises the first part, and JSON Web Tokens, a common format for the second.
Passport's model. Passport is authentication middleware with more than 500 strategies, one per authentication method. Each strategy knows how to extract credentials from a request and calls your verify callback to find the user. The rest of the application never sees the method used.
| Concept | Role |
|---|---|
Strategy (passport-local, passport-google-oauth20, passport-jwt, ...) | Extracts and checks credentials for one method |
| Verify callback | Your code: given credentials, return a user, false (failed) or an error |
passport.authenticate(name, options) | Middleware that runs a strategy |
serializeUser / deserializeUser | What to store in the session (an ID), and how to load the user from it |
req.user, req.isAuthenticated() | Set for authenticated requests |
req.login(user, cb) / req.logout(cb) | Establish or end a login session |
Local strategy with sessions. Suits server-rendered applications and SPAs served from the same site:
POST /login {email, password}
|
v
passport.authenticate('local') --> verify callback: find user, verify hash --> user
|
v
req.login(user): regenerates the session, serializeUser(user) -> session.passport.user = user.id
|
later requests: cookie --> session store --> deserializeUser(id) --> req.user
import passport from 'passport';
import { Strategy as LocalStrategy } from 'passport-local';
import argon2 from 'argon2';
passport.use(new LocalStrategy({ usernameField: 'email' }, async (email, password, done) => {
try {
const user = await users.findByEmail(email.trim().toLowerCase());
const valid = user !== null && await argon2.verify(user.passwordHash, password);
if (!valid) return done(null, false, { message: 'Invalid email or password' }); // failure, not error
return done(null, user);
} catch (err) {
return done(err); // infrastructure error -> error handler
}
}));
passport.serializeUser((user, done) => done(null, user.id)); // keep the session small
passport.deserializeUser(async (id, done) => {
try { done(null, (await users.findById(id)) ?? false); } // false: user no longer exists
catch (err) { done(err); }
});
app.use(session({ /* as in the previous topic */ }));
app.use(passport.initialize());
app.use(passport.session()); // restores req.user from the session
app.post('/login',
loginLimiter, // slow down password guessing
passport.authenticate('local', { failureRedirect: '/login?error=1' }),
(req, res) => res.redirect(303, '/dashboard')); // Passport regenerates the session on login
app.post('/logout', (req, res, next) => {
req.logout((err) => (err ? next(err) : res.redirect(303, '/'))); // callback required since Passport 0.6
});
const requireLogin = (req, res, next) => (req.isAuthenticated() ? next() : res.redirect('/login'));
app.get('/dashboard', requireLogin, (req, res) => res.render('dashboard', { user: req.user }));
Social login (OAuth 2.0 / OpenID Connect). The same structure works with an external identity provider; only the strategy and routes change:
import { Strategy as GoogleStrategy } from 'passport-google-oauth20';
passport.use(new GoogleStrategy({
clientID: config.GOOGLE_CLIENT_ID,
clientSecret: config.GOOGLE_CLIENT_SECRET,
callbackURL: 'https://app.example.com/auth/google/callback',
}, async (accessToken, refreshToken, profile, done) => {
try { done(null, await users.findOrCreateByGoogleId(profile.id, profile.emails?.[0]?.value)); }
catch (err) { done(err); }
}));
app.get('/auth/google', passport.authenticate('google', { scope: ['openid', 'email', 'profile'] }));
app.get('/auth/google/callback',
passport.authenticate('google', { failureRedirect: '/login' }),
(req, res) => res.redirect(303, '/dashboard'));
JWT access tokens. For APIs used by mobile apps, other services or SPAs on another domain, the server issues a short-lived signed token at login, and clients send it in the Authorization: Bearer header (10.3). No session store is needed: any instance with the key can verify it. The jose library implements JWT with modern, promise-based APIs:
// src/auth/tokens.js
import { SignJWT, jwtVerify } from 'jose';
const key = new TextEncoder().encode(config.JWT_SECRET); // HS256: at least 32 random bytes
const ISSUER = 'https://api.example.com';
const AUDIENCE = 'bookshelf-clients';
export function issueAccessToken(user) {
return new SignJWT({ role: user.role }) // custom claims: keep them few and non-sensitive
.setProtectedHeader({ alg: 'HS256' })
.setSubject(String(user.id))
.setIssuer(ISSUER)
.setAudience(AUDIENCE)
.setIssuedAt()
.setExpirationTime('15m') // short-lived: limits the damage of a leak
.sign(key);
}
export async function verifyAccessToken(token) {
const { payload } = await jwtVerify(token, key, {
issuer: ISSUER,
audience: AUDIENCE,
algorithms: ['HS256'], // never let the token choose the algorithm
});
return { id: payload.sub, role: payload.role };
}
// src/middleware/authenticate-jwt.js
export async function authenticateJwt(req, res, next) {
const [scheme, token] = (req.get('Authorization') ?? '').split(' ');
if (scheme !== 'Bearer' || !token) {
throw new HttpError(401, 'missing bearer token', { headers: { 'WWW-Authenticate': 'Bearer' } });
}
try {
req.user = await verifyAccessToken(token);
} catch { // expired, bad signature, wrong audience...
throw new HttpError(401, 'invalid or expired token',
{ headers: { 'WWW-Authenticate': 'Bearer error="invalid_token"' } });
}
next();
}
// Login for API clients: verify the password, return tokens
router.post('/token', loginLimiter, validate({ body: loginBody }), async (req, res) => {
const user = await users.verifyCredentials(req.valid.body.email, req.valid.body.password);
if (!user) throw new HttpError(401, 'invalid email or password');
const refreshToken = await refreshTokens.issue(user.id); // random, stored hashed, revocable (10.3)
res.cookie('refresh_token', refreshToken, {
httpOnly: true, secure: true, sameSite: 'strict',
path: '/api/auth/token', // only sent to the token endpoints
maxAge: 30 * 24 * 60 * 60 * 1000,
});
res.json({ accessToken: await issueAccessToken(user), tokenType: 'Bearer', expiresIn: 900 });
});
router.post('/token/refresh', async (req, res) => {
const record = await refreshTokens.consume(req.cookies.refresh_token); // single use; detects reuse
if (!record) throw new HttpError(401, 'invalid refresh token');
const user = await users.findById(record.userId);
const rotated = await refreshTokens.issue(user.id); // rotation: a new refresh token every time
res.cookie('refresh_token', rotated, { httpOnly: true, secure: true, sameSite: 'strict', path: '/api/auth/token', maxAge: 30 * 24 * 60 * 60 * 1000 });
res.json({ accessToken: await issueAccessToken(user), tokenType: 'Bearer', expiresIn: 900 });
});
The same verification is available as a Passport strategy, which is convenient when an application already uses Passport for other methods:
import { Strategy as JwtStrategy, ExtractJwt } from 'passport-jwt';
passport.use(new JwtStrategy({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
secretOrKey: config.JWT_SECRET,
algorithms: ['HS256'],
issuer: 'https://api.example.com',
audience: 'bookshelf-clients',
}, (payload, done) => done(null, { id: payload.sub, role: payload.role })));
router.get('/me', passport.authenticate('jwt', { session: false }), (req, res) => res.json(req.user));
Authorization comes after authentication. Knowing who the caller is does not say what they may do. Role checks fit in middleware (requireRole, 12.2); ownership checks need the resource and belong in the handler or service:
router.delete('/:id', authenticateJwt, validate({ params: bookIdParams }), async (req, res) => {
const book = await books.get(req.valid.params.id);
const isOwner = book.ownerId === req.user.id;
if (!isOwner && req.user.role !== 'admin') throw new HttpError(403, 'you can only delete your own books');
await books.remove(book.id);
res.sendStatus(204);
});
Choosing between the two.
| Session cookie (+ Passport) | JWT bearer token | |
|---|---|---|
| Typical clients | Browser apps on the same site | Mobile apps, third-party clients, service-to-service, SPAs on other domains |
| State on the server | Session store | None for access tokens (refresh tokens are stored) |
| Revocation | Delete the session | Wait for expiry, or keep a deny-list |
| CSRF exposure | Yes (cookies sent automatically): use SameSite and checks | No, if the token is sent in a header |
| XSS exposure | Low with HttpOnly cookies | High if the token is stored where scripts can read it |
Security checklist. Rate-limit login and token endpoints; return the same message for unknown users and wrong passwords; pin the JWT algorithm and check issuer, audience and expiry; keep access tokens short-lived; never put personal or secret data in JWT payloads; serve everything over HTTPS; regenerate sessions at login.
Key Takeaways
- Passport separates authentication methods (strategies) from your user lookup (verify callbacks) and integrates with sessions through serialise/deserialise.
- JWT access tokens let any instance authenticate requests without a session store; verify them with a pinned algorithm, issuer, audience and expiry.
- Pair short-lived access tokens with rotated, revocable refresh tokens in
HttpOnlycookies scoped to the token endpoint. - Authentication identifies the caller; authorization (roles, ownership) must still be checked for every protected action.
Practice
- (Beginner) Implement
POST /api/auth/tokenand a protectedGET /api/mewithjose. Decode a token in the terminal and verify that tampering with its payload causes a401.- (Intermediate) Add refresh-token rotation with reuse detection: if an already-used refresh token is presented, revoke all refresh tokens for that user. Write a test that simulates a stolen token being used after the legitimate client has refreshed.
- (Interview) "How would you log out a user everywhere immediately in a system that uses JWT access tokens?" Hint: consider short expiry, revoking refresh tokens, a per-user token version or
iatcut-off checked on each request, and the cost of each option.
File Uploads with Multer
Uploading files from an HTML form or fetch uses multipart/form-data (11.6): the body is split into parts by a boundary string, each part with its own headers, and files can be very large. Parsing this by hand means streaming, handling limits and cleaning up partial files. Multer is Express middleware built on the busboy streaming parser that does this for you: text fields go to req.body, files to req.file or req.files.
Client side.
<form action="/api/v1/books/42/cover" method="post" enctype="multipart/form-data">
<input type="file" name="cover" accept="image/jpeg,image/png,image/webp">
<button>Upload</button>
</form>
const form = new FormData();
form.append('cover', fileInput.files[0]);
await fetch('/api/v1/books/42/cover', { method: 'POST', body: form }); // do NOT set Content-Type: the browser adds the boundary
Configuring Multer. A Multer instance combines a storage engine, limits and a file filter:
// src/books/cover-upload.js
import multer from 'multer';
import path from 'node:path';
import { randomUUID } from 'node:crypto';
import { HttpError } from '../lib/http-error.js';
export const COVER_TYPES = new Map([['image/jpeg', '.jpg'], ['image/png', '.png'], ['image/webp', '.webp']]);
export const coversDir = path.join(config.DATA_DIR, 'covers'); // outside the source tree and public/
export const uploadCover = multer({
storage: multer.diskStorage({
destination: coversDir, // a string: Multer creates the directory
filename: (req, file, cb) => cb(null, randomUUID() + COVER_TYPES.get(file.mimetype)), // never trust originalname
}),
limits: {
fileSize: 5 * 1024 * 1024, // 5 MB per file
files: 1, // at most one file
fields: 10, // and a few text fields
fieldSize: 10 * 1024,
},
fileFilter: (req, file, cb) => {
if (COVER_TYPES.has(file.mimetype)) return cb(null, true);
cb(new HttpError(415, 'only JPEG, PNG or WebP images are accepted', { code: 'UNSUPPORTED_FILE_TYPE' }));
},
});
Using it in a route. The mimetype checked by fileFilter comes from the client and can lie. After the upload, check the file's magic bytes (its real format) and delete it if it does not match:
import fs from 'node:fs/promises';
import { fileTypeFromFile } from 'file-type';
router.post('/:id/cover',
authenticateJwt,
validate({ params: bookIdParams }),
uploadCover.single('cover'), // field name must match the form
async (req, res) => {
if (!req.file) throw new HttpError(400, 'a "cover" image file is required');
const detected = await fileTypeFromFile(req.file.path); // reads the first bytes of the file
if (!detected || !COVER_TYPES.has(detected.mime)) {
await fs.rm(req.file.path, { force: true }); // clean up rejected files
throw new HttpError(415, 'file content is not a supported image', { code: 'UNSUPPORTED_FILE_TYPE' });
}
await books.setCover(req.valid.params.id, req.file.filename);
res.status(201).json({ coverUrl: `/media/covers/${req.file.filename}`, size: req.file.size });
});
| Method | Accepts | Result |
|---|---|---|
upload.single('cover') | One file in field cover | req.file |
upload.array('photos', 5) | Up to 5 files in field photos | req.files (array) |
upload.fields([{ name: 'cover', maxCount: 1 }, { name: 'gallery', maxCount: 8 }]) | Several named fields | req.files.cover, req.files.gallery |
upload.none() | Text fields only | req.body; any file causes an error |
upload.any() | Any files | req.files: avoid, it accepts everything |
req.file contains fieldname, originalname, mimetype, size and, depending on the storage, destination, filename and path (disk) or buffer (memory).
Errors. Limit violations produce a multer.MulterError with a code. Map them in the error handler's normalize function (12.2):
import multer from 'multer';
if (err instanceof multer.MulterError) {
const status = err.code === 'LIMIT_FILE_SIZE' ? 413 : 400; // LIMIT_FILE_COUNT, LIMIT_UNEXPECTED_FILE, ...
return new HttpError(status, err.message, { code: err.code });
}
Storage options.
| Storage | How | Suits |
|---|---|---|
diskStorage | Streams to a local file | One server, or a shared volume |
memoryStorage | Keeps the whole file in req.file.buffer | Small files processed immediately (resize, then upload elsewhere); dangerous without strict limits |
Custom engine (for example multer-s3) | Streams to object storage | Multiple instances, containers with ephemeral disks |
| Direct-to-storage with presigned URLs | The app only issues a signed upload URL; the browser uploads to S3/GCS directly | Large files; keeps traffic off the app servers |
Presigned upload flow
browser --POST /api/uploads {type, size}--> Express: validate, authorise, create a presigned PUT URL
browser <--------- { uploadUrl, key } -----
browser --PUT file--------------------------> object storage (checks signature, size, type)
browser --POST /api/books/42/cover {key}--> Express: verify the object exists, record it
Serving uploaded files safely. An uploaded file is attacker-controlled content. An "image" that is really HTML with a script, served from your domain with the wrong type, becomes stored XSS.
app.use('/media/covers', express.static(coversDir, {
index: false,
dotfiles: 'deny',
setHeaders(res) {
res.set('X-Content-Type-Options', 'nosniff'); // browsers must trust Content-Type
res.set('Content-Security-Policy', "default-src 'none'"); // nothing in the file may execute
},
}));
Further measures: serve user content from a separate domain (usercontent.example.com) so it cannot read your cookies; re-encode images (for example with sharp), which also strips metadata such as GPS location; scan files with an antivirus service where required; enforce per-user quotas; and never use originalname to build paths.
Key Takeaways
- Multer parses
multipart/form-dataas a stream, putting text fields inreq.bodyand files inreq.file/req.files. - Always set limits (size, count, fields), generate file names on the server, and verify the real content type from the file's bytes.
- Map
MulterErrorcodes to413/400in the central error handler, and delete rejected files. - Serve uploads with
nosniffand a restrictive CSP, ideally from a separate domain; consider presigned URLs for large files and multiple instances.
Practice
- (Beginner) Build an avatar upload endpoint that accepts one PNG or JPEG up to 1 MB and returns its URL. Try a 2 MB file, a text file renamed to
.png, and a request with two files.- (Intermediate) Use
memoryStoragewithsharpto resize uploads to 400x600, convert them to WebP, and store only the processed version. Measure memory use while uploading ten files concurrently, then explain why the limits matter.- (Interview) "What can go wrong when users upload files to your web application, and how do you defend against each risk?" Hint: consider size and count (denial of service), file names (path traversal), content type spoofing (stored XSS), malware, privacy metadata, and storage on ephemeral containers.
Template Engines and Server-Side Views
Not every web application is a JSON API plus a single-page front end. Admin panels, content sites, e-mail templates and progressively enhanced forms are often simpler and faster to build with server-side rendering (SSR): the server produces finished HTML for every page, and the browser just displays it. Pages load fast, work without JavaScript and are easy for search engines to index.
A template engine turns a template (HTML with placeholders and simple logic) plus data into an HTML string. It is like a mail merge: one letter template, many personalised letters.
template views/books/list.ejs data { books: [...], user: {...} }
\ /
+-------> template engine <------+
|
v
HTML string --> res.send() --> browser
Express's view system. Express does not include a template engine, but it defines how one plugs in: you configure a directory and an engine, and res.render() finds the file, merges the data and sends the result.
import path from 'node:path';
app.set('views', path.join(import.meta.dirname, 'views')); // where templates live
app.set('view engine', 'ejs'); // default extension and engine (npm i ejs)
app.locals.siteName = 'Bookshelf'; // available in every template
app.get('/books', async (req, res) => {
const { items } = await books.list({ offset: 0, limit: 50 });
res.render('books/list', { title: 'All books', books: items }); // renders views/books/list.ejs
});
The data available in a template is the merge of app.locals, res.locals and the object passed to res.render, with later sources taking precedence. In production (NODE_ENV=production) the view cache setting is on, so templates are compiled only once.
EJS: JavaScript inside HTML. EJS uses plain JavaScript in tags, so there is little new syntax to learn:
<%# views/books/list.ejs %>
<%- include('../partials/header', { title }) %>
<h1><%= title %></h1>
<% if (books.length === 0) { %>
<p>No books yet. <a href="/books/new">Add the first one</a>.</p>
<% } else { %>
<ul>
<% for (const book of books) { %>
<li>
<a href="/books/<%= book.id %>"><%= book.title %></a>
by <%= book.author %> (<%= book.year %>)
</li>
<% } %>
</ul>
<% } %>
<%- include('../partials/footer') %>
| EJS tag | Meaning |
|---|---|
<%= value %> | Output, HTML-escaped (< becomes <): use for all data |
<%- value %> | Output raw, unescaped: only for trusted HTML such as include() results |
<% code %> | Run JavaScript (loops, conditions), no output |
<%# comment %> | Comment, not included in the output |
Escaped output is what prevents a book titled <script>alert(1)</script> from executing in every visitor's browser. Using <%- with user data is one of the most common sources of XSS in server-rendered Express apps.
Shared data with res.locals. Values that every page needs (the logged-in user, flash messages, a CSRF token) are set once in middleware instead of in every res.render call:
app.use((req, res, next) => {
res.locals.currentUser = req.user ?? null; // for the navigation bar
res.locals.flash = req.session.flash ?? null; // one-time message from the previous request
delete req.session.flash; // ...shown only once
res.locals.csrfToken = req.session.csrfToken; // for hidden form fields
next();
});
Forms: Post/Redirect/Get. After a successful POST, respond with a redirect (303 See Other) instead of rendering a page. Otherwise reloading the result page resubmits the form. On validation errors, re-render the form with the submitted values and messages, with status 422:
router.get('/books/new', (req, res) => res.render('books/new', { values: {}, errors: {} }));
router.post('/books', express.urlencoded({ extended: false }), async (req, res) => {
const input = { ...req.body, year: Number(req.body.year) }; // form fields are always strings
const result = bookCreateBody.safeParse(input);
if (!result.success) {
const errors = Object.fromEntries(result.error.issues.map((i) => [i.path.join('.'), i.message]));
return res.status(422).render('books/new', { values: req.body, errors }); // keep what the user typed
}
const book = await books.create(result.data);
req.session.flash = { type: 'success', message: `"${book.title}" was added.` };
res.redirect(303, `/books/${book.id}`); // Post/Redirect/Get
});
<%# views/books/new.ejs (excerpt) %>
<form method="post" action="/books" novalidate>
<input type="hidden" name="_csrf" value="<%= csrfToken %>">
<label>Title
<input name="title" value="<%= values.title ?? '' %>" required maxlength="200">
</label>
<% if (errors.title) { %><p class="error"><%= errors.title %></p><% } %>
<button>Save</button>
</form>
Other engines.
| Engine | Syntax style | Notes |
|---|---|---|
| EJS | HTML with <% %> JavaScript tags | Minimal learning curve; layouts via include |
| Pug | Indentation-based, no closing tags | Concise; mixins and inheritance; the syntax must be learned |
Handlebars (express-handlebars) | {{ }} "logic-less" templates with helpers | Layouts and partials built in; logic moves to helpers |
| Nunjucks | Jinja2-like {% %} blocks | Template inheritance with extends/block; familiar to Python developers |
JSX on the server (React renderToString, Hono JSX) | Components | Typed components; the bridge to full SSR frameworks |
//- views/books/list.pug: the same list in Pug
extends ../layout
block content
h1= title
ul
each book in books
li
a(href=`/books/${book.id}`)= book.title
| by #{book.author}
Where server-rendered views fit today. Classic templates suit content-heavy sites and internal tools, often enhanced with small amounts of JavaScript (or libraries such as htmx that swap HTML fragments returned by the server). Highly interactive interfaces usually use a front-end framework (Chapter 14), sometimes with framework-level SSR, while Express serves the API and the built assets (next topic).
Key Takeaways
- Template engines merge templates with data into HTML; Express connects them through
views,view engineandres.render. - Template data comes from
app.locals,res.localsand the render call; useres.localsfor per-request shared values. - Always use escaped output for data (
<%= %>in EJS); raw output of user data causes XSS. - Follow Post/Redirect/Get after successful form submissions, and re-render with
422and the user's input on errors.
Practice
- (Beginner) Build list, detail and "new book" pages with EJS, sharing a header and footer partial and showing the logged-in user's name in the header.
- (Intermediate) Add flash messages and full validation feedback to the form, including CSRF protection. Then render a book with the title
<img src=x onerror=alert(1)>using<%=and<%-and explain the difference.- (Interview) "When would you choose server-rendered templates over a React single-page application for a new project?" Hint: consider interactivity requirements, SEO and first-load performance, team skills, the amount of client-side state, and hybrid approaches such as htmx or framework SSR.
Serving SPAs and API Gateways
Express often runs at the edge of a system in one of two roles: hosting a single-page application (the built output of React, Vue or Angular together with its API) and acting as an API gateway, a single entry point that forwards requests to internal services.
Hosting an SPA. A build tool (Vite, webpack) produces static files: an index.html and fingerprinted assets such as assets/index-3f9a1c.js. Express must do three things: serve the API, serve the assets with good caching, and return index.html for every client-side route so that reloading /books/42 works (the history fallback, 6.4).
GET /api/v1/books --> API router (JSON)
GET /api/unknown --> JSON 404 (never index.html)
GET /assets/index-3f9a.js --> express.static, cached for a year (file name changes when content changes)
GET /favicon.ico --> express.static, short cache
GET /books/42 --> index.html, no-cache (the SPA router renders the page)
GET /missing.png --> 404 (not HTML: no fallback)
import path from 'node:path';
const distDir = path.join(import.meta.dirname, '..', 'client', 'dist');
app.use('/api', apiRouter); // 1. API first
app.use('/api', (req, res) => res.status(404).json({ error: 'not found' })); // 2. unknown API paths stay JSON
app.use('/assets', express.static(path.join(distDir, 'assets'), {
immutable: true,
maxAge: '1y', // 3. fingerprinted: cache "forever"
fallthrough: false, // a missing asset is a 404, not index.html
}));
app.use(express.static(distDir, { index: false, maxAge: '1h' })); // favicon.ico, robots.txt, ...
app.get('/{*path}', (req, res, next) => { // 4. history fallback (GET and HEAD)
if (!req.accepts('html')) return next(); // only for browsers navigating to a page
res.sendFile('index.html', {
root: distDir,
headers: { 'Cache-Control': 'no-cache' }, // always revalidate: it points to the current assets
});
});
index.html must not be cached for long, because it references the current asset file names; the assets themselves can be cached for a year because a new build produces new names.
Development versus production. During development the front end runs on the Vite dev server (with hot reloading) and proxies API calls to Express, so both appear to come from one origin. In production Express serves the built files itself, and front end and API share one origin: no CORS configuration is needed and cookies can use SameSite=Strict.
// client/vite.config.js (development only)
export default {
server: {
proxy: { '/api': 'http://localhost:3000' }, // the browser talks to Vite; Vite forwards /api to Express
},
};
API gateway and backend for frontend (BFF). When a system is split into several services (users, orders, inventory), clients should not need to know every service's address, authenticate against each, or make many round trips. A gateway in front of them centralises cross-cutting concerns:
+---------------------------------------+
browser / mobile -----> | Express gateway |
| TLS, CORS, rate limits, auth (once), |
| request IDs, logging, aggregation |
+----+--------------+--------------+----+
| | |
v v v
users-service orders-service inventory-service (internal network only)
| Responsibility | How in Express |
|---|---|
| Routing to services | http-proxy-middleware per path prefix |
| Authentication | Verify the token once, pass a trusted identity header downstream |
| Rate limiting, CORS, security headers | The middleware from 12.2, in one place |
| Aggregation (BFF) | A route that calls several services and combines the results |
| Observability | Request IDs forwarded to every service |
Proxying.
import { createProxyMiddleware, fixRequestBody } from 'http-proxy-middleware';
app.use('/api/orders',
authenticateJwt, // verify the token at the edge
createProxyMiddleware({
target: 'http://orders-service:4000/orders', // the mount path is stripped: /api/orders/42 -> /orders/42
changeOrigin: true, // Host header becomes orders-service:4000
proxyTimeout: 5_000, // fail instead of waiting forever
on: {
proxyReq: (proxyReq, req) => {
proxyReq.setHeader('X-User-Id', req.user.id); // trusted identity (overwrites any client-sent value)
proxyReq.setHeader('X-Request-Id', req.id);
fixRequestBody(proxyReq, req); // re-send the body if a body parser already consumed it
},
},
}));
Body parsers and proxies conflict: if express.json() has already read the request stream, the proxy has nothing left to forward and the upstream service waits for a body that never comes. Either mount proxies before body parsers or re-serialise the body with fixRequestBody. Internal services must accept identity headers only from the gateway (network policies, mutual TLS), or anyone could send X-User-Id: 1.
Aggregation in a BFF. A dashboard that needs data from two services can get it in one request from the client's point of view, with independent failures handled gracefully:
const getJson = async (url, signal) => {
const r = await fetch(url, { signal });
if (!r.ok) throw new Error(`${url} answered ${r.status}`);
return r.json();
};
app.get('/api/dashboard', authenticateJwt, async (req, res) => {
const signal = AbortSignal.timeout(2_000); // shared deadline for both calls
const userId = encodeURIComponent(req.user.id);
const [profile, orders] = await Promise.allSettled([ // in parallel, and one failure does not hide the other
getJson(`${config.USERS_URL}/users/${userId}`, signal),
getJson(`${config.ORDERS_URL}/orders?user=${userId}&limit=5`, signal),
]);
res.json({
profile: profile.status === 'fulfilled' ? profile.value : null,
recentOrders: orders.status === 'fulfilled' ? orders.value : [],
degraded: profile.status === 'rejected' || orders.status === 'rejected',
});
});
For pure routing at very high throughput, dedicated proxies (nginx, Envoy, cloud API gateways) are more efficient. An Express gateway is worthwhile when the edge needs application logic: aggregation, custom authentication, response shaping for a specific client.
Key Takeaways
- To host an SPA: API routes first, a JSON 404 for unknown API paths, long-cached fingerprinted assets, and an
index.htmlfallback (withno-cache) only for HTML navigation requests. - Use the dev server's proxy in development and same-origin hosting in production to avoid CORS.
- An Express gateway centralises authentication, rate limiting and logging, and forwards requests with
http-proxy-middleware; beware of body parsers consuming the stream. - A BFF aggregates several services for one client, in parallel, with timeouts and graceful degradation.
Practice
- (Beginner) Build a Vite + React (or plain HTML/JS) front end with client-side routes, serve its
distfolder with Express, and verify that reloading a deep link works while/api/nopeand/missing.pngreturn 404.- (Intermediate) Create two small services (users and orders) and an Express gateway that authenticates with JWT, proxies
/api/usersand/api/orders, and exposes an aggregated/api/dashboard. Stop one service and check how the gateway responds.- (Interview) "What are the risks of an API gateway forwarding user identity to internal services in a header?" Hint: consider who else can reach the internal services, whether clients can inject the same header, and mechanisms such as network isolation, mutual TLS or signed internal tokens.
Testing Express Applications
Without automated tests, every change to a middleware order, a validation rule or an error mapping must be checked by hand, and regressions reach users. Express applications are well suited to testing because the app is just a function that handles requests: a test can call it without a real network, a browser or a deployed server.
What to test at which level.
| Level | Tests | Example | Speed |
|---|---|---|---|
| Unit | One function in isolation | requireRole returns 403 for members; service rejects duplicate ISBN | Milliseconds |
| Integration (HTTP) | The app through HTTP, with fake or test dependencies | POST /api/v1/books returns 201 and Location; invalid body returns 422 | Tens of milliseconds |
| Integration (data) | Repositories against a real test database | SQL queries, unique constraints | Slower; needs a database (Chapter 13) |
| End-to-end | Browser + deployed stack | Login and add a book through the UI (17.2) | Seconds |
Most tests should be fast unit and HTTP integration tests; a few end-to-end tests cover critical journeys.
HTTP tests with supertest. supertest takes an Express app, starts it on an ephemeral port for the duration of a request, and offers a fluent API for requests and assertions. Combined with the app factory from 12.1 and Node's built-in test runner (11.2), each test gets a fresh app with in-memory dependencies:
// test/books.test.js
import { describe, it, beforeEach } from 'node:test';
import assert from 'node:assert/strict';
import request from 'supertest';
import { createApp } from '../src/app.js';
import { createInMemoryBookRepository } from '../src/books/books.repository.js';
const fakeAuth = (req, res, next) => { req.user = { id: '1', role: 'admin' }; next(); }; // replace real auth
describe('Books API', () => {
let app;
beforeEach(() => {
app = createApp({ bookRepository: createInMemoryBookRepository(), authenticate: fakeAuth }); // isolated state per test
});
const dune = { title: 'Dune', author: 'Frank Herbert', isbn: '9780441172719', year: 1965 };
it('creates a book and returns its location', async () => {
const res = await request(app)
.post('/api/v1/books')
.send(dune) // serialised as JSON, Content-Type set
.expect('Content-Type', /json/)
.expect(201);
assert.equal(res.body.title, 'Dune');
assert.equal(res.headers.location, `/api/v1/books/${res.body.id}`);
});
it('rejects duplicates with 409', async () => {
await request(app).post('/api/v1/books').send(dune).expect(201);
const res = await request(app).post('/api/v1/books').send(dune).expect(409);
assert.equal(res.body.code, 'DUPLICATE_ISBN');
});
it('returns 422 with details for invalid input', async () => {
const res = await request(app).post('/api/v1/books').send({ ...dune, year: 'old' }).expect(422);
assert.ok(res.body.errors.some((e) => e.path === 'year'));
});
it('returns a problem document for unknown books', async () => {
const res = await request(app).get('/api/v1/books/999').expect(404);
assert.match(res.headers['content-type'], /application\/problem\+json/);
});
});
node --test # runs test/**/*.test.js
node --test --experimental-test-coverage # with a coverage report
Testing authentication flows. For token-based APIs, obtain a token (or sign one with the test key) and send it with .set('Authorization', Bearer ${token}). For session-based apps, request.agent(app) keeps cookies between requests like a browser:
it('keeps the user logged in between requests', async () => {
const agent = request.agent(app); // stores Set-Cookie and sends Cookie on later requests
await agent.post('/api/auth/login').send({ email: 'ada@example.com', password: 'correct horse battery staple' }).expect(200);
await agent.get('/api/me').expect(200);
await agent.post('/api/auth/logout').expect(204);
await agent.get('/api/me').expect(401);
});
supertest talks plain HTTP, so cookies marked Secure are not sent back. Make the secure flag (and a __Host- cookie name, which requires it) depend on configuration, and disable it in the test environment.
Unit-testing middleware. Middleware is a function; call it with minimal fake objects:
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { requireRole } from '../src/middleware/require-role.js';
function fakeRes() {
return {
statusCode: 200, body: undefined,
status(code) { this.statusCode = code; return this; },
json(body) { this.body = body; return this; },
};
}
test('requireRole lets admins through', (t) => {
const next = t.mock.fn(); // records calls
requireRole('admin')({ user: { role: 'admin' } }, fakeRes(), next);
assert.equal(next.mock.callCount(), 1);
});
test('requireRole rejects members with 403', (t) => {
const res = fakeRes();
const next = t.mock.fn();
requireRole('admin')({ user: { role: 'member' } }, res, next);
assert.equal(res.statusCode, 403);
assert.equal(next.mock.callCount(), 0);
});
Dependencies outside the app.
| Dependency | Strategy |
|---|---|
| Database | In-memory repository for HTTP tests; a real database for repository tests (Docker or Testcontainers), reset per test with transactions or truncation |
| Outgoing HTTP calls | Inject a client and fake it, or intercept fetch with undici's MockAgent or MSW |
| Time | Inject a clock, or use t.mock.timers in node:test |
| Configuration | A dedicated .env.test and an explicit config object passed to createApp |
Key Takeaways
- An app factory with injected dependencies makes Express apps testable without a network, a real database or global state.
- supertest drives the app through HTTP and asserts status codes, headers and bodies;
request.agentkeeps cookies for session flows. - Middleware and services are plain functions: unit-test them with simple fakes and mocks.
- Most tests should be fast unit and HTTP integration tests; test error paths (
404,409,422,401) as carefully as success paths.
Practice
- (Beginner) Write supertest tests for every endpoint of the books API, including one success and one error case each.
- (Intermediate) Test the login/logout flow with
request.agent, and test that the rate limiter returns429after the configured number of failed logins (inject a small limit through configuration).- (Interview) "How do you keep integration tests for an Express API fast and reliable?" Hint: think about app factories, fresh state per test, which dependencies to fake and which to run for real, parallel test execution, and avoiding fixed ports and sleeps.
Alternatives: Koa, Fastify and NestJS
Express's design dates from 2010, before promises, async/await and TypeScript were common. Newer frameworks keep its core idea (functions handling requests) but make different trade-offs: Koa redesigns the middleware model around async/await, Fastify focuses on performance and schemas, NestJS adds an opinionated architecture, and Hono targets every JavaScript runtime. Knowing them helps you choose, and most concepts from this chapter transfer directly.
Koa: the onion model. Koa was created by the original Express team. It has no built-in router or body parser, and replaces req/res with a single context object ctx. Its main difference is the middleware model: await next() really waits for everything downstream, so each middleware wraps the ones after it like layers of an onion.
request
|
+-----------v----------------------------------------+
| timing: before await next() |
| +-------v----------------------------------+ |
| | errors: try { await next() } | |
| | +-------v--------------------------+ | |
| | | router: ctx.body = book | | |
| | +-------+--------------------------+ | |
| | } catch (err) { ctx.status = ... } | |
| +-------+----------------------------------+ |
| timing: after await next(): set header |
+-----------+----------------------------------------+
v
response (sent by Koa after the whole chain has resolved)
import Koa from 'koa';
import Router from '@koa/router';
const app = new Koa();
app.use(async (ctx, next) => { // wraps everything downstream
const start = performance.now();
await next(); // in Koa this really waits
ctx.set('X-Response-Time', `${Math.round(performance.now() - start)}ms`);
});
app.use(async (ctx, next) => { // error handling is a plain try/catch
try {
await next();
} catch (err) {
ctx.status = err.status ?? 500;
ctx.body = { error: err.expose ? err.message : 'internal server error' };
}
});
const router = new Router();
router.get('/books/:id', async (ctx) => {
const book = await books.findById(Number(ctx.params.id));
if (!book) ctx.throw(404, 'book not found'); // throws an HttpError with expose = true
ctx.body = book; // assigning an object means "respond with JSON"
});
app.use(router.routes()).use(router.allowedMethods()); // allowedMethods adds 405 and OPTIONS handling
app.listen(3000);
Fastify: speed through schemas. Fastify asks you to describe inputs and outputs with JSON Schema. It uses the schemas to validate and coerce requests (with Ajv) and to compile a fast JSON serialiser for responses, which also drops fields not in the schema (preventing accidental data leaks). It has built-in logging (pino), an encapsulated plugin system, and lifecycle hooks (onRequest, preHandler, onSend, onResponse, onError) instead of one generic middleware chain.
import Fastify from 'fastify';
const app = Fastify({ logger: true }); // structured logging built in
const Book = {
type: 'object',
properties: { id: { type: 'integer' }, title: { type: 'string' }, author: { type: 'string' } },
required: ['id', 'title', 'author'],
};
app.get('/books/:id', {
schema: {
params: { type: 'object', properties: { id: { type: 'integer', minimum: 1 } }, required: ['id'] },
response: { 200: Book }, // serialiser compiled from the schema
},
}, async (request, reply) => {
const book = await books.findById(request.params.id); // already validated and coerced to a number
if (!book) return reply.code(404).send({ error: 'book not found' });
return book; // the returned value is the response body
});
app.register(async (admin) => { // encapsulated plugin: the hook applies only inside
admin.addHook('onRequest', authenticateAdmin);
admin.get('/stats', async () => ({ books: await books.count() }));
}, { prefix: '/admin' });
await app.listen({ port: 3000, host: '0.0.0.0' });
In "hello world" benchmarks Fastify handles several times more requests per second than Express. In real applications, where database calls dominate, the difference is smaller but still noticeable under high load. Existing Express middleware can be used through @fastify/express or @fastify/middie during a migration.
NestJS: architecture included. NestJS is a TypeScript framework inspired by Angular. It runs on top of Express (version 5 by default since NestJS 11) or Fastify and adds a structure: modules group features, controllers handle routes, providers (services) are created and injected by a dependency injection container, and cross-cutting concerns have dedicated building blocks: guards (authorization), pipes (validation and transformation), interceptors (wrapping handlers, like Koa middleware) and exception filters (error mapping).
// books/books.controller.ts
import { Controller, Get, Param, ParseIntPipe, NotFoundException, UseGuards } from '@nestjs/common';
import { BooksService } from './books.service';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
@Controller('books')
export class BooksController {
constructor(private readonly books: BooksService) {} // injected by the DI container
@Get(':id')
async findOne(@Param('id', ParseIntPipe) id: number) { // the pipe validates and converts the parameter
const book = await this.books.findById(id);
if (!book) throw new NotFoundException('book not found'); // turned into a 404 by the exception filter
return book; // serialised to JSON
}
@Get(':id/notes')
@UseGuards(JwtAuthGuard) // authorization as a declarative guard
notes(@Param('id', ParseIntPipe) id: number) {
return this.books.notesFor(id);
}
}
// books/books.module.ts and main.ts (imports of BooksService and AppModule omitted)
import { Module } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import { BooksController } from './books.controller';
@Module({ controllers: [BooksController], providers: [BooksService] })
export class BooksModule {}
const app = await NestFactory.create(AppModule); // Express adapter by default; FastifyAdapter is available
await app.listen(3000);
The same layered architecture that 12.1 built by convention is enforced by the framework, which helps large teams stay consistent, at the cost of more concepts, decorators and boilerplate.
Hono: web standards on every runtime. Hono is a small framework built on the standard Request/Response objects of the Fetch API. The same code runs on Node.js, Deno, Bun, Cloudflare Workers and other edge platforms (11.1):
import { Hono } from 'hono';
import { serve } from '@hono/node-server';
const app = new Hono();
app.get('/books/:id', async (c) => {
const book = await books.findById(Number(c.req.param('id')));
return book ? c.json(book) : c.json({ error: 'book not found' }, 404);
});
serve({ fetch: app.fetch, port: 3000 }); // on Bun or Workers you would export app instead
Comparison.
| Express | Koa | Fastify | NestJS | Hono | |
|---|---|---|---|---|---|
| Style | Minimal, linear middleware | Minimal, onion middleware | Plugins, hooks, schemas | Opinionated, modules, DI, decorators | Minimal, Web-standard Request/Response |
| Async errors | Native since v5 | Native (try/catch around next) | Native | Native (exception filters) | Native |
| Validation | Bring your own | Bring your own | JSON Schema built in | Pipes (class-validator, Zod) | Validator middleware (Zod and others) |
| Performance | Baseline | Somewhat faster | Among the fastest on Node | Depends on adapter | Very fast, especially on Bun and edge runtimes |
| TypeScript | Via @types/express | Via types package | Built-in types, type providers | TypeScript-first | TypeScript-first |
| Ecosystem | Largest | Moderate | Large, official plugins | Large, integrated modules | Growing quickly |
| Typical fit | Most apps, learning, legacy | Small services wanting clean async | High-throughput APIs | Large teams and enterprise codebases | Edge and multi-runtime apps |
How to choose.
- An existing Express codebase, a team that knows it, or a need for a specific middleware: stay with Express 5.
- A new API where throughput or strict schemas matter: Fastify.
- A large application with many developers who benefit from enforced structure: NestJS.
- Deployment to edge or serverless runtimes, or code shared between runtimes: Hono.
- Whatever you pick, the concepts of this chapter (pipelines of handlers, validation at the boundary, central error handling, layered services, app factories for testing) carry over.
Key Takeaways
- Koa replaces Express's linear chain with the onion model, where
await next()waits for downstream middleware; it ships without router or body parser. - Fastify uses JSON Schemas for validation and fast serialisation, and organises code into encapsulated plugins with lifecycle hooks.
- NestJS adds modules, dependency injection, guards, pipes, interceptors and filters on top of Express or Fastify.
- Hono builds on Web-standard
Request/Responseand runs unchanged across Node.js, Deno, Bun and edge platforms.
Practice
- (Beginner) Implement
GET /books/:idandPOST /booksin Express, Koa and Fastify, and compare how each handles parameters, body parsing, validation and errors.- (Intermediate) Benchmark the three implementations with
autocannonfor a JSON response, first with an in-memory result and then with a simulated 20 ms database delay. Explain why the relative differences shrink.- (Interview) "Your team must build a new API platform with 30 developers. Express, Fastify or NestJS?" Hint: weigh performance needs against consistency across teams, onboarding cost, testability, the existing middleware you depend on, and the fact that NestJS can run on Fastify.
<a id="13-storage-and-data-persistence"></a>