Routers and project structure

Express · Practical · 12 min

Single-file apps become unmaintainable fast; routers are how Express apps stay modular.

express.Router() creates a mini-application: it has get, post, use, route and param like app, but no server of its own. You mount it under a prefix with app.use("/users", usersRouter), and inside the router paths are relative: router.get("/:id") answers /users/:id.

Routers give you:

  • Modularity — one file per resource or feature, each exporting a router.
  • Scoped middleware — router.use(requireLogin) only affects that router's routes.
  • Versioning — mount the same or different routers under /v1 and /v2.

By default a child router does not see parameters from its mount path. For nested resources like /projects/:projectId/tasks, create the child with express.Router({ mergeParams: true }) so req.params.projectId is visible.

A structure that scales well for APIs groups by feature, not by technical layer:

src/
  app.js            creates app, global middleware, mounts routers
  server.js         listen + shutdown
  config.js
  users/
    users.router.js
    users.service.js    business logic, no req/res
    users.schema.js     validation
  middleware/

Keep handlers thin: parse and validate input, call a service function, map the result to a response. Business logic in plain functions is easy to unit-test and reuse from jobs or scripts.

router.param("id", fn) runs fn once per request whenever a route with :id matches — a neat way to load a record and 404 early. Express 5 also re-exposes app.router, the app's own base router.

Example

// tasks.router.js
import { Router } from "express";

const tasks = [{ id: 1, projectId: 7, title: "write docs" }];
export const tasksRouter = Router({ mergeParams: true });   // sees :projectId

tasksRouter.get("/", (req, res) => {
  const projectId = Number(req.params.projectId);
  res.json(tasks.filter((t) => t.projectId === projectId));
});

// projects.router.js
export const projectsRouter = Router();
projectsRouter.param("projectId", (req, res, next, value) => {
  if (!/^\d+$/.test(value)) return res.status(400).json({ error: "bad project id" });
  next();
});
projectsRouter.get("/:projectId", (req, res) => res.json({ id: Number(req.params.projectId) }));
projectsRouter.use("/:projectId/tasks", tasksRouter);

// app.js
import express from "express";
const app = express();
app.use(express.json());
app.use("/api/v1/projects", projectsRouter);   // GET /api/v1/projects/7/tasks
app.listen(3000);

Remember

  • express.Router() is a mountable mini-app with relative paths
  • Use mergeParams: true for nested resources
  • Organise by feature; keep handlers thin and logic in services

Watch out for

  • Forgetting mergeParams and finding the parent's req.params empty in a nested router.

Check yourself

  1. A router mounted at /teams/:teamId/members needs teamId. What must you do?

Sign in to answer, see the explanations, and get this lesson back as a quick review later.

Official docs: expressjs.com/en/guide/routing