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
/v1and/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: truefor nested resources - Organise by feature; keep handlers thin and logic in services
Watch out for
- Forgetting
mergeParamsand finding the parent'sreq.paramsempty in a nested router.