The Escena Viva application now has a solid structure but a single route. In this lesson we fill it up: we bring back the four API endpoints from Module 4 (/health, /events, /events/:id, /events/:id/sessions), we add sessions and orders, and we organize them into modular routers with separate controllers. Along the way we will compare each Express mechanism with the router.js you wrote by hand, because the best way to understand app.get('/events/:id') is to remember what it took to do it without a framework.

Contents

  1. Route methods and the anatomy of a handler
  2. Route parameters and req.params
  3. Optional parameters, wildcards and regular expressions
  4. The other request data: query, body, headers, ip
  5. express.Router(): modular routers
  6. Reorganizing the Escena Viva API
  7. Separate controllers
  8. router.route() and router.param()
  9. Order matters
  10. The res methods

  1. Route methods and the anatomy of a handler

Express exposes one method per HTTP verb, plus all for every one of them:

app.get('/events', handler);         // read a collection
app.post('/orders', handler);        // create a resource
app.put('/sessions/:id', handler);   // replace entirely
app.patch('/sessions/:id', handler); // modify partially
app.delete('/orders/:id', handler);  // delete
app.all('/api/*rest', handler);      // any method

Each one registers an entry in Express's internal stack: a pattern, a method and one or more functions. It is literally what your router.register(method, pattern, handler) did, with the difference that Express accepts several functions per route: in app.post('/orders', validateBody, checkCapacity, createOrder) all three run in order and each one decides whether to continue. That is already middleware, and it is the subject of 06-04.

The anatomy of a handler

// req: an extended IncomingMessage; res: an extended ServerResponse.
function handler(req, res, next) {
  // Three possible exits:
  // 1. Respond: res.json(...) / res.send(...) — the chain ends.
  // 2. Continue: next() — move on to the next matching handler.
  // 3. Fail: next(error) — jump to the error handler (06-07).
  res.json({ status: 'ok' });
}

A golden rule: every execution path must end in exactly one of those three things. If an if neither responds nor calls next(), the request hangs until the client gives up. We will diagnose that in 06-04.

  1. Route parameters and req.params

app.get('/events/:id', (req, res) => {
  res.json({ requestedId: req.params.id });
});
// curl -s localhost:3000/events/evt-002  ->  {"requestedId":"evt-002"}

Compare it with what your Module 4 code did. compilePattern('/events/:id') walked the pattern, replaced every :name with a capture group, stored the names in an array and built the regex /^\/events\/([^/]+)$/; then match ran it against the path and rebuilt the { id: 'evt-002' } object by hand. Express does the same, with differences that matter:

Aspect Your compilePattern Express (path-to-regexp)
URL decoding You called decodeURIComponent yourself Automatic: /events/Sala%20B%C3%B3veda arrives decoded
Special characters and edge cases Escaped by hand; risk of a catastrophic regex Safe escaping and bounded, well-tested patterns
Multiple parameters It worked, but the code grew /events/:eventId/sessions/:sessionId at no cost

Several parameters in one route:

app.get('/events/:eventId/sessions/:sessionId', (req, res) => {
  const { eventId, sessionId } = req.params;
  res.json({ eventId, sessionId });
});
// GET /events/evt-001/sessions/ses-001-2
//   -> {"eventId":"evt-001","sessionId":"ses-001-2"}

Important: req.params always contains strings. If you expect a number, convert it and validate it. In 06-06 we will do it with schemas instead of by hand.

  1. Optional parameters, wildcards and regular expressions

This is where Express 5 broke with Express 4; pay attention if you run into old code.

