A route is a claim: this method, this shape of path, this function. A router is the code that checks the claim. Everything else a framework adds is a decision about defaults.

Start with the pathname. The query string is not part of the pattern. If you leave it attached, /users/:id and /users/:id?verbose=1 look like different paths, and the match fails for a boring reason.

Compile the pattern when the route is registered.

function compile(pattern) {
  const names = [];
  const source = pattern
    .split("/")
    .map((part) => {
      if (part.startsWith(":")) {
        names.push(part.slice(1));
        return "([^/]+)";
      }
      return part;
    })
    .join("/");
  return { names, regex: new RegExp(`^${source}$`) };
}

Matching walks a list. First hit wins. That rule feels too simple until two patterns overlap and the one you wanted is second. The list order is the specification. Write it down, or the router will write it down for you in the form of a surprise.

Parameters come back as strings. id is not a number because the path contained digits. Coercion is a later choice, and it belongs next to validation, not inside the matcher.

A miss is a result, not an exception from the depths of the table. The handler for "no route" can be the same error path as a handler that refuses. One way to say no.

This note is a sample of that argument. The build it belongs to is the from-scratch framework, and the framework is a learning project, not a server with a traffic graph.

All notes