Organizing Routes into Modules
As an Express application grows past a handful of endpoints, keeping every route in app.js becomes unmanageable — merge conflicts multiply, related logic drifts apart, and finding anything turns into a scavenger hunt. The fix is to split routes into small, focused route modules, each living in its own file under a routes/ directory and exporting an express.Router(). An aggregator then wires them together, and app.js mounts that aggregator under a single prefix. This is the structure nearly every production Express codebase converges on, and it scales cleanly to versioned APIs like /api/v1.
A scalable directory layout
The convention is one router file per resource. Each file is self-contained: it imports Express, builds a router, defines that resource’s routes relative to its own root, and exports the router. An index.js aggregator collects them under sub-prefixes so the main app only ever mounts one thing.
src/
├── app.js
└── routes/
├── index.js # aggregator — combines all resource routers
├── userRoutes.js # everything under /users
└── productRoutes.js # everything under /products
Paths inside each module are written relative to the mount point, not the site root. A router.get("/") in userRoutes.js becomes /api/v1/users once the prefixes are stacked — you never repeat the prefix inside the module.
The resource route modules
Each module is a plain router. Here is a users module using async handlers (the standard for anything touching a database or external service):
// routes/userRoutes.js
const express = require("express");
const router = express.Router();
// Pretend data source
const users = [{ id: 1, name: "Ada" }, { id: 2, name: "Grace" }];
router.get("/", async (req, res) => {
res.json(users);
});
router.get("/:id", async (req, res) => {
const user = users.find((u) => u.id === Number(req.params.id));
if (!user) return res.status(404).json({ error: "User not found" });
res.json(user);
});
router.post("/", async (req, res) => {
const user = { id: users.length + 1, name: req.body.name };
users.push(user);
res.status(201).json(user);
});
module.exports = router;
The products module follows the same shape, keeping each file small and predictable:
// routes/productRoutes.js
const express = require("express");
const router = express.Router();
const products = [{ id: 1, name: "Keyboard", price: 49 }];
router.get("/", async (req, res) => {
res.json(products);
});
router.get("/:id", async (req, res) => {
const product = products.find((p) => p.id === Number(req.params.id));
if (!product) return res.status(404).json({ error: "Product not found" });
res.json(product);
});
module.exports = router;
The aggregator
The index.js aggregator is itself a router. It mounts each resource router under a sub-prefix, exposing a single combined router to app.js. This means adding a new resource later is a one-line change here plus a new file — app.js never needs editing.
// routes/index.js
const express = require("express");
const router = express.Router();
const userRoutes = require("./userRoutes");
const productRoutes = require("./productRoutes");
router.use("/users", userRoutes);
router.use("/products", productRoutes);
module.exports = router;
Mounting under a versioned prefix
In app.js, mount the aggregator once. Putting the version in the mount path — /api/v1 — keeps every route versioned without touching the modules. When you ship a breaking v2, you mount a second aggregator under /api/v2 and the v1 routes keep serving existing clients.
// app.js
const express = require("express");
const apiRoutes = require("./routes");
const app = express();
app.use(express.json());
app.use("/api/v1", apiRoutes);
app.listen(3000, () => console.log("Listening on :3000"));
The three prefixes now compose: /api/v1 + /users + /:id resolves to the route below.
curl http://localhost:3000/api/v1/users/1
Output:
{"id":1,"name":"Ada"}
Tip: Keep version branching at the mount point, not inside route files. A module should never know which API version it runs under — that knowledge lives entirely in
app.js, which is what makes parallelv1/v2support painless.
How the prefixes stack
Mount in app.js | Mount in index.js | Route in module | Resolved URL |
|---|---|---|---|
/api/v1 | /users | / | /api/v1/users |
/api/v1 | /users | /:id | /api/v1/users/:id |
/api/v1 | /products | / | /api/v1/products |
Express 5 note
This pattern is identical in Express 4 and 5. The relevant change in 5.x is path matching: bare wildcard strings like "*" and unnamed parameters are no longer accepted, and named wildcards ("/*splat") are required instead. Module-level routes that use plain paths and :param segments — as shown above — work unchanged across both versions.
Best Practices
- Keep one router file per resource and name it after the resource (
userRoutes.js,orderRoutes.js) for predictable navigation. - Write paths relative to the mount point; never hard-code the
/api/v1prefix inside a module. - Centralize mounting in a single
routes/index.jsaggregator soapp.jsmounts exactly one router. - Put the API version in the mount path, enabling side-by-side
v1andv2aggregators without duplicating handlers. - Register shared middleware (
express.json(), auth) on the app or aggregator, not repeatedly inside each module. - Return consistent status codes and JSON error shapes across modules so clients can handle responses uniformly.
- Keep handlers thin — delegate real work to a service/controller layer so route files stay focused on routing.