Need Express 4 Express 5
Optional parameter /events/:id? /events{/:id}
Wildcard /files/* → req.params[0] Anonymous not allowed (it throws); named, /files/*path → req.params.path
Repeatable segment /:section+ /*section (captures the rest)
// Optional parameter: answers both /events and /events/evt-001.
app.get('/events{/:id}', (req, res) => {
  const { id } = req.params;
  res.json(id === undefined ? { type: 'collection' } : { type: 'item', id });
});

// Named wildcard: GET /downloads/reports/2026/occupancy.csv
//   -> req.params.path === 'reports/2026/occupancy.csv'
app.get('/downloads/*path', (req, res, next) => {
  try {
    // The wildcard is the classic door to path traversal:
    // we reuse resolveWithin from module 3.
    res.sendFile(resolveWithin(REPORTS_DIR, req.params.path));
  } catch (error) {
    next(error); // code PATH_NOT_ALLOWED -> 403
  }
});

Notice the lesson repeating itself: the wildcard is convenient and dangerous. Express extracting the path for you does not mean it is safe; resolveWithin is still essential.

For cases the segment syntax does not cover, a regular expression is accepted as a pattern; with it, the captures arrive by numeric index: app.get(/^\/events\/(evt-\d{3})$/, (req, res) => res.json({ id: req.params[0] })). You can do it, but use it sparingly: a regex in the route is hard to read and hides validation where nobody looks for it. It is better to accept :id and validate the format with a schema (06-06), because then the error you return explains itself instead of being a silent 404.

  1. The other request data

Property Content Notes
req.params Route parameters Always strings, already decoded
req.query The parsed query string Read-only in Express 5
req.body The parsed body undefined without express.json() (06-04)
req.headers / req.get(n) Lowercased headers / a single header req.get('Content-Type') ignores casing
req.ip, req.protocol, req.secure The client's IP and scheme They depend on trust proxy (06-02)
req.method, req.path, req.originalUrl Method and path originalUrl keeps the full URL before mount points
// GET /api/events?venue=Sala%20Boveda&page=2&sort=date
app.get('/api/events', (req, res) => {
  const { venue, page = '1', sort = 'date' } = req.query;
  res.json({ venue, page: Number.parseInt(page, 10), sort });
});

The classic error when migrating to Express 5

In Express 4, req.query was an ordinary property and a lot of code "normalized" the query by reassigning it:

// WORKED in Express 4 and FAILS in Express 5.
req.query = normalize(req.query); // TypeError: Cannot set property query

// Correct in Express 5: the normalized result goes into another property.
req.normalizedQuery = normalize(req.query);

In Express 5, req.query is a getter that parses the query string on demand and memoizes it; it has no setter. That is exactly the pattern the validate() middleware in 06-06 will use with req.validatedData.

  1. express.Router(): modular routers

A Router is a mini-application: it has its own routes and its own middleware, but it does not listen on any port. It is mounted on the application (or on another router) under a prefix.

const eventRoutes = express.Router();
eventRoutes.get('/', listEvents);    // relative path
eventRoutes.get('/:id', getEvent);   // relative path
// The prefix is decided at mount time, not at declaration time.
app.use('/api/events', eventRoutes);
// Result: GET /api/events and GET /api/events/:id

The advantages over declaring everything in app.js: relative paths (the router does not know where it is mounted, so changing /api/events to /api/v2/events is one line), scoped middleware (orderRoutes.use(limiter) affects orders only), small files (one router per resource, instead of an endless app.js) and reuse (the same router can be mounted twice under different prefixes).

  1. Reorganizing the Escena Viva API

// src/routes/index.js — a single mount point.
const express = require('express');
const { eventRoutes } = require('./events.js');
const { sessionRoutes } = require('./sessions.js');
const { orderRoutes } = require('./orders.js');
function createApiRoutes() {
  const api = express.Router();
  api.get('/health', (req, res) => res.json({ status: 'ok', timestamp: new Date().toISOString() }));
  api.use('/events', eventRoutes);
  api.use('/sessions', sessionRoutes);
  api.use('/orders', orderRoutes);
  return api;
}

module.exports = { createApiRoutes };
// src/routes/events.js
const express = require('express');
const cc = require('../controllers/events.js');

const eventRoutes = express.Router();

// Runs exactly once per request that carries :id, before the handler.
eventRoutes.param('id', cc.loadEvent);
eventRoutes.get('/', cc.listEvents);
eventRoutes.get('/:id', cc.getEvent);
eventRoutes.get('/:id/sessions', cc.listEventSessions);

module.exports = { eventRoutes };

// src/routes/sessions.js and src/routes/orders.js follow the same pattern.
sessionRoutes.get('/:sessionId', getSession);
sessionRoutes.get('/:sessionId/prices', getSessionPrices);
orderRoutes.route('/').post(createOrder);
orderRoutes.route('/:orderId').get(getOrder);

// And the mounting in src/app.js:
app.use(express.json({ limit: configuration.bodyLimit }));
app.use('/api', createApiRoutes());
app.use(express.static(configuration.publicDir, { index: 'index.html' }));

The whole API hangs off /api and the static front end in public/ is served at the root. A single use decides the versioning of the entire API.

  1. Separate controllers

The controller translates HTTP into domain. It contains no business rules: it calls the domain and decides the status code.

// src/controllers/events.js
const { getCatalog, getEventById } = require('../catalog-data.js');

/** GET /api/events — a summarized catalog listing. */
async function listEvents(req, res) {
  const events = (await getCatalog()).map((e) => ({
    id: e.id, title: e.title, venue: e.venue, sessionCount: e.sessionCount,
    totalCapacity: e.totalCapacity, ticketsSold: e.ticketsSold, soldOut: e.soldOut,
  }));
  res.json({ total: events.length, events });
}

