Routing Without a Framework

Match the request method and path yourself with a route table, and know when a routing library is worth the dependency.

Syntaxconst path = new URL(req.url, 'http://localhost').pathname; // req.method holds 'GET', 'POST', ...

Routing is choosing which function handles a request. http.createServer gives you one function for every request that arrives, so the choice is yours to make. A route is a pair: an HTTP method (GET, POST) and a path (/users/42).

Matching a request

req.method is the method as an uppercase string. req.url is only the path and query string that came in the request line, with no scheme or host. To read the path on its own, parse it against any base: new URL(req.url, 'http://localhost').pathname.

Keep routes in an array and walk it. Match the path first, then the method. A path that matches with the wrong method is a 405, and the reply should carry an Allow header naming the methods that do work. Nothing matched at all is a 404.

Two ways to send the head

These are the same thing. The second form writes the status line and headers in one call:

res.statusCode = 404;
res.setHeader('Content-Type', 'application/json');

res.writeHead(404, { 'Content-Type': 'application/json' });

When a framework earns its place

A table of regular expressions holds up for a handful of routes. Once you want nested groups of routes, per-route body limits and a fixed order of shared steps, a routing library is less code than maintaining your own. Node.js 23.8.0 added an experimental URLPattern class to node:url, which matches paths without hand-written regular expressions.

Example

Try it yourself
Loading editor…
Press Run to execute the code.

When to use it

  • Serve /health and /users from one built-in server without adding a dependency.
  • Answer 405 with an Allow header when a client POSTs to a GET-only endpoint.
  • Split a growing single-file server into one route module per resource.
  • Read the id out of /users/42 before looking the row up.

More examples

Named path parameters without a library

Filtering by path first and by method second is what lets you answer 405 with a correct Allow header instead of a misleading 404. The `/?$` in the pattern accepts a trailing slash, which is the most common reason a hand-written route table returns 404 for a URL that looks right. Reach for this once you have more than three or four routes and are tired of writing regular expressions by hand.

Example · javascript
// Turn '/users/:id/posts/:postId' into a regular expression plus the parameter names.
function compile(path) {
  const names = [];
  const source = path.replace(/:([A-Za-z0-9_]+)/g, (_, name) => {
    names.push(name);
    return '([^/]+)';
  });
  return { regex: new RegExp(`^${source}/?$`), names };
}

const routes = [
  { method: 'GET', ...compile('/users/:id'), handler: (params) => ({ status: 200, body: params }) },
  { method: 'DELETE', ...compile('/users/:id'), handler: () => ({ status: 204, body: null }) },
];

function match(method, path) {
  const byPath = routes.filter((r) => r.regex.test(path));
  if (byPath.length === 0) return { status: 404 };
  const route = byPath.find((r) => r.method === method);
  if (!route) {
    // The path exists; the method does not. 405 must list what is allowed.
    return { status: 405, allow: byPath.map((r) => r.method).join(', ') };
  }
  const values = route.regex.exec(path).slice(1);
  const params = Object.fromEntries(route.names.map((n, i) => [n, values[i]]));
  return { status: 200, route, params };
}

Dispatching by path prefix

This is how a single-file server stops being a single file. The prefix check tests for an exact match or a following slash, so `/usersettings` does not get routed to the users module. Each route module then matches short paths like `/` and `/:id` and never has to know where it was mounted.

Example · javascript
// Each module exports its own handler and owns everything under its prefix.
const users = require('./routes/users');   // handles /users...
const orders = require('./routes/orders'); // handles /orders...

const mounts = [
  { prefix: '/users', handler: users },
  { prefix: '/orders', handler: orders },
];

const server = require('node:http').createServer((req, res) => {
  const url = new URL(req.url, 'http://localhost');
  const mount = mounts.find((m) => url.pathname === m.prefix || url.pathname.startsWith(`${m.prefix}/`));
  if (!mount) {
    res.writeHead(404, { 'Content-Type': 'application/json' });
    return res.end('{"error":"not_found"}');
  }
  // Hand the module a path relative to its own prefix: '/users/42' becomes '/42'.
  req.subPath = url.pathname.slice(mount.prefix.length) || '/';
  mount.handler(req, res, url.searchParams);
});

The same two routes with Express

Express is one dependency that replaces the route table, the parameter extraction, the 404 fallback and the automatic 405. `res.json(body)` is shorthand for setting Content-Type to application/json and calling `res.end(JSON.stringify(body))`. Nothing here is unavailable to the built-in server; the difference is how much of it you maintain. Start without it to see what it is doing, then add it when the table stops being the shortest code.

Example · javascript
// After: npm install express
const express = require('express');
const app = express();

app.get('/users', (req, res) => {
  res.json({ users: ['ada'] });
});

// ':id' is Express's own path syntax; req.params.id holds the matched segment.
app.get('/users/:id', (req, res) => {
  res.json({ id: req.params.id });
});

app.listen(3000);

Discussion

  • Be the first to comment on this lesson.