From if-chains to a routing table
The if-chain from lesson 3-1 works, but at 30 routes it becomes a wall. Real routers use a routing table: a data structure mapping method + path to a handler function.
const routes = { "GET /hello": () => "Hello!", "POST /orders": () => "Order placed", };
Looking up a route is now one line: build the key, check the table. Adding a route is adding a line of data, not more branching logic. This "replace code with data" move is one of the most useful habits in backend work.
A table-driven router
dispatch builds the key, finds the handler, and falls back to 404 when nothing matches.
const routes = { "GET /hello": () => "Hello!", "GET /health": () => "ok", "POST /orders": () => "Order placed", }; function dispatch(method, path) { const handler = routes[method + " " + path]; if (!handler) return "404 not found"; return handler(); } console.log(dispatch("GET", "/hello")); console.log(dispatch("POST", "/orders")); console.log(dispatch("GET", "/nope"));
Output
Hello!
Order placed
404 not founddispatch("GET", "/hello") builds the key "GET /hello" and finds the first handler. Combining the verb and the path into one string is what lets a single object lookup do the work an if-chain spread over six lines.
The if (!handler) check exists because a missing key gives undefined, and calling undefined() throws. Guarding before the call turns a crash into a 404, which is the difference between a broken server and a working one.
Adding a route now means adding a line of data rather than a branch of logic, and the lookup code never changes. That is the replace-code-with-data move, and it is one of the most useful habits in backend work.
Note that the object holds functions as values, which is only remarkable if functions-as-values still feels new. Each handler is stored unevaluated, and handler() is where it finally runs, so building the table costs nothing.
Why the table cannot serve /users/42
It fails because the lookup needs an exact key match, and every user id would need its own entry.
routes["GET /users/42"] is an exact string lookup, so serving ids 1 through a million would mean a million entries. Ids are unbounded and assigned at runtime, so no table written ahead of time can list them.
The general problem is that a path has two kinds of piece. Some parts identify the route, such as users, and some parts are data, such as 42, and exact matching treats both the same way.
The fix is a pattern like /users/:id, where :id is a placeholder that matches any single segment and captures its value. The route becomes one entry again, and the id arrives as data alongside the request.
You implement exactly that next, and it is worth knowing that the version you write is close to what Express does internally.
Dynamic segments: /users/:id
Every web framework supports path patterns like /users/:id. The :id part is a dynamic segment: it matches any value in that position and captures it as a param.
The matching algorithm is honest, simple string work:
- Split both pattern and path on
/. - If they have different lengths, no match.
- Walk the pieces together. A piece starting with
:always matches and recordsparams[name] = value. Any other piece must be equal, or there is no match.
So /users/:id vs /users/42 gives { id: "42" }, and /users/:id vs /orders/42 fails at piece one. Params are always strings, converting "42" to a number is your job later. Now build it, this function sits inside every framework you will ever use.
Matching a path against a pattern
matchPath(pattern, path) returns an object of captured params when the path matches, or null when it does not.
function matchPath(pattern, path) { const patternParts = pattern.split("/"); const pathParts = path.split("/"); if (patternParts.length !== pathParts.length) return null; const params = {}; for (let i = 0; i < patternParts.length; i++) { const part = patternParts[i]; if (part.startsWith(":")) { params[part.slice(1)] = pathParts[i]; } else if (part !== pathParts[i]) { return null; } } return params; } console.log(JSON.stringify(matchPath("/users/:id", "/users/42"))); console.log(JSON.stringify(matchPath("/users/:id/posts/:postId", "/users/7/posts/99"))); console.log(JSON.stringify(matchPath("/users/:id", "/orders/42")));
Output
{"id":"42"}
{"id":"7","postId":"99"}
nullThe length check comes first because it rejects most non-matches immediately, and because the loop would otherwise compare a piece against undefined. Different segment counts can never match, so there is nothing to inspect.
part.startsWith(":") detects a dynamic segment and part.slice(1) strips the colon to get the name, so :id becomes the key id. The value is whatever sat in that position in the real path, with no interpretation.
The else if is where a literal segment must be equal, and returning null there aborts the whole match. Note the asymmetry: a dynamic piece can never fail, and a literal piece is the only thing that can reject a path.
Params are always strings, so {"id":"42"} has quotes around the 42. Converting to a number is the handler's job, and forgetting it produces the bug where id === 42 is false while id == 42 is true.
The second example shows two params captured in one pass, which falls out of the algorithm without extra code. Any number of dynamic segments works, because each one simply adds a key as the loop walks past it.
Returning null rather than an empty object for a failure matters, because a successful match can legitimately return {} when the pattern has no dynamic segments. Distinguishing "no match" from "matched with no params" is why the caller can write if (params).
The full router: a table of patterns
This combines both ideas of the lesson, walking the routes in order and calling the first handler whose pattern matches.
function matchPath(pattern, path) { const patternParts = pattern.split("/"); const pathParts = path.split("/"); if (patternParts.length !== pathParts.length) return null; const params = {}; for (let i = 0; i < patternParts.length; i++) { const part = patternParts[i]; if (part.startsWith(":")) { params[part.slice(1)] = pathParts[i]; } else if (part !== pathParts[i]) { return null; } } return params; } const routes = [ ["GET", "/users/:id", (params) => "user " + params.id], ["GET", "/users/:id/posts/:postId", (params) => "post " + params.postId + " of user " + params.id], ]; function dispatch(method, path) { for (const [m, pattern, handler] of routes) { if (m !== method) continue; const params = matchPath(pattern, path); if (params) return handler(params); } return "404 not found"; } console.log(dispatch("GET", "/users/42")); console.log(dispatch("GET", "/users/7/posts/99")); console.log(dispatch("DELETE", "/users/42"));
Output
user 42 post 99 of user 7 404 not found
The routes became an array rather than an object, which is the necessary change. Patterns cannot be looked up by key, so they have to be tested one at a time, and an array preserves the order that testing happens in.
if (params) return handler(params) works because matchPath returns null on failure, and the handler receives the captured params as its argument. That is precisely how req.params gets filled in Express.
The 404 return goes after the loop, so it only runs when every route failed to match. Putting it inside the loop would make the first non-matching route answer the request.
Order matters as soon as two patterns can match the same path. /users/me and /users/:id both match /users/me, and whichever is listed first wins, which is why specific literal routes are registered before dynamic ones.
if (m !== method) continue filters by verb before doing the string work, which is both faster and clearer than folding the verb into the pattern. It also means DELETE /users/42 never reaches matchPath at all.
The cost of this design is a linear scan, so a hundred routes means up to a hundred matchPath calls per request. Real frameworks optimize with tries or compiled regular expressions, and the behavior they implement is exactly what this loop describes.