Skip to content

Routing

Routing is the process of deciding which piece of code handles a given HTTP request. The decision is based on two properties of the request:

  • req.method — the HTTP verb: GET, POST, PUT, DELETE, etc.
  • req.url — the path (and optional query string): /users, /users/42, /search?q=node

Node’s built-in server gives you both. You write the logic that maps them to handlers.

The simplest router is a chain of if statements:

Node.js

Needs the Node.js runtime — open in StackBlitz to run.

When URLs contain path parameters or query strings, use Node’s global URL constructor to parse them cleanly. Pass req.url as the first argument and a fake base as the second:

Node.js

Needs the Node.js runtime — open in StackBlitz to run.

Hand-written startsWith checks break as soon as a route has parameters. Node 24 makes the web-standard URLPattern a global, so you can declare routes like /users/:id and read the captured groups. (The legacy url.parse() is runtime-deprecated since Node 24 — use URL and URLPattern instead.)

const userRoute = new URLPattern({ pathname: '/users/:id' });
const match = userRoute.exec('http://localhost:3000/users/42');
console.log(match.pathname.groups.id); // '42'
console.log(userRoute.test('http://localhost:3000/posts/1')); // false

URLPattern in a real router, with 404 vs. 405

Section titled “URLPattern in a real router, with 404 vs. 405”

A route can exist for a path while not supporting the method a client used — that is a different situation from the path not existing at all, and it deserves a different status code: 404 means “no such path,” 405 Method Not Allowed means “this path exists, but not for that verb” (and should include an Allow header listing what is supported):

Node.js
Output (verified on Node 24)
GET /users/42: { status: 200, body: { id: '42' } }
DELETE /users/42: { status: 405, allow: 'GET' }
GET /unknown: { status: 404 }

/users/42 matches the pattern for both GET and DELETE — the difference is only in the second lookup, route.methods[req.method]. That is what makes 405 possible here: the code already knows the path matched by the time it discovers the method did not.

Once you have more than a few routes, extract them into a router map. This is exactly the pattern that Express uses internally:

Node.js

Needs the Node.js runtime — open in StackBlitz to run.

A router is a lookup table with two keys, checked in a specific order: first the path (does anything match this URL shape at all?), then the method (does the thing that matched support this verb?). Checking both at once with a single combined key — 'GET /users' as one string — collapses that two-step decision into one, which is exactly why the tiny router object above cannot tell “no such path” apart from “wrong verb for this path”: both fall into the same else branch.

A single combined-key lookup (method + ' ' + path) is the least code and reads great for a handful of fixed routes — until you need parameters (/users/:id) or a 405 response, at which point you need to match path and method as two separate steps, which is more code than the map. URLPattern gives you real parameter capture and the two-step structure for free, but it is a newer global (Node 24+) — a route table like the one in this lesson is trivially portable to any Node version or to a framework’s router, while URLPattern code is not yet something you can assume every runtime supports.

  • “A 404 is the correct response whenever a route lookup fails.” Only when the path itself has no match. If the path matches but the method is unsupported, the correct code is 405, with an Allow header — 404 tells a client “stop trying this URL,” while 405 tells it “try a different verb here.”
  • “URLPattern parameters are validated automatically.” They are captured as strings, nothing more — /users/:id matches /users/abc just as happily as /users/42. Validating that a captured group looks like an actual ID is still the handler’s job.
  • “A router built by hand is inherently fragile compared to a framework’s.” The underlying decision (match path, then match method) is identical in both — a framework mainly adds convenience (middleware, parameter coercion, wildcard/catch-all routes) on top of the same two-step lookup.

AI-generated routers frequently key routes by method + path as a single string, which reads clean for the happy path and silently produces the wrong status code the moment a path is hit with an unsupported method.

🐛 Spot the bug

const routes = {
'GET /users': listUsers,
'POST /users': createUser,
};
const server = http.createServer(function (req, res) {
const key = req.method + ' ' + new URL(req.url, 'http://x').pathname;
const handler = routes[key];
if (handler) {
handler(req, res);
} else {
res.writeHead(404);
res.end('Not found');
}
});
// A DELETE request to /users returns 404, as if the route didn't exist at all
Show the answer

/users is a real resource — GET and POST are both defined for it — but DELETE /users is not in the map, so it falls into the same else branch as a request to a path that was never defined at all, like /completely-unknown. Both get a 404, which tells the client the path itself is wrong; the client has no way to know the path was actually fine and only the verb was the problem. The fix is to look up by path first, then check whether the matched route supports the request’s method — returning 405 with an Allow header when it does not, exactly like the URLPattern example earlier in this lesson.

Which two properties of the request object are used for routing decisions?
What does new URL(req.url, "http://localhost") give you?
What HTTP status code should a router return when no route matches?
A route exists for /users/:id supporting only GET. A client sends DELETE /users/42. What should the server respond with?
const match = route.pattern.exec(url);
const handler = route.methods[req.method];
// route.methods = { GET: getUser }
Why does keying a route table by req.method + ' ' + pathname as a single combined string make it impossible to return 405?