We already know how to read a request and build a correct response. What is missing is the decision in between: which piece of code serves each request. That is routing.
A router is nothing more than a function that, given a (method, path) pair, finds the handler it belongs to. It sounds trivial, and the first attempt fits in ten lines of if/else. The problem is that this first attempt falls apart as soon as the first path with an identifier inside it appears, /events/evt-001, and from then on every new route makes the mess worse. In this lesson we build a real router: a route table, /events/:id-style patterns compiled into regular expressions, parameter extraction, 404 when the route does not exist, 405 with an Allow header when it exists but under another method, and a central try/catch that maps our error.appCode values to HTTP statuses using the table from the previous lesson. It is the first appearance of the idea of a centralized error handler, which Express will formalize in lesson 06-07.
Contents
- What routing is exactly
- The naive attempt:
if/elseoverreq.url - Where it breaks: routes with parameters
- The route table
compilePattern: from/events/:idto a regular expression- Normalizing the path before matching it
- Matching, extracting parameters and responding
404or405 - Asynchronous dispatch and centralized error handling
- The routes of the Escena Viva API
- Why we will use Express from Module 6 onwards
- What routing is exactly
Routing is matching a (method, path) pair to a handler. Nothing more. All the complexity of real routers —Express, Fastify, Koa— comes from three added requirements, and a router that solves them is already genuinely useful:
- Patterns have variable parts.
/events/evt-001and/events/evt-002must go to the same handler, which needs to know which of the two it got. - The method is part of the route's identity.
GET /orders(list) andPOST /orders(create) are different things sharing a path. - Failures have to be told apart. "That route does not exist" (
404) is not the same as "that route exists, but not with that method" (405).
- The naive attempt:
if/else over req.url
if/else over req.urlThis is how everybody starts, and for two routes it is perfectly fine:
async function handleRequest(req, res) {
if (req.method === 'GET' && req.url === '/health') return sendJson(res, 200, { status: 'ok' });
if (req.method === 'GET' && req.url === '/events') return sendJson(res, 200, await getCatalog());
return sendError(res, 404, 'Route not found', 'RESOURCE_NOT_FOUND');
}Notice the returns: they are the discipline from the previous lesson, the one that prevents ERR_HTTP_HEADERS_SENT. And this code already has a silent bug: it compares against req.url instead of the pathname. GET /events?venue=Sala%20B%C3%B3veda does not match '/events', because the string includes the query. The result is a baffling 404 that only shows up when somebody adds a filter. The fix is the one from 04-02: parse with new URL and compare against url.pathname.
- Where it breaks: routes with parameters
Now add GET /events/evt-001. Since the identifier is variable, equality comparison no longer works, and manual slicing appears:
// The road to disaster
const parts = url.pathname.split('/'); // ['', 'events', 'evt-001']
if (req.method === 'GET' && parts[1] === 'events' && parts.length === 3) {
return handleEvent(req, res, parts[2]);
}
if (req.method === 'GET' && parts[1] === 'events' && parts[3] === 'sessions') {
return handleSessions(req, res, parts[2]);
}With four routes it is bearable. With twenty it is unreadable, and along the way you will hit very concrete problems:
| Problem | Symptom |
|---|---|
Magic indexes (parts[2]) |
Nobody remembers what 2 is; adding an /api prefix breaks everything |
| Length conditions | parts.length === 3 gets forgotten and /events/x/y/z matches by accident |
| The method repeated in every branch | A DELETE /events falls into the generic 404 instead of giving a 405 |
| No decoding, and fragile ordering | A %20 arrives with the percent sign intact; moving an if leaves a route unreachable |
The solution is not writing better ifs: it is separating the declaration of the routes from the logic that matches them. You declare a table; a generic engine walks it.
- The route table
A route is an object with three fields —{ method: 'GET', pattern: '/events/:id', handler: getEvent }— and the router is a list of them plus two operations: register and dispatch.
With that shape, adding a route means adding a row, and the engine never changes. This is a request's full journey, and everything that follows is filling in those boxes:
flowchart LR
A[request] --> B[normalize path]
B --> C{look up in the table}
C -->|method and pattern match| D[handler with params]
C -->|pattern matches, method doesn't| E[405 + Allow]
C -->|nothing matches| F[404]
D -->|throws an error| G[central try/catch]
G --> H[statusForError]
compilePattern: from /events/:id to a regular expression
compilePattern: from /events/:id to a regular expressionWe need to turn the string '/events/:id' into something that can say whether '/events/evt-001' matches and, on top of that, extract evt-001. The natural tool is a regular expression with capture groups. It is built in three steps:
// Compiles a pattern such as '/events/:id/sessions' into a regular expression
// plus the ordered list of its parameter names.
function compilePattern(pattern) {
const names = [];
// Step 1: escape the characters with a special meaning in a regex.
// A pattern like '/reports/2026.json' has a dot that must be literal.
const escaped = pattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
// Step 2: replace each :name with a capture group for ONE segment.
const source = escaped.replace(/:([a-zA-Z][a-zA-Z0-9]*)/g, (_, name) => {
names.push(name);
return '([^/]+)';
});
// Step 3: anchor it. Without ^ and $ the path would match in pieces.
return { regex: new RegExp(`^${source}$`), names };
}The resulting expression is worth reading slowly. For '/events/:id/sessions' we get:
compilePattern('/events/:id/sessions');
// { regex: /^\/events\/([^\/]+)\/sessions$/, names: ['id'] }Piece by piece:
| Fragment | What it means |
|---|---|
^ … $ |
The path starts and ends here: matching in the middle does not count |
\/events\/ |
Literal text (the slash needs no escaping with new RegExp, but it does no harm) |
( … ) |
Capture group: whatever matches here can be retrieved later |
[^/]+ |
One or more characters that are not a slash: it never crosses into another segment |
The two critical details are [^/]+ and the anchors. [^/]+ instead of .+: with .+, the pattern /events/:id would match /events/evt-001/sessions and id would be 'evt-001/sessions', trampling another route. And without ^ and $, /events would match inside /admin/events/delete, which besides being a functional bug is a security hole.
+ instead of * matters too: with *, /events/ (trailing slash, no identifier) would match with id being an empty string, and you would end up looking for event ''.
With the pattern compiled, matching a path and extracting the parameters is straightforward:
function match(compiled, path) {
const found = compiled.regex.exec(path);
if (found === null) return null;
const params = {};
compiled.names.forEach((name, index) => {
// Group 0 is the full match; the parameters start at 1.
// Decoding happens HERE, one segment at a time (see lesson 04-02).
params[name] = decodeURIComponent(found[index + 1]);
});
return params;
}Returning null rather than {} is deliberate: a pattern with no parameters that matches returns an empty object, which is different from "no match". Confusing the two with an if (!params) would be a bug, because {} is truthy but null is not.
- Normalizing the path before matching it
The same page can be requested in several ways, and they must all lead to the same place:
| Request | Problem | Decision |
|---|---|---|
/events/ |
Superfluous trailing slash | Strip it (except at the root /) |
/EVENTS |
Capitals | Do not change them: paths are case-sensitive by spec |
/events//evt-001 |
Duplicated slash | Collapse it into one |
/events%2Fevt-001 |
Encoded slash | Do not decode the whole path: it is done per segment |
/events?venue=X |
Query included | Match against pathname, not against req.url |
// Leaves the path in canonical form so it can be matched consistently.
function normalizePath(pathname) {
const collapsed = pathname.replace(/\/{2,}/g, '/');
return collapsed.length > 1 ? collapsed.replace(/\/+$/, '') : collapsed;
}About capitals, the spec is clear and counterintuitive: the domain is case-insensitive, the path is not. /Events and /events are different resources. Lowercasing the path "for convenience" seems friendly until a legitimate identifier carries capitals (EV-2026-000123) and you destroy it. If you want to be tolerant, do it with a 301 redirect to the canonical form, not by silently matching: for the same reason the trailing slash is normalized instead of registering two routes, because one piece of content living at two addresses confuses caches and hurts you in search engines.
- Matching, extracting parameters and responding
404 or 405
404 or 405We now have all the pieces. This is the complete src/server/router.js:
// src/server/router.js
// Minimal router: a table of { method, pattern, handler } and a
// dispatcher that matches (method, path), extracts params and centralizes errors.
const { sendError } = require('./responses.js');
// compilePattern, match and normalizePath: seen in sections 5 and 6.
function createRouter() {
const routes = [];
function register(method, pattern, handler) {
routes.push({ method, pattern, compiled: compilePattern(pattern), handler });
return { register, get, post, dispatch }; // chainable
}
const get = (pattern, handler) => register('GET', pattern, handler);
const post = (pattern, handler) => register('POST', pattern, handler);
async function dispatch(req, res) {
const url = new URL(req.url, `http://${req.headers.host ?? 'localhost'}`);
const path = normalizePath(url.pathname);
// HEAD is served by the GET handler: Node already discards the body.
const method = req.method === 'HEAD' ? 'GET' : req.method;
const allowedMethods = new Set(); // so we can give a useful 405
for (const route of routes) {
const params = match(route.compiled, path);
if (params === null) continue; // not even the path lines up
allowedMethods.add(route.method);
if (route.method === method) {
return route.handler(req, res, { params, query: url.searchParams, url });
}
}
if (allowedMethods.size > 0) {
// The route exists: the 405 MUST include Allow with the valid methods.
if (allowedMethods.has('GET')) allowedMethods.add('HEAD');
const allowed = [...allowedMethods].sort().join(', ');
return sendError(res, 405, `Method ${req.method} not allowed on ${path}`,
'METHOD_NOT_ALLOWED', { allowed }, { Allow: allowed });
}
return sendError(res, 404, `Route ${path} does not exist`, 'RESOURCE_NOT_FOUND');
}
return { register, get, post, dispatch, routes };
}
module.exports = { createRouter, compilePattern, match, normalizePath };The 405 is the part almost everyone skips, and the spec is explicit: a 405 response must include the Allow header with the accepted methods. Seeing it work:
A client that gets a 404 assumes it used the wrong URL and gives up. One that gets a 405 with Allow: GET, HEAD knows exactly what to do. It is the difference between an API you can explore and one you have to guess.
About ordering and specificity: our router walks the table and the first match wins, so the most specific routes go first. With /events/:id and /events/featured registered in that order, /events/featured would fall into the first one with id = 'featured'. Serious routers sort by specificity; ours delegates that responsibility to you, which is why you should always register literals before patterns with parameters.
- Asynchronous dispatch and centralized error handling
The handlers are async, because nearly all of them read the catalog. That means any of them can throw, and we do not want to repeat a try/catch in each one. We move it up just once into the dispatcher:
// Wraps the dispatch: a single point where errors are translated.
function createHttpHandler(router) {
return function handleRequest(req, res) {
// Promise.resolve() also captures whatever dispatch throws synchronously.
Promise.resolve().then(() => router.dispatch(req, res)).catch((error) => {
if (res.headersSent) {
// The headers already went out: the status cannot be taken back.
console.error('[error] after sending headers:', error);
return res.destroy();
}
const { status, code, message } = bodyForError(error);
sendError(res, status, message, code);
});
};
}This is a before-and-after moment in the server's architecture:
- Handlers stop handling HTTP errors. If the event does not exist, they throw
EVENT_NOT_FOUNDand forget about it. The translation to404is done bybodyForError, in a single place. - The domain still knows nothing about HTTP.
Event.reservethrowsINSUFFICIENT_CAPACITYjust as it did when the Module 1 CLI called it. - Nothing is lost. An error with no known translation becomes a
500and is logged tostderr, which is exactly what we want in order to find out about it. res.headersSentdecides the last resort. If the response was already under way (halfway through serving a file, for instance), the honest thing is to destroy the connection: the client will detect the truncated response.
This pattern —a wrapper that catches and translates— is exactly what Express calls an error handler, with its four-argument signature (err, req, res, next). We will see it in lesson 06-07 and you will recognize the idea instantly, because you have just written it.
- The routes of the Escena Viva API
With the engine ready, declaring the API is almost like reading a list:
// src/server/api-routes.js
// Declaration of Escena Viva's HTTP routes. The handlers do NOT
// handle errors: they throw with error.appCode and the router decides.
const { getCatalog, getEventById } = require('../catalog-data.js');
const { sendJson } = require('./responses.js');
const startedAt = Date.now();
async function findEventOrFail(id) {
const event = await getEventById(id);
if (!event) {
const error = new Error(`Event ${id} does not exist`);
error.appCode = 'EVENT_NOT_FOUND';
throw error;
}
return event;
}
function registerRoutes(router) {
router.get('/health', (req, res) => {
const uptimeSeconds = Math.round((Date.now() - startedAt) / 1000);
sendJson(res, 200, { status: 'ok', version: process.version, uptimeSeconds });
});
router.get('/events', async (req, res, { query }) => {
const venue = query.get('venue');
const events = (await getCatalog())
.filter((event) => venue === null || event.venue === venue);
sendJson(res, 200, { total: events.length, events });
});
router.get('/events/:id', async (req, res, { params }) => {
sendJson(res, 200, await findEventOrFail(params.id));
});
router.get('/events/:id/sessions', async (req, res, { params }) => {
const event = await findEventOrFail(params.id);
sendJson(res, 200, { eventId: event.id, total: event.sessionCount, sessions: event.sessions });
});
return router;
}
module.exports = { registerRoutes };And the server from lesson 04-01 collapses into a single line: http.createServer(createHttpHandler(registerRoutes(createRouter()))). A full check of the API:
curl -s http://localhost:3000/health # 200 {"status":"ok",...}
curl -s http://localhost:3000/events | head -3 # 200, total 3
curl -s http://localhost:3000/events/evt-002 # 200, Noche de Monologos
curl -s http://localhost:3000/events/evt-999 # 404 EVENT_NOT_FOUND
curl -s http://localhost:3000/events/evt-003/sessions # 200, total 2
curl -i -X POST http://localhost:3000/events # 405 with Allow: GET, HEAD
curl -i http://localhost:3000/does-not-exist # 404 RESOURCE_NOT_FOUND
- Why we will use Express from Module 6 onwards
Our router works, it is short and you understand every line of it. And even so, in a real project almost nobody would write this. It is worth being honest about what it does not have:
| What it lacks | What that implies |
|---|---|
| Middleware | There is no way to say "this runs before every route" (logging, CORS, authentication) |
| Sub-routers | All routes in one flat list; no mounting /api/v1 with its own routes inside |
| Ordering by specificity | Registering in the wrong order makes a route unreachable, with no warning |
| Rich patterns | No optionals, wildcards or per-parameter constraints |
| Body parsing, cookies, content negotiation | Every one of those has to be done by hand (we start in 04-05) |
So why did we do this? Because Express is not magic, it is this very table with more years on it. When in lesson 06-03 you write app.get('/events/:id', handler), you will know that inside there is a pattern compiled into a regular expression, a req.params object coming out of the capture groups and a dispatcher walking a list. And when something does not work —a route that is never reached, an unexpected 404, an error handler that never fires—, you will not be debugging a black box.
Common Mistakes and Tips
- Matching against
req.urlinstead ofurl.pathname. Any query parameter breaks the match and produces an inexplicable404. - Using
.+instead of[^/]+for the parameters. The pattern swallows whole segments and tramples other routes. - Forgetting the
^and$anchors./eventswould match inside/admin/events/delete. - Registering
/events/:idbefore/events/featured. The first match wins, and the literal route becomes unreachable. - Returning
404when405is what fits. And if you return405, theAllowheader is mandatory. - Lowercasing the path. It destroys legitimate identifiers such as
EV-2026-000123. If you want tolerance, redirect with a301. - Putting a
try/catchin every handler. Repetition, inconsistency and swallowed errors. One central one is enough. - Tip: expose
router.routesand add aGET /routesroute in development that lists them. It is the cheapest documentation there is.
Exercises
Exercise 1: testing compilePattern without a server
Write src/lab/test-patterns.js that, without starting any server, checks these cases and prints a pattern | path | matches | params table: /events against /events and /events/evt-001; /events/:id against /events/evt-001, /events/evt-001/sessions and /events/; /events/:id/sessions/:sessionId against /events/evt-002/sessions/ses-002-1; and /reports/2026.json against /reports/2026Xjson. Explain the last case.
Exercise 2: DELETE and the 405
Register DELETE /events/:id in the router with a handler that throws INVALID_STATE if the event has tickets sold. Check with curl -i that: DELETE /events/evt-001 gives 409, DELETE /events/evt-999 gives 404, PUT /events/evt-001 gives 405 with Allow: DELETE, GET, HEAD, and DELETE /events gives 405 with Allow: GET, HEAD.
Exercise 3: counting routes and timings
Add a per-route counter to the router (using route.pattern as the key) with the number of requests and the total time in milliseconds, plus a GET /metrics route that returns it sorted by average time, descending. Measure the effect: which route is the slowest and why?
Solutions
Solution 1. The interesting case is the last one: /reports/2026.json against /reports/2026Xjson does not match, because step 1 of compilePattern escaped the dot and turned it into a literal. Without that escaping, . would match any character and the X would sneak through.
const { compilePattern, match } = require('../server/router.js');
const cases = [
['/events', '/events'],
['/events/:id', '/events/evt-001'],
['/events/:id', '/events/evt-001/sessions'], // null: [^/]+ does not cross segments
['/events/:id', '/events/'], // null: + requires at least one character
['/reports/2026.json', '/reports/2026Xjson'] // null: the dot is escaped
];
for (const [pattern, path] of cases) {
const params = match(compilePattern(pattern), path);
console.log(`${pattern.padEnd(28)} | ${path.padEnd(30)} | ${params !== null} | ${JSON.stringify(params)}`);
}Solution 2. The handler only deals with the domain; the statuses are set by the table from 04-02:
router.register('DELETE', '/events/:id', async (req, res, { params }) => {
const event = await findEventOrFail(params.id); // throws 404
if (event.ticketsSold > 0) {
const error = new Error(`Event ${event.id} has ${event.ticketsSold} tickets sold`);
error.appCode = 'INVALID_STATE'; // -> 409
throw error;
}
sendNoContent(res, 204);
});evt-001 has 276 tickets sold, so it returns 409. Note that the PUT gives a 405 with DELETE and GET in Allow: the router walked the whole table accumulating the methods of every entry whose pattern matched, which is why Allow comes out complete.
Solution 3. The counter wraps around the handler call, inside dispatch:
const metrics = new Map();
async function invokeWithMetrics(route, req, res, context) {
const start = process.hrtime.bigint();
try {
return await route.handler(req, res, context);
} finally {
const ms = Number(process.hrtime.bigint() - start) / 1e6;
const previous = metrics.get(route.pattern) ?? { requests: 0, totalMs: 0 };
metrics.set(route.pattern, { requests: previous.requests + 1, totalMs: previous.totalMs + ms });
}
}The finally is essential: without it, a request that throws would not be counted and the averages would lie precisely in the cases that matter most. The slowest route will be /events/:id/sessions, because getEventById walks the catalog after reading it; with the memoization from lesson 03-01, the first request costs a disk read and the following ones almost nothing. That jump between the first measurement and the rest shows up perfectly in /metrics.
Conclusion
Escena Viva now has a navigable API. You have seen that routing is matching (method, path) to a handler, that the if/else over req.url is wrong from the very first moment —it compares the query along with the path— and that it becomes untenable as soon as parameters appear: magic indexes, length conditions and a fragile ordering nobody dares to touch.
The alternative is src/server/router.js: a table of { method, pattern, handler } and a generic engine. compilePattern turns /events/:id into /^\/events\/([^\/]+)$/, and now you know exactly why each piece is the way it is: the ^ and $ anchors so the whole path has to match, [^/]+ so a parameter does not eat a neighboring segment, + instead of * to reject the empty value, and the prior escaping so a dot in the pattern is a real dot. match extracts the capture groups and decodes them per segment, normalizePath collapses slashes and strips the trailing one, and capitals are respected because paths are case-sensitive. Failures too: 404 if nothing matches, and 405 with the mandatory Allow header if the route exists under another method.
And above all, you have your first centralized error handler: a single catch in the dispatcher that calls bodyForError, maps EVENT_NOT_FOUND to 404 and INVALID_STATE to 409, logs the 500s without leaking them and checks res.headersSent before attempting to respond. The handlers in src/server/api-routes.js have come out clean: they do their job and throw with error.appCode. That idea is the one Express formalizes in 06-07, and you will recognize it instantly because you have just written it.
With GET /events, GET /events/:id, GET /events/:id/sessions and GET /health running, the API returns JSON. But a ticketing platform also has to show a page. In the next lesson, Serving Static Files, we will build Escena Viva's mini front-end from this same server: we will map the URL to a disk path protected with resolveWithin —and you will see live how GET /../../data/events.json walks off with your data without that defense—, we will send the files with createReadStream and pipeline instead of readFile, and we will learn to respond 304 Not Modified with ETag so the second visit downloads nothing.
Node.js Course: From Beginner to Advanced
Module 1: Introduction to Node.js
- What Is Node.js?
- Installing and Setting Up the Environment
- Your First Node.js Program
- The Node.js REPL
- Modern JavaScript for Node.js
- The Course Project: the Escena Viva Platform
Module 2: Core Concepts
- Node.js Architecture
- The Event Loop
- Callbacks and Asynchronous Programming
- Promises and async/await
- Events and EventEmitter
- CommonJS Modules and require()
- ES Modules and Interoperability
Module 3: File System and I/O
- Reading and Writing Files
- The fs Module in Depth
- Cross-Platform Paths with the path Module
- Working with Streams
- Transform Streams and pipeline
- Buffers and Binary Data
Module 4: HTTP and Web Servers
- Creating a Simple HTTP Server
- Handling Requests and Responses
- Manual Routing
- Serving Static Files
- Receiving Data: Request Bodies and JSON
- Consuming External APIs from Node.js
Module 5: NPM and Package Management
- Introduction to NPM and package.json
- Installing and Using Packages
- Semantic Versioning and package-lock
- npm Scripts and Project Automation
- Creating and Publishing Packages
- Dependency Security and Maintenance
Module 6: The Express.js Framework
- Introduction to Express.js
- Setting Up an Express Application
- Routing in Express
- Middleware
- Essential Third-Party Middleware
- Input Data Validation
- Error Handling
Module 7: Databases and ORMs
- Introduction to Databases
- Using MongoDB with Mongoose
- CRUD Operations
- Relationships, Population and Advanced Queries
- Using SQL Databases with Sequelize
- Migrations, Transactions and Seed Data
Module 8: Authentication and Authorization
- Introduction to Authentication
- User Registration and Password Hashing
- Sessions and Cookies with Passport.js
- Authentication with JWT
- Role-Based Access Control
- API Security Best Practices
Module 9: Testing and Debugging
- Introduction to Testing
- Unit Testing with Mocha and Chai
- Test Doubles with Sinon
- Integration Testing
- Coverage and Test Automation
- Debugging Node.js Applications
Module 10: Advanced Topics
- The Cluster Module
- Worker Threads
- Caching and Job Queues with Redis
- Performance Optimization
- Building RESTful APIs
- GraphQL with Node.js
Module 11: Deployment and DevOps
- Configuration and Environment Variables
- Logging and Monitoring in Production
- Using PM2 for Process Management
- Packaging with Docker
- Deploying to Heroku and Other PaaS
- Continuous Integration and Deployment
