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

  1. What routing is exactly
  2. The naive attempt: if/else over req.url
  3. Where it breaks: routes with parameters
  4. The route table
  5. compilePattern: from /events/:id to a regular expression
  6. Normalizing the path before matching it
  7. Matching, extracting parameters and responding 404 or 405
  8. Asynchronous dispatch and centralized error handling
  9. The routes of the Escena Viva API
  10. Why we will use Express from Module 6 onwards

  1. 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-001 and /events/evt-002 must 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) and POST /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).

  1. The naive attempt: if/else over req.url

This 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.

  1. 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.

  1. 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]

  1. compilePattern: from /events/:id to a regular expression

We 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.

  1. 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.

  1. Matching, extracting parameters and responding 404 or 405

We 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:

curl -i -X DELETE http://localhost:3000/events
HTTP/1.1 405 Method Not Allowed
Allow: GET, HEAD
Content-Type: application/json; charset=utf-8

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.

  1. 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_FOUND and forget about it. The translation to 404 is done by bodyForError, in a single place.
  • The domain still knows nothing about HTTP. Event.reserve throws INSUFFICIENT_CAPACITY just as it did when the Module 1 CLI called it.
  • Nothing is lost. An error with no known translation becomes a 500 and is logged to stderr, which is exactly what we want in order to find out about it.
  • res.headersSent decides 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.

  1. 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

  1. 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.url instead of url.pathname. Any query parameter breaks the match and produces an inexplicable 404.
  • Using .+ instead of [^/]+ for the parameters. The pattern swallows whole segments and tramples other routes.
  • Forgetting the ^ and $ anchors. /events would match inside /admin/events/delete.
  • Registering /events/:id before /events/featured. The first match wins, and the literal route becomes unreachable.
  • Returning 404 when 405 is what fits. And if you return 405, the Allow header is mandatory.
  • Lowercasing the path. It destroys legitimate identifiers such as EV-2026-000123. If you want tolerance, redirect with a 301.
  • Putting a try/catch in every handler. Repetition, inconsistency and swallowed errors. One central one is enough.
  • Tip: expose router.routes and add a GET /routes route 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

Module 2: Core Concepts

Module 3: File System and I/O

Module 4: HTTP and Web Servers

Module 5: NPM and Package Management

Module 6: The Express.js Framework

Module 7: Databases and ORMs

Module 8: Authentication and Authorization

Module 9: Testing and Debugging

Module 10: Advanced Topics

Module 11: Deployment and DevOps

Module 12: Real-World Projects

© Copyright 2026. All rights reserved