Node.js · lesson 4 of 20
Beginner
6Intermediate
10- 7 Structured Logging with Pino
- 8 Async Route Handlers and Errors in Express 5
- 9 Testing an HTTP API with node:test and fetch
- 10 The Same API in Fastify
- 11 JWT Auth Middleware
- 12 Graceful Shutdown and Health Checks
- 13 File Uploads with multer
- 14 A WebSocket Server with ws
- 15 OpenAPI Docs from Fastify Schemas
- 16 PostgreSQL with pg: Pools and Transactions
Advanced
4Also part of Node.js
10- + Files, Streams, and the Command Line in Node.js from JavaScript
- + JavaScript Event Loop Explained from JavaScript
- + JavaScript Modules: ESM and CommonJS from JavaScript
- + Introduction to Docker from Docker
- + Managing Docker Containers from Docker
- + Multi-Stage Builds from Docker
- + Docker Compose from Docker
- + Node.js with TypeScript from TypeScript
- + Unsafe File Uploads: secure_filename, Allowlists and Size Limits from Web Security (OWASP Top 10, fix-side)
- + Decorators in TypeScript from TypeScript
Express Routing, Request, and Response
Install Express and rewrite the manual http router as named routes, compare its default 404 page to a custom one, and read req.params and req.query.
By SkillByExample · Published
npm install express replaces the regex router and manual body buffering from the first lesson with named routes and a one-line body parser.
Project Setup
$ npm install express
// package.json
{
"name": "express-routing-demo",
"version": "1.0.0",
"type": "module",
"scripts": {
"start": "node server.js"
},
"dependencies": {
"express": "^5.3.0"
}
}
A plain npm install express writes the caret range, not the exact version — ^5.3.0, matching the caret the previous lesson’s install also wrote. package-lock.json (not shown here) is what actually pins the resolved 5.3.0.
Named Routes Instead of an if Chain
// server.js
import express from "express";
const app = express();
app.use(express.json());
const users = [
{ id: 1, name: "Alice" },
{ id: 2, name: "Bob" },
];
app.get("/users", (req, res) => {
const limit = Number(req.query.limit ?? 10);
res.json(users.slice(0, limit));
});
app.get("/users/:id", (req, res) => {
const user = users.find((u) => u.id === Number(req.params.id));
if (!user) {
res.status(404).json({ error: `user ${req.params.id} not found` });
return;
}
res.json(user);
});
app.post("/users", (req, res) => {
const user = { id: users.length + 1, name: req.body.name };
users.push(user);
res.status(201).json(user);
});
app.listen(3000, () => console.log("listening on http://localhost:3000"));
$ node server.js &
$ curl -s http://localhost:3000/users
[{"id":1,"name":"Alice"},{"id":2,"name":"Bob"}]
$ curl -s http://localhost:3000/users/2
{"id":2,"name":"Bob"}
$ curl -s "http://localhost:3000/users?limit=1"
[{"id":1,"name":"Alice"}]
$ curl -s -X POST http://localhost:3000/users -H 'content-type: application/json' -d '{"name":"Carol"}'
{"id":3,"name":"Carol"}
app.get("/users/:id", ...) turns the :id segment into req.params.id, and req.query.limit reads the ?limit=1 query string — both as plain strings, which is why Number(...) wraps each one before comparing or arithmetic, exactly like the manual regex capture in the first lesson.
Two Different 404s
A route you write yourself and the one Express falls back to when nothing matches look the same in the status line, but not in the body.
$ curl -s -i http://localhost:3000/users/99
HTTP/1.1 404 Not Found
X-Powered-By: Express
Content-Type: application/json; charset=utf-8
Content-Length: 29
ETag: W/"1d-Qiw1G6lPhNjZkT5WvRGrOrxQvgA"
Date: Wed, 28 Oct 2026 09:15:00 GMT
Connection: keep-alive
Keep-Alive: timeout=5
{"error":"user 99 not found"}
$ curl -s -i http://localhost:3000/no-such-route
HTTP/1.1 404 Not Found
X-Powered-By: Express
Content-Security-Policy: default-src 'none'
X-Content-Type-Options: nosniff
Content-Type: text/html; charset=utf-8
Content-Length: 152
Date: Wed, 28 Oct 2026 09:15:09 GMT
Connection: keep-alive
Keep-Alive: timeout=5
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Error</title>
</head>
<body>
<pre>Cannot GET /no-such-route</pre>
</body>
</html>
/users/99 matched the /users/:id route and ran our handler, which chose to respond with a JSON error. /no-such-route matched nothing Express knows about, so Express’s own default handler sent an HTML page — complete with Content-Security-Policy and X-Content-Type-Options headers our handler never set. A client parsing every 404 as JSON would crash on the second one; a catch-all route (covered with middleware in the next lesson) is how real APIs keep every response in one shape.
Practice
1. Add a GET /users/:id/name route that returns just {"name": "..."}, and a 404 with the same shape as /users/:id when the id doesn’t exist.
Solution
// server.js (added route)
import express from "express";
const app = express();
app.use(express.json());
const users = [
{ id: 1, name: "Alice" },
{ id: 2, name: "Bob" },
];
app.get("/users/:id/name", (req, res) => {
const user = users.find((u) => u.id === Number(req.params.id));
if (!user) {
res.status(404).json({ error: `user ${req.params.id} not found` });
return;
}
res.json({ name: user.name });
});
app.listen(3000, () => console.log("listening on http://localhost:3000"));
$ curl -s http://localhost:3000/users/2/name
{"name":"Bob"}
$ curl -s http://localhost:3000/users/99/name
{"error":"user 99 not found"}
2. Change GET /users to also accept an offset query parameter and slice users.slice(offset, offset + limit).
Solution
// server.js (modified route)
import express from "express";
const app = express();
app.use(express.json());
const users = [
{ id: 1, name: "Alice" },
{ id: 2, name: "Bob" },
];
app.get("/users", (req, res) => {
const limit = Number(req.query.limit ?? 10);
const offset = Number(req.query.offset ?? 0);
res.json(users.slice(offset, offset + limit));
});
app.listen(3000, () => console.log("listening on http://localhost:3000"));
$ curl -s "http://localhost:3000/users?offset=1&limit=1"
[{"id":2,"name":"Bob"}]
3. Remove app.use(express.json()) and POST the same body again. What happens to req.body.name?
Solution
// server-nojson.js
import express from "express";
const app = express();
const users = [
{ id: 1, name: "Alice" },
{ id: 2, name: "Bob" },
];
app.post("/users", (req, res) => {
const user = { id: users.length + 1, name: req.body.name };
users.push(user);
res.status(201).json(user);
});
app.listen(3000, () => console.log("listening on http://localhost:3000"));
$ curl -s -i -X POST http://localhost:3000/users -H 'content-type: application/json' -d '{"name":"Dave"}'
HTTP/1.1 500 Internal Server Error
X-Powered-By: Express
Content-Security-Policy: default-src 'none'
X-Content-Type-Options: nosniff
Content-Type: text/html; charset=utf-8
Content-Length: 1031
Date: Wed, 28 Oct 2026 14:24:55 GMT
Connection: keep-alive
Keep-Alive: timeout=5
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Error</title>
</head>
<body>
<pre>TypeError: Cannot read properties of undefined (reading 'name')<br> at file:///root/node-api/server-nojson.js:12:55<br> at Layer.handleRequest (/root/node-api/node_modules/router/lib/layer.js:152:17)<br> at next (/root/node-api/node_modules/router/lib/route.js:157:13)<br> at Route.dispatch (/root/node-api/node_modules/router/lib/route.js:117:3)<br> at handle (/root/node-api/node_modules/router/index.js:435:11)<br> at Layer.handleRequest (/root/node-api/node_modules/router/lib/layer.js:152:17)<br> at /root/node-api/node_modules/router/index.js:295:15<br> at processParams (/root/node-api/node_modules/router/index.js:582:12)<br> at next (/root/node-api/node_modules/router/index.js:291:5)<br> at router.handle (/root/node-api/node_modules/router/index.js:186:3)</pre>
</body>
</html>
With no body parser registered, req.body isn’t an empty object — it’s undefined entirely, so req.body.name throws a TypeError synchronously. Express 5’s default error handler still catches it and responds with a 500 and a stack trace instead of crashing the process, the same safety net the next lesson’s custom error-handling middleware replaces with a JSON body.
4. Try the classic Express 4 way to constrain a param to digits, "/users/:id(\\d+)". What happens on Express 5, and how do you actually constrain it?
Solution
// server-broken.js
import express from "express";
const app = express();
app.get("/users/:id(\\d+)", (req, res) => {
res.json({ ok: true });
});
app.listen(3000, () => console.log("listening on http://localhost:3000"));
$ node server-broken.js
/root/node-api/node_modules/path-to-regexp/dist/index.js:130
throw new PathError(`Unexpected ${value} at index ${index - 1}`, str);
^
PathError [TypeError]: Unexpected ( at index 10: /users/:id(\d+); visit https://git.new/pathToRegexpError for info
at consumeUntil (/root/node-api/node_modules/path-to-regexp/dist/index.js:130:23)
at parse (/root/node-api/node_modules/path-to-regexp/dist/index.js:140:26)
at process (/root/node-api/node_modules/path-to-regexp/dist/index.js:263:56)
at pathToRegexp (/root/node-api/node_modules/path-to-regexp/dist/index.js:274:5)
at Object.match (/root/node-api/node_modules/path-to-regexp/dist/index.js:225:30)
at matcher (/root/node-api/node_modules/router/lib/layer.js:86:23)
at new Layer (/root/node-api/node_modules/router/lib/layer.js:93:62)
at router.route (/root/node-api/node_modules/router/index.js:428:17)
at app.route (/root/node-api/node_modules/express/lib/application.js:257:22)
at app.<computed> [as get] (/root/node-api/node_modules/express/lib/application.js:478:22) {
originalPath: '/users/:id(\\d+)'
}
Node.js v26.12.1
This doesn’t even start: Express 5 upgraded path-to-regexp, and the old :id(\d+) inline-regex syntax throws at route registration time — many still-published Express tutorials show this exact pattern, written for Express 4. The Express 5 way to constrain a param is to pass a real RegExp as the path itself, with capture groups landing in req.params[0], req.params[1], and so on instead of named keys:
// server-regexparam.js
import express from "express";
const app = express();
const users = [
{ id: 1, name: "Alice" },
{ id: 2, name: "Bob" },
];
app.get(/^\/users\/(\d+)$/, (req, res) => {
const user = users.find((u) => u.id === Number(req.params[0]));
if (!user) {
res.status(404).json({ error: `user ${req.params[0]} not found` });
return;
}
res.json(user);
});
app.listen(3000, () => console.log("listening on http://localhost:3000"));
$ curl -s http://localhost:3000/users/abc
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Error</title>
</head>
<body>
<pre>Cannot GET /users/abc</pre>
</body>
</html>
$ curl -s http://localhost:3000/users/1
{"id":1,"name":"Alice"}
/users/abc never reaches the handler — the RegExp only matches digits — so it falls through to Express’s default HTML 404, the same page the earlier /no-such-route request got.