Skip to main content
Node.js · lesson 4 of 20
Node.js beginner Lesson 4 of 20

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

nodejs express

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 &#39;name&#39;)<br> &nbsp; &nbsp;at file:///root/node-api/server-nojson.js:12:55<br> &nbsp; &nbsp;at Layer.handleRequest (/root/node-api/node_modules/router/lib/layer.js:152:17)<br> &nbsp; &nbsp;at next (/root/node-api/node_modules/router/lib/route.js:157:13)<br> &nbsp; &nbsp;at Route.dispatch (/root/node-api/node_modules/router/lib/route.js:117:3)<br> &nbsp; &nbsp;at handle (/root/node-api/node_modules/router/index.js:435:11)<br> &nbsp; &nbsp;at Layer.handleRequest (/root/node-api/node_modules/router/lib/layer.js:152:17)<br> &nbsp; &nbsp;at /root/node-api/node_modules/router/index.js:295:15<br> &nbsp; &nbsp;at processParams (/root/node-api/node_modules/router/index.js:582:12)<br> &nbsp; &nbsp;at next (/root/node-api/node_modules/router/index.js:291:5)<br> &nbsp; &nbsp;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.

Frequently Asked Questions

What does app.get(path, handler) do that a plain http.createServer callback doesn't?
It registers a handler for one specific method and path pattern, letting Express pick which handler runs instead of you writing an if chain against req.method and req.url. Express also parses path parameters like :id into req.params for you.
Why is req.params.id a string even though the route is /users/:id?
Every URL segment is text on the wire; Express never guesses a type for it. Convert explicitly with Number(req.params.id) before comparing against a numeric id, the same way the raw http module's regex match required.
What's the difference between a route I didn't define and one that 404s on purpose?
A route Express never matched (no app.get/post registered for that path) falls through to Express's own default handler, which sends an HTML page. A route you did define that calls res.status(404).json(...) sends whatever body you wrote. They're both status 404, but only one is yours.
Is express.json() required to read req.body?
Yes, for a JSON request body. Without app.use(express.json()), req.body is undefined for a JSON POST, because Express doesn't parse any body by default — the same manual buffering the raw http module needed is just packaged as middleware here.
What Express version does this lesson use?
Express 5.3.0, the current major version, on Node 26.12.1. Express 5 changed several defaults from Express 4, including how async handler errors are forwarded, covered in a later lesson.