Express Routing

Organise Express apps with express.Router: split routes into files, mount them on a prefix, chain handlers with router.route, use multiple parameters and router.param, and structure a project so it stays easy to navigate.

Lesson 11 of 43Intermediate18 min read

Defining every route directly on app works for a handful of endpoints, but a real API quickly grows to dozens: products, orders, users, authentication, admin tools. One huge file full of app.get calls becomes hard to read, hard to test and easy to break.

Express solves this with routers. An express.Router() is a mini application that holds a group of related routes. You build a router for products in one file, a router for orders in another, and mount each one on a path prefix with app.use('/products', productsRouter). Inside the router, paths are written relative to that prefix, so the products file only needs to know about / and /:id.

Routers also support middleware, covered in the next lesson, so you can, for example, require a login for every route in the admin router with one line. In this lesson you'll create and mount routers, chain methods with router.route, work with several parameters and nested routes, load resources with router.param, and organise an Express project into folders.

Creating and mounting a router

JavaScript
// routes/products.js
import { Router } from 'express';

const router = Router();

router.get('/', (req, res) => res.json(products));        // GET /products
router.get('/:id', (req, res) => res.json(find(req.params.id)));   // GET /products/7
router.post('/', (req, res) => res.status(201).json(create(req.body)));

export default router;
JavaScript
// app.js
import express from 'express';
import productsRouter from './routes/products.js';

const app = express();
app.use(express.json());
app.use('/products', productsRouter);
  • Paths inside a router are relative to where it's mounted.
  • The same router can be mounted in more than one place, for example under /api/v1 and /api/v2.
  • Routers are just middleware, so they run in the order they're added with app.use.

router.route and multiple parameters

router.route(path) chains handlers for different methods on one path, so the path is written once:

JavaScript
router.route('/:id')
  .get(getProduct)
  .put(updateProduct)
  .delete(deleteProduct);

A path can have several parameters: /stores/:storeId/products/:productId gives req.params.storeId and req.params.productId.

For nested routers, such as reviews belonging to a product, create the child with Router({ mergeParams: true }) so it can see the parent's :id parameter.

router.param and Express 5 paths

router.param('id', handler) runs before any route that uses :id. It's a neat place to load the item once, return 404 if it doesn't exist, and attach it to req:

JavaScript
router.param('id', (req, res, next, id) => {
  req.product = products.find((p) => p.id === Number(id));
  if (!req.product) return res.status(404).json({ error: 'Not found' });
  next();
});