// router.param middleware: loads the event exactly once. If it does not exist,
// it propagates the domain error (EVENT_NOT_FOUND -> 404).
async function loadEvent(req, res, next, idValue) {
  req.event = await getEventById(idValue);
  next();
}

/** GET /api/events/:id — the event is already loaded by loadEvent. */
const getEvent = (req, res) => res.json(req.event.toJSON());

/** GET /api/events/:id/sessions */
function listEventSessions(req, res) {
  const { id, sessionCount, sessions } = req.event;
  res.json({
    eventId: id, total: sessionCount,
    sessions: sessions.map((s) => ({
      id: s.id, start: s.start, capacity: s.capacity, available: s.available,
      soldOut: s.soldOut, priceCents: s.priceCents,
    })),
  });
}

module.exports = { listEvents, loadEvent, getEvent, listEventSessions };

Three things you will not see in this file, and that is deliberate: there is no try/catch (in Express 5, if getEventById rejects with the code EVENT_NOT_FOUND, that rejection reaches the error handler on its own, and it will translate it into a 404 using the STATUS_BY_CODE table from Module 4; see 06-07); there is no file reading, which belongs to catalog-data.js; and there are no capacity rules, which belong to domain/.

// src/controllers/orders.js — POST /api/orders
async function createOrder(req, res) {
  // req.body exists thanks to express.json() (06-04);
  // in 06-06 we will replace it with req.validatedData.
  const order = await recordOrder(req.body);
  res.status(201).location(`/api/orders/${order.id}`).json(order.toJSON());
}

  1. router.route() and router.param()

router.route()

It chains several methods on the same path without repeating the pattern: sessionRoutes.route('/:sessionId').get(getSession).patch(updateSession).delete(cancelSession).

Advantages: the pattern is written once (less risk of one copy drifting out of sync) and it is visually clear which operations the resource supports. On top of that, Express automatically generates the 405 Method Not Allowed with the correct Allow header for the methods not registered on that path — exactly what you programmed by hand in router.js.

router.param()

eventRoutes.param('id', loadEvent) registers a middleware that runs once per request when the route contains that parameter, before any handler. With it, the three routes /, /:id and /:id/sessions share the event loading without repeating a line, and the controllers receive req.event already resolved.

In favor Against
It removes duplication in routes that share a parameter and leaves the controllers very clean The controller depends on something you cannot see in its file
A single place for the resource's 404 It also runs where you may not need it, and it confuses people if two routers use the same name with different semantics

An important caveat: router.param() is local to the router where it is registered. If you mount sessionRoutes somewhere else, its param is not inherited. And if the same name (:id) means different things in two routers, use different names (:eventId, :sessionId) as we have done here.

  1. Order matters

Express walks its stack in registration order and keeps the first entry that matches on method and path. This explains 90% of all "my route does not run" cases.

// BAD: the wildcard is declared first and swallows everything else.
eventRoutes.get('/*rest', serveGenericPage);
eventRoutes.get('/featured', listFeatured);  // NEVER runs
eventRoutes.get('/:id', getEvent);           // NEVER runs

// GOOD: from the most specific to the most generic.
eventRoutes.get('/featured', listFeatured);  // literal
eventRoutes.get('/:id', getEvent);           // parameter
eventRoutes.get('/*rest', serveGenericPage); // wildcard

With the bad order, a request to /api/events/featured matches /*rest, which responds, and the chain ends there: the other two routes are dead code. The practical rule, in declaration order: first the literal routes (/featured, /health), then the ones with a parameter (/:id), then the wildcard ones (/*rest) and finally the 404. It is the same problem you already had in Module 4: your router.js also walked the route array in order. Express does not solve it for you; it inherits the same rule.

The final 404 route

// At the end of src/app.js, after all the routes and static files.
app.use((req, res, next) => {
  const message = `There is no route ${req.method} ${req.originalUrl}`;
  next(Object.assign(new Error(message), { appCode: 'ROUTE_NOT_FOUND' }));
});

It is a use with no path, so it matches anything that got that far without being handled. It does not respond: it propagates an error, so that the central handler in 06-07 produces the response in the { error: { code, message, status } } format like any other error.

  1. The res methods

Method What it does What it adds over node:http
res.json(object) Serializes and sends JSON Sets Content-Type and Content-Length, applies json spaces, computes the ETag
res.send(value) Sends a string, a Buffer or an object Infers the Content-Type from the argument's type
res.status(code) Sets the status Returns res, so it chains
res.sendStatus(code) Status + a body with the status text res.sendStatus(204) responds in one line
res.location(url) Sets the Location header Encodes the URL correctly
res.redirect([code], url) Location + status + body 302 by default; use 301 or 308 for permanent ones
res.sendFile(absolutePath) Sends a file ETag, Content-Type, ranges and streaming: it is your static.js
res.set(n, v) / res.type(t) Headers and Content-Type set accepts an object; type accepts shorthands ('json')
res.end() Ends the response The same one from node:http, with no extras
// Concrete examples in Escena Viva.
res.status(201).location(`/api/orders/${order.id}`).json(order.toJSON());
res.sendStatus(204); // cancellation accepted, no body

// A permanent redirect from an old URL.
app.get('/events/:id', (req, res) => res.redirect(301, `/api/events/${req.params.id}`));

// Several headers at once.
res.set({ 'Cache-Control': 'public, max-age=60', 'X-Venue': 'Teatro Almendra' });

Check the equivalence with Module 4: curl -si localhost:3000/api/events/evt-001 returns 200 OK with Content-Type: application/json; charset=utf-8, Content-Length and an ETag: W/"19c-...". Your static.js computed that ETag by hand with a hash; res.json() does it by default for any response.

Common Mistakes and Tips

  • Declaring /:id before /featured. The parameter captures the literal word and the specific route ends up dead. From specific to generic, always.
  • Using an unnamed * in Express 5. It is no longer allowed: the application fails at startup. Migrate to *name.
  • Reassigning req.query. A TypeError in Express 5; store the normalized result in another property.
  • Expecting req.body to exist without express.json(). It will be undefined and you will get a TypeError: the most common failure when writing your first POST.
  • Putting business logic in the controller. If it computes occupancy or validates capacity, that logic cannot be reused from the reports or tested without HTTP.
  • Forgetting that router.param() is local to the router: it is not inherited when you mount it elsewhere.
  • Trusting an unvalidated :id. It arrives as a string and can be anything. A silent 404 does not help the client; in 06-06 we will return an explanatory 400.
  • Tip: in development, print the route table at startup by walking app.router.stack. Seeing the real registration order clears up many mysteries.

Exercises

Exercise 1: filtering and pagination by query

Extend GET /api/events so that it accepts ?venue=, ?soldOut=true|false, ?page= and ?perPage= (defaults 1 and 10, maximum 50). Return { page, perPage, total, totalPages, events }. Do not reassign req.query. With the seed data, ?perPage=2 must return 2 pages for the 3 events.

Exercise 2: a sessions router with route() and param()

Create src/routes/sessions.js with a router.param('sessionId', loadSession) that looks up the session by walking the catalog and leaves it on req.session (propagating SESSION_NOT_FOUND if it does not exist), and expose a GET with router.route('/:sessionId') that returns the detail. Check that PUT /api/sessions/ses-001-1 returns 405 with the Allow header.

Exercise 3: the order that breaks things

Write an application with three badly ordered routes (/*rest, /featured, /:id), prove with curl that only the first one runs, reorder them and test again. Also add a /debug/routes endpoint that returns the list of registered routes in their real order.

Solutions

Solution 1

// src/controllers/events.js (fragment)
const MAX_PER_PAGE = 50;

async function listEvents(req, res) {
  // Reading without reassigning req.query; every conversion is manual.
  const { venue, soldOut } = req.query;
  const page = Math.max(1, Number.parseInt(req.query.page ?? '1', 10) || 1);
  const raw = Number.parseInt(req.query.perPage ?? '10', 10) || 10;
  const perPage = Math.min(MAX_PER_PAGE, Math.max(1, raw));

  let events = await getCatalog();
  if (venue) events = events.filter((e) => e.venue === venue);
  if (soldOut === 'true' || soldOut === 'false') {
    events = events.filter((e) => e.soldOut === (soldOut === 'true'));
  }

  const total = events.length;
  const from = (page - 1) * perPage;
  res.json({
    page, perPage, total,
    totalPages: Math.max(1, Math.ceil(total / perPage)),
    events: events.slice(from, from + perPage).map((e) => e.toJSON()),
  });
}

All this manual conversion —parseInt, caps, ?? '1'— is exactly what we will replace with a schema in 06-06.

Solution 2

// src/controllers/sessions.js
async function loadSession(req, res, next, idValue) {
  const catalog = await getCatalog();
  for (const event of catalog) {
    const session = event.findSession(idValue);
    if (session) {
      req.session = session;
      req.sessionEvent = event;
      next();
      return;
    }
  }
  const message = `There is no session ${idValue}`;
  next(Object.assign(new Error(message), { appCode: 'SESSION_NOT_FOUND' }));
}

const getSession = (req, res) =>
  res.json({ ...req.session, eventId: req.sessionEvent.id });

module.exports = { loadSession, getSession };

And curl -si -X PUT localhost:3000/api/sessions/ses-001-1 returns 405 Method Not Allowed with Allow: GET, HEAD, generated by Express without you writing anything.

Solution 3

// route-order.js — the failing version; reorder it to compare.
const app = require('express')();
app.get('/*rest', (req, res) => res.json({ route: 'wildcard' }));
app.get('/featured', (req, res) => res.json({ route: 'featured' }));
app.get('/:id', (req, res) => res.json({ route: 'parameter' }));

// The list of routes in their real order comes from the internal stack.
app.get('/debug/routes', (req, res) => {
  res.json(
    app.router.stack.filter((l) => l.route).map((l) => l.route.path)
  );
});

app.listen(3000);

With that order, curl -s localhost:3000/featured and curl -s localhost:3000/evt-001 both return {"route":"wildcard"}. After reordering (featured, :id, *rest) they return {"route":"featured"} and {"route":"parameter"}. Note: /debug/routes is not reachable either while the wildcard is declared first; the exercise proves itself.

Conclusion

Escena Viva now has an organized API: three routers mounted under /api, controllers that only translate HTTP into domain, router.param() loading the event exactly once and leaving it on req.event, and router.route() grouping each resource's methods with the 405 and its Allow header for free. You have seen that :id and req.params are your compilePattern better solved, that Express 5 wildcards require a name and still need resolveWithin, that req.query is read-only and why that breaks old code, and that route declaration order is a rule Express inherits, not one it solves.

One very visible loose end remains: POST /api/orders reads req.body, and req.body does not exist on its own: it only appears if express.json() has run before the route. That "before the route" is the doorway to the framework's central concept. In the next lesson, Middleware, we will see what exactly that chain of (req, res, next) functions crossing every request is: how it is walked, what next(), next(error) and —the most frustrating Express bug— not calling next() at all mean. We will write Escena Viva's own middleware (logging with duration, request identifier, cache control) and take express.json() and express.static() apart by comparing them with the body.js and static.js you wrote by hand.

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