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
- Route methods and the anatomy of a handler
- Route parameters and
req.params - Optional parameters, wildcards and regular expressions
- The other request data:
query,body,headers,ip express.Router(): modular routers- Reorganizing the Escena Viva API
- Separate controllers
router.route()androuter.param()- Order matters
- The
resmethods
- 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 methodEach 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.
- Route parameters and
req.params
req.paramsapp.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.paramsalways 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.
- 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.
- 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.
express.Router(): modular routers
express.Router(): modular routersA 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/:idThe 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).
- 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.
- 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());
}
router.route() and router.param()
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.
- 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); // wildcardWith 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.
- The
res methods
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
/:idbefore/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. ATypeErrorin Express 5; store the normalized result in another property. - Expecting
req.bodyto exist withoutexpress.json(). It will beundefinedand you will get aTypeError: the most common failure when writing your firstPOST. - 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
- 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