Express 5 uses a stricter, safer path syntax. Optional segments are written in braces, /files{/:name}, and wildcards need a name, /docs/*path. Old patterns such as /files/:name? and * from Express 4 tutorials no longer work.

Skipping handlers with next('route') and next('router')

next() moves to the next function in line, but two special values let you jump further.

next('route') skips the rest of the handlers on the current route and carries on with the next route that matches. It's useful when one path pattern covers two cases:

JavaScript
app.get('/items/:id', (req, res, next) => {
  if (req.params.id === 'new') return next('route');   // let the next route handle it
  res.send(`item ${req.params.id}`);
});
app.get('/items/:id', (req, res) => res.send('new item form'));

A request for /items/5 gets "item 5", while /items/new falls through to the form.

next('router') leaves the whole router and returns to the app, which keeps looking for a match. A router mounted at / can use it to step aside: an admin router whose first middleware calls next('router') for non-admin users lets a public /stats route further down answer them instead.

Routers also take options. Router({ caseSensitive: true }) treats /Abc and /abc as different paths, and Router({ strict: true }) treats /abc/ and /abc as different. Both are off by default, which is what most APIs want. Finally, router.all(path, handler) matches every HTTP method, handy for a health check or for answering 405 Method Not Allowed after the real handlers.

Node.js Web Syntax

Router syntax

import { Router } from 'express';
const router = Router();
router.get('/', list);
router.route('/:id').get(show).put(update).delete(remove);
router.param('id', loadItem);
const reviews = Router({ mergeParams: true });
router.use('/:id/reviews', reviews);
export default router;

app.use('/api/products', router);
app.get('/files{/:name}', handler);   // optional segment (Express 5)
app.get('/docs/*path', handler);      // named wildcard (Express 5)

Node.js Web Summary

  • express.Router() groups related routes; app.use(prefix, router) mounts it.
  • Paths inside a router are relative to its mount prefix.
  • router.route(path) chains get, post, put and delete for one path.
  • Nested routers need mergeParams: true to read parent parameters.
  • router.param loads a resource for every route using that parameter.

Node.js Web Examples

Each example shows the code, the output it produces and what each part does.

Example 1

Two routers mounted on prefixes

Node.js
import express, { Router } from 'express';

const products = Router();
products.get('/', (req, res) => res.json(['Mug', 'Lamp']));
products.get('/:id', (req, res) => res.json({ product: Number(req.params.id) }));

const orders = Router();
orders.get('/', (req, res) => res.json({ orders: 2 }));
orders.post('/', (req, res) => res.status(201).json({ created: true }));

const app = express();
app.use('/api/products', products);
app.use('/api/orders', orders);

const server = app.listen(0, async () => {
  const base = `http://localhost:${server.address().port}/api`;
  const calls = [['GET', '/products'], ['GET', '/products/7'], ['GET', '/orders'], ['POST', '/orders'], ['GET', '/users']];
  for (const [method, path] of calls) {
    const r = await fetch(base + path, { method });
    console.log(method, path, r.status, r.ok ? await r.text() : '');
  }
  server.close();
});
Output
GET /products 200 ["Mug","Lamp"]
GET /products/7 200 {"product":7}
GET /orders 200 {"orders":2}
POST /orders 201 {"created":true}
GET /users 404 
Line-by-line explanation
  1. Lines 3 to 5The products router uses short paths relative to its prefix.
  2. Lines 7 to 9A second router handles orders independently.
  3. Lines 12 to 13Mounting adds the prefix, so / inside products becomes /api/products.
  4. Line 17No router handles /api/users, so Express returns 404.
Example 2

router.route, router.param and nested routes

Node.js
import express, { Router } from 'express';

const products = [{ id: 1, name: 'Mug', reviews: ['Great!'] }];

const router = Router();
router.param('id', (req, res, next, id) => {
  req.product = products.find((p) => p.id === Number(id));
  if (!req.product) return res.status(404).json({ error: `No product ${id}` });
  next();
});

router.route('/:id')
  .get((req, res) => res.json({ name: req.product.name }))
  .delete((req, res) => res.sendStatus(204));

const reviews = Router({ mergeParams: true });
reviews.get('/', (req, res) => {
  const product = products.find((p) => p.id === Number(req.params.id));
  res.json({ product: req.params.id, reviews: product.reviews });
});
router.use('/:id/reviews', reviews);

const app = express();
app.use('/products', router);

const server = app.listen(0, async () => {
  const base = `http://localhost:${server.address().port}/products`;
  console.log(await (await fetch(`${base}/1`)).text());
  console.log(await (await fetch(`${base}/9`)).text());
  console.log((await fetch(`${base}/1`, { method: 'DELETE' })).status);
  console.log(await (await fetch(`${base}/1/reviews`)).text());
  server.close();
});
Output
{"name":"Mug"}
{"error":"No product 9"}
204
{"product":"1","reviews":["Great!"]}
Line-by-line explanation
  1. Lines 6 to 10router.param loads the product once for every route with :id, or answers 404.
  2. Lines 12 to 14router.route writes the path once and chains a handler for each method.
  3. Line 16mergeParams lets the nested router read :id from the parent's path.
  4. Line 21The reviews router is mounted under each product's path.
Example 3

Express 5 path patterns

Node.js
import express from 'express';

const app = express();

app.get('/files{/:name}', (req, res) => {
  res.send(req.params.name ? `File ${req.params.name}` : 'All files');
});

app.get('/docs/*path', (req, res) => {
  res.send(`Doc path: ${req.params.path.join(' > ')}`);
});

app.get('/stores/:storeId/items/:itemId', (req, res) => {
  const { storeId, itemId } = req.params;
  res.send(`Store ${storeId}, item ${itemId}`);
});

const server = app.listen(0, async () => {
  const base = `http://localhost:${server.address().port}`;
  for (const p of ['/files', '/files/report.pdf', '/docs/guides/express/routing', '/stores/12/items/345']) {
    console.log(await (await fetch(base + p)).text());
  }
  server.close();
});
Output
All files
File report.pdf
Doc path: guides > express > routing
Store 12, item 345
Line-by-line explanation
  1. Line 5Braces make the /:name segment optional in Express 5.
  2. Line 9A named wildcard matches any number of segments.
  3. Line 10The wildcard's value is an array of the matched segments.
  4. Lines 13 to 14Several parameters can appear in one path and are destructured from req.params.

Common Node.js Web Mistakes

Repeating the prefix inside a router

Don't
router.get('/products/:id', show);
app.use('/products', router);       // the route is now /products/products/:id
Do
router.get('/:id', show);
app.use('/products', router);

Nested router without mergeParams

Don't
const reviews = Router();
reviews.get('/', (req, res) => res.json(req.params.id));   // undefined
Do
const reviews = Router({ mergeParams: true });
reviews.get('/', (req, res) => res.json(req.params.id));

Express 4 wildcard syntax in Express 5

Don't
app.get('*', handler);              // throws a path-to-regexp error in Express 5
Do
app.get('/*splat', handler);        // or use app.use(handler) as a final catch-all

Node.js Web in Real Projects

Routing in larger Express apps

Production APIs usually mount routers under a versioned prefix such as /api/v1, with one router per resource and an index.js that mounts them all. Admin routers get authentication middleware attached once at the router level. Frameworks like NestJS formalise the same idea with controllers, while simpler projects keep the folder-per-feature structure described in this lesson.

Node.js Web FAQ

How do I split Express routes into separate files?

Create a router in each file with const router = Router(), add its routes with paths relative to its prefix, and export default router. In your main app file, import each router and mount it with app.use('/products', productsRouter). Every route in the products file then lives under /products without repeating the prefix.

Why can't my nested router see the parent's route parameter?

By default, a router only sees the parameters in its own paths, so req.params.productId is undefined in a reviews router mounted at /products/:productId/reviews. Create the child with Router({ mergeParams: true }) and the parent's parameters are merged into req.params.

What is the difference between app.use and app.get for mounting routes?

app.use(path, handler) matches every HTTP method and any path that starts with the prefix, which is what you want for routers and middleware. app.get(path, handler) matches only GET (and HEAD) requests to that exact path pattern. Mount routers with app.use; define individual endpoints with get, post and the other method functions.

Why does Express match the wrong route for /products/new?

Express runs routes in the order you define them, so if /products/:id comes first, it happily matches new as an id. Put fixed paths such as /products/new or /products/search above the parameter route, or check the parameter in the handler and call next('route').

How do I version an Express API with routers?

Mount each version under its own prefix: app.use('/api/v1', v1Router) and app.use('/api/v2', v2Router). The two routers can share most of their route files and only differ where the API changed. Keeping old versions mounted lets existing clients carry on working while new ones move to v2.

Node.js Web Exercises

Answer each one and check it instantly. If you get stuck, open the hint before the answer.

Fill in the blankseasy

Complete the line to mount the router on /orders.

app.('/orders', ordersRouter);
Multiple choiceeasy

What does express.Router() create?

Predict the outputmedium

A router defines router.get('/:id', ...) and is mounted with app.use('/api/users', router). Which URL matches?

Multiple choicemedium

What does router.route('/:id') let you do?

Multiple choicehard

Why would you use router.param('id', ...)?

Node.js Web Quiz

5 questions to check what you learned.

  1. Where do route parameters appear?

    • req.query
    • req.body
    • req.route
    • req.params
  2. What option lets a nested router read its parent's parameters?

    • strict: true
    • mergeParams: true
    • caseSensitive: true
    • nested: true
  3. How is an optional path segment written in Express 5?

    • /files{/:name}
    • /files/:name?
    • /files/[name]
    • /files/*name?
  4. What's a good way to organise routes in a growing project?

    • One file for every route
    • Everything in app.js
    • One router per feature, mounted under a prefix
    • One router for GET and one for POST
  5. In Express 5, what does a named wildcard like /docs/*path put in req.params.path?

    • A string
    • A number
    • An array of the matched segments
    • Nothing