Most Express tutorials show all routes defined in app.js. That works for examples but falls apart when the application grows. Here are the patterns that keep routing maintainable.

The router module pattern

Express’s Router is a mini-app — it handles its own middleware and routes and can be mounted on a path.

// routes/users.js
const { Router } = require("express");
const router = Router();

const { authenticate } = require("../middleware/auth");
const userController = require("../controllers/user");

router.get("/", authenticate, userController.list);
router.post("/", authenticate, userController.create);
router.get("/:id", authenticate, userController.getById);
router.put("/:id", authenticate, userController.update);
router.delete("/:id", authenticate, userController.delete);

module.exports = router;
// app.js
const app = express();
const usersRouter = require("./routes/users");
const postsRouter = require("./routes/posts");

app.use("/users", usersRouter);
app.use("/posts", postsRouter);

The /users prefix is defined once in app.js. The router handles GET /, POST /, GET /:id, etc., which resolve to GET /users, POST /users, GET /users/:id when mounted.

Nested routers

For nested resources:

// routes/posts.js
const router = Router({ mergeParams: true }); // mergeParams lets nested routes see parent params

router.get("/", postController.list);
router.post("/", postController.create);

module.exports = router;

// routes/users.js
const postsRouter = require("./posts");

router.use("/:userId/posts", postsRouter);
// Now GET /users/:userId/posts works

mergeParams: true is required for the nested router to access req.params.userId. Without it, req.params only contains the nested router’s own params.

Controller pattern

Routes should not contain business logic. Extract handlers to controllers:

// controllers/user.js
const userService = require("../services/user");

exports.list = async (req, res, next) => {
  try {
    const users = await userService.getAll(req.query);
    res.json(users);
  } catch (err) {
    next(err);
  }
};

exports.create = async (req, res, next) => {
  try {
    const user = await userService.create(req.body);
    res.status(201).json(user);
  } catch (err) {
    next(err);
  }
};

The controller handles the HTTP layer: parsing request data, calling services, formatting responses. The service handles business logic.

Route grouping by concern

Organize routes by domain concept, not by HTTP method:

// routes/index.js — barrel file that mounts all routers
const { Router } = require("express");
const router = Router();

router.use("/auth", require("./auth"));
router.use("/users", require("./users"));
router.use("/posts", require("./posts"));
router.use("/comments", require("./comments"));
router.use("/media", require("./media"));

module.exports = router;

// app.js
app.use("/api/v1", require("./routes"));

The version prefix (/api/v1) is set once. Changing it or adding v2 is one line.

Middleware scoped to router

Middleware added to a router only applies to that router’s routes:

// routes/admin.js
const router = Router();
const { requireAdmin } = require("../middleware/auth");

// Apply to all routes in this router
router.use(requireAdmin);

router.get("/users", adminController.listAllUsers);
router.delete("/users/:id", adminController.deleteUser);

module.exports = router;

requireAdmin only runs for /admin/* routes. You do not have to add it to every route individually.

Param middleware

For routes with a common :id pattern, router.param runs once per unique parameter value per request:

// routes/users.js
router.param("userId", async (req, res, next, id) => {
  try {
    req.targetUser = await userService.getById(id);
    if (!req.targetUser) return res.status(404).json({ error: "User not found" });
    next();
  } catch (err) {
    next(err);
  }
});

// The user is already loaded when these run
router.get("/:userId", (req, res) => res.json(req.targetUser));
router.put("/:userId", userController.update);
router.delete("/:userId", userController.delete);

router.param eliminates the repeated “fetch the user, check if it exists” code in every handler.

Route file structure

src/
  routes/
    index.js        # mounts all routers
    auth.js
    users.js
    posts.js
    comments.js
  controllers/
    user.js
    post.js
  services/
    user.js
    post.js
  middleware/
    auth.js
    validate.js
    error.js
  app.js

This structure is navigable because the location of any code is predictable. Authentication logic is in middleware/auth.js. User business logic is in services/user.js. User HTTP handlers are in controllers/user.js. User routes are in routes/users.js.

The pattern that causes spaghetti is putting too much in one place. The pattern that scales is separating concerns early.