Skip to content
Express.js ex routing 4 min read

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 parallel v1/v2 support painless.

How the prefixes stack

Mount in app.jsMount in index.jsRoute in moduleResolved 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/v1 prefix inside a module.
  • Centralize mounting in a single routes/index.js aggregator so app.js mounts exactly one router.
  • Put the API version in the mount path, enabling side-by-side v1 and v2 aggregators 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.
Last updated June 14, 2026
Was this helpful?