Routers — Basics · Express

Split the app into routers, one per resource and one file each, mounted under their paths. Load a cat once for every route with :id, nest an owner's cats under the owner, and see what the request knows about where it is.

What you will learn

Read the theory for Routers

All Basics lessons

All Express courses

loading types…

What you'll learn

  • Group routes in an express.Router() in their own file and mount it with app.use(path, router)
  • Load what a route parameter names once with router.param, and scope middleware to one router
  • Nest a router under a parameterised path with mergeParams, and read req.baseUrl, req.path and req.originalUrl

Routers

An app with twenty routes in one file is hard to read, and the routes that belong together, everything under /owners, sit next to everything else. Express's answer is the router: a mini application with its own routes and middleware, written in its own file and mounted under a path. It is the same idea as a NestJS controller with a path prefix, or a module that groups a feature, and it is how every Express app beyond a toy is organised.

A router is an app without listen()

// owners.ts
import { Router } from 'express';

export const ownersRouter = Router();

ownersRouter.get('/', (req, res) => { /* every owner */ });
ownersRouter.get('/:id', (req, res) => { /* one owner */ });
// main.ts
app.use('/owners', ownersRouter);

A router has get, post, use, route and the rest, exactly like app. Its paths are relative to where it is mounted: mounted at /owners, its / answers /owners and its /:id answers /owners/7. The router does not know its own prefix, which is what makes it reusable and lets the prefix live in one place.

Middleware for one router

router.use(fn) runs fn for requests that reach this router only. A check that applies to one resource, a header that says which part of the app answered, logging for one area: each goes on its router, and the rest of the app never runs it.

Where am I? baseUrl, path, originalUrl

Inside a mounted router, the request's URL is split in three:

PropertyFor GET /owners/7?full=1 inside the router mounted at /owners
req.baseUrl/owners, where the router is mounted
req.path/7, the part the router matches against
req.originalUrl/owners/7?full=1, the URL as it arrived

A log written inside a router uses originalUrl; path alone would drop the prefix.

Loading a parameter once: router.param()

Several routes on /:id all start by finding the thing the id names, and answering 404 if there is none. router.param(name, handler) does that once, for every route in the router whose path has :name, before the route runs:

ownersRouter.param('id', (req, res, next, id) => {
  const owner = owners.find((o) => o.id === Number(id));
  if (!owner) {
    res.status(404).json({ error: `Owner ${id} not found` });
    return;
  }
  res.locals.owner = owner;
  next();
});

ownersRouter.get('/:id', (req, res) => {
  res.json(res.locals.owner);
});

It is middleware with a fifth argument, the parameter's value: answer or call next(), as always.

Order inside a router

Routes are tried in the order they are declared, in a router as in the app. /:id matches any single segment, so a route for /stats declared after it is never reached: stats is taken for an id. Fixed paths go before parameterised ones.

Nested resources: mergeParams

An owner's cats live at /owners/:ownerId/cats. Mounting a router there reads naturally, ownersRouter.use('/:ownerId/cats', ownerCatsRouter), but by default a router only sees the parameters in its own paths, so req.params.ownerId is undefined inside it. Creating it with Router({ mergeParams: true }) merges in the parameters of the path it is mounted at.

Your task

data.ts holds the owners and the cats. Split the API into two routers:

  1. cats.ts: every response carries x-resource: cats. GET / answers every cat; GET /where answers { baseUrl, path, originalUrl } as the request sees them; for routes with :id, router.param loads the cat into res.locals.cat or answers 404 with { "error": "Cat <id> not found" }, and GET /:id answers res.locals.cat.
  2. owners.ts: every response carries x-resource: owners. GET / answers every owner; GET /:ownerId answers one or 404 with { "error": "Owner <id> not found" }. A router of its own, mounted at /:ownerId/cats, answers GET / with that owner's cats, or the same 404.
  3. main.ts mounts them under /cats and /owners.

When it fails

  • Every request is Express's 404 page: the routers are not mounted, or mounted at a path with a typo.
  • GET /cats/where answers Cat where not found: /where is declared after /:id.
  • "Ann's cats" is an empty list or a 404 for undefined: the nested router cannot see :ownerId. Router({ mergeParams: true }).
  • x-resource is missing on one route: the router's middleware is declared after that route.

Remember

  • Router() groups routes and middleware; app.use(path, router) mounts it, and its paths are relative.
  • Inside a router, req.baseUrl is the mount point, req.path the rest, req.originalUrl the whole URL.
  • router.param loads a parameter once for every route that has it.
  • A nested router needs mergeParams: true to read its parent's parameters.
Stuck? Show a hint

A router has the same methods as the app, and its paths are relative to where it is mounted: catsRouter.get('/') answers /cats. router.param('id', (req, res, next, id) => { … }) runs before any of that router's routes with :id. Router({ mergeParams: true }) makes the parent's params visible, so a router mounted at /:ownerId/cats can read req.params.ownerId. Declare /where before /:id.