Skip to main content

๐Ÿ—‚๏ธ Router Object and Modularization

A single app.js with every route in it works โ€” until it hits a thousand lines and three teammates keep colliding in it. The Express Router is a mini-application you can define separately and mount anywhere, turning one sprawling file into a clean tree of focused modules. This lesson shows how to split by resource, scope middleware, nest routers, version an API, and test each router in isolation.

๐ŸŽฏ Learning Objectives

By the end of this lesson, you will be able to:

  • Create an express.Router() instance and mount it at a path prefix
  • Organize routes into resource-based modules combined through an index.js
  • Apply router-level and path-scoped middleware
  • Build nested routers and access parent params with mergeParams
  • Implement API versioning and a router factory to remove repetition
  • Test a router in isolation with Supertest

Estimated Time: 35โ€“45 minutes  โ€ข  Difficulty: Intermediate

Hands-on: Refactor a monolithic app into routes/ modules with a nested posts router.

In This Lesson

Why Modularize?

As an app grows, one route file becomes a bottleneck: hard to navigate, prone to merge conflicts, and full of repeated path prefixes. Modularization splits it into logical units that map to how you actually think about the domain.

๐Ÿ’ก Analogy: A large Express app is a growing city. Without planning it's a chaotic maze. Modularization is zoning โ€” distinct neighborhoods (routers), each with its own internal organization, connected by clear main roads (mount paths). Newcomers find their way and maintenance stays sane.

โš ๏ธ Signs you need to modularize

  • A route file crossing several hundred lines.
  • The same prefix (/api/users/...) repeated dozens of times.
  • Frequent merge conflicts on one file.
  • Duplicated middleware across similar routes.
graph TD A[Monolithic app.js] --> B[Hard to navigate] A --> C[Merge conflicts] A --> D[Repeated prefixes] E[Modular routers] --> F[Clear organization] E --> G[Parallel teamwork] E --> H[Reusable middleware]

The Express Router

An express.Router() is a lightweight, mountable mini-app. It supports the same .get(), .post(), .use(), .route(), and .param() methods as the app object, but you define it in its own file and plug it in later.

// routes/pages.routes.js
const express = require('express');
const router = express.Router();

router.get('/', (req, res) => res.send('Home page'));
router.get('/about', (req, res) => res.send('About page'));

module.exports = router;
// app.js
const express = require('express');
const app = express();
const pages = require('./routes/pages.routes');

app.use('/', pages);            // mount the router
app.listen(3000, () => console.log('http://localhost:3000'));

The pattern is always the same four steps: create a router, define routes on it, export it, mount it.

๐Ÿ“– Key Terms

Router: an isolated, mountable collection of routes and middleware.

Mounting: attaching a router to the app (or another router) at a base path with app.use(prefix, router).

Mount path / prefix: the base URL prepended to every route inside the mounted router.

๐ŸŒ Real-world: Platforms with thousands of endpoints (profiles, messaging, jobs, payments) give each domain its own router module. Finding "where does POST /messages live?" becomes obvious instead of a scroll through one giant file.

Mounting & Path Prefixes

When you mount a router at a prefix, that prefix is prepended to every route defined inside it. A router that defines / and /:id, mounted at /api/users, produces /api/users and /api/users/:id.

// routes/user.routes.js
const router = require('express').Router();
router.get('/',    (req, res) => res.json({ message: 'All users' }));
router.get('/:id', (req, res) => res.json({ id: req.params.id }));
module.exports = router;

// app.js โ€” mount several routers side by side
app.use('/api/users',    require('./routes/user.routes'));
app.use('/api/products', require('./routes/product.routes'));
app.use('/auth',         require('./routes/auth.routes'));
Routers mounted at path prefixes One Express application mounts three routers โ€” users, products, and auth โ€” each at its own base path, and each router defines its own internal routes. Express Application (app.js) userRouter GET / โ†’ all users GET /:id โ†’ one user POST / โ†’ create productRouter GET / โ†’ all products GET /:id โ†’ one product POST / โ†’ create authRouter POST /login POST /register POST /logout /api/users /api/products /auth
Figure 1 โ€” Each router owns its internal routes; the mount path decides the full public URL. Change a prefix in one line and every route inside moves with it.

๐Ÿ’ก Analogy: Mounting is assigning departments to floors. HR (a router) organizes its own offices, but the floor number (mount path) becomes part of every office's full address. Move HR to a new floor and all its addresses update at once.

Router-Level Middleware

A router can carry its own middleware with router.use(). It runs only for routes defined on that router and only for routes declared after it โ€” a clean way to scope logging, auth, or rate limiting to one section of the app.

// routes/protected.routes.js
const router = require('express').Router();

// Applies to every route below, on this router only
router.use((req, res, next) => {
  console.log(`${req.method} ${req.originalUrl}`);
  next();
});

router.use((req, res, next) => {
  if (req.headers['x-api-key'] !== process.env.API_KEY) {
    return res.status(401).json({ error: 'API key required' });
  }
  next();
});

router.get('/',  (req, res) => res.json({ message: 'Protected data' }));
router.post('/', (req, res) => res.json({ message: 'Created' }));

module.exports = router;

Path-scoped middleware and mount-time middleware

You can restrict middleware to a sub-path inside a router, and you can inject middleware at the moment you mount a router:

const adminRouter = require('express').Router();

adminRouter.use('/settings', (req, res, next) => {
  if (!req.user?.permissions.includes('manage_settings')) {
    return res.status(403).json({ error: 'Settings permission required' });
  }
  next();
});

adminRouter.get('/dashboard', (req, res) => res.json({ ok: true }));
adminRouter.get('/settings',  (req, res) => res.json({ settings: {} }));

// authenticate runs before the whole admin router is entered
app.use('/admin', authenticate, adminRouter);

๐ŸŒ Real-world: Admin dashboards use router-level middleware so store owners, staff, and developers all reach the dashboard but only see the sections their permissions allow โ€” enforced once per router, not repeated on every route.

Organizing by Resource

The most common structure gives each resource its own router file, wired together by a single routes/index.js that the app mounts under one prefix.

project/
โ”œโ”€โ”€ app.js
โ”œโ”€โ”€ routes/
โ”‚   โ”œโ”€โ”€ index.js          # combines all resource routers
โ”‚   โ”œโ”€โ”€ user.routes.js
โ”‚   โ”œโ”€โ”€ product.routes.js
โ”‚   โ””โ”€โ”€ auth.routes.js
โ”œโ”€โ”€ controllers/          # handlers (next lesson)
โ”œโ”€โ”€ models/
โ””โ”€โ”€ middleware/
// routes/user.routes.js
const router = require('express').Router();
const userController = require('../controllers/user.controller');
const { authenticate, authorize } = require('../middleware/auth');

router.get('/',    userController.getAllUsers);
router.get('/:id', userController.getUserById);
router.post('/',   authenticate, userController.createUser);
router.put('/:id', authenticate, authorize('admin'), userController.updateUser);
router.delete('/:id', authenticate, authorize('admin'), userController.deleteUser);

module.exports = router;
// routes/index.js โ€” one place to see the whole surface
const router = require('express').Router();
router.use('/users',    require('./user.routes'));
router.use('/products', require('./product.routes'));
router.use('/auth',     require('./auth.routes'));
module.exports = router;

// app.js
app.use('/api', require('./routes'));   // โ†’ /api/users, /api/products, ...

๐Ÿ’ก Analogy: Resource-based organization is a well-run library. Each section (Fiction, Reference) has its own shelving and rules; the front desk directory (index.js) just points you to the right section, which then manages itself.

Nested Routers & mergeParams

Resources often nest: a user has posts, a post has comments. You express this by mounting one router inside another. The catch: by default a child router cannot see the parent's URL params. Turn on mergeParams: true to fix that.

// routes/post.routes.js  โ†’  mounted at /users/:userId/posts
const router = require('express').Router({ mergeParams: true }); // key!

router.get('/', (req, res) => {
  // req.params.userId is visible thanks to mergeParams
  res.json({ message: `All posts for user ${req.params.userId}` });
});

router.get('/:postId', (req, res) => {
  const { userId, postId } = req.params;
  res.json({ userId, postId });
});

module.exports = router;
// routes/user.routes.js
const router = require('express').Router();
const postRouter = require('./post.routes');

router.get('/:userId', (req, res) => res.json({ id: req.params.userId }));
router.use('/:userId/posts', postRouter);   // nest it

module.exports = router;

โš ๏ธ Forget mergeParams and req.params.userId is undefined

This is the number-one nested-router bug. A child router only inherits parent params when created with express.Router({ mergeParams: true }).

Nesting composes to any depth โ€” comments under posts under users:

graph TD A[userRouter ยท /users] --> B[postRouter ยท /:userId/posts] B --> C[commentRouter ยท /:postId/comments] A --> A1[GET /users/:userId] B --> B1[GET /users/:userId/posts] C --> C1[GET /users/:userId/posts/:postId/comments]

API Versioning & Factories

Routers make versioning natural: keep v1 and v2 as separate modules and mount them under different prefixes so existing clients keep working while new ones adopt the new shape.

// routes/index.js
const router = require('express').Router();
router.use('/v1/users', require('./v1/user.routes'));
router.use('/v2/users', require('./v2/user.routes'));
module.exports = router;
// โ†’ /api/v1/users  and  /api/v2/users

Router factories

When many resources share the same CRUD shape, a factory function generates a consistent router so you write the boilerplate once:

// utils/resourceRouter.js
function createResourceRouter(controller, { authenticate } = {}) {
  const router = require('express').Router({ mergeParams: true });

  router.get('/',    controller.getAll);
  router.get('/:id', controller.getById);

  const guard = authenticate ? [authenticate] : [];
  router.post('/',      ...guard, controller.create);
  router.put('/:id',    ...guard, controller.update);
  router.delete('/:id', ...guard, controller.delete);

  return router;
}
module.exports = createResourceRouter;

// usage
const userRouter = createResourceRouter(userController, { authenticate });
app.use('/api/users', userRouter);

๐ŸŒ Real-world: Payment and content platforms maintain several API versions at once via router-based structures, letting integrators upgrade on their own schedule while old versions stay backward-compatible.

Hands-on Exercise

๐Ÿ‹๏ธ Refactor a Monolith into Routers

Objective: Turn a single-file app into resource modules, then add a nested posts router.

Instructions:

  1. Create routes/user.routes.js with GET / and GET /:userId.
  2. Create routes/post.routes.js with mergeParams: true, exposing GET / that reports the parent userId.
  3. Nest the post router in the user router at /:userId/posts.
  4. Create routes/index.js that mounts the user router at /users.
  5. In app.js, mount the combined router at /api and verify /api/users/5/posts reports user 5.
๐Ÿ’ก Hint

The nested router needs express.Router({ mergeParams: true }) or req.params.userId will be undefined. Mount order: post router inside user router, user router inside index, index inside app under /api.

โœ… Solution
// routes/post.routes.js
const router = require('express').Router({ mergeParams: true });
router.get('/', (req, res) =>
  res.json({ message: `Posts for user ${req.params.userId}` }));
module.exports = router;

// routes/user.routes.js
const router = require('express').Router();
const postRouter = require('./post.routes');
router.get('/',        (req, res) => res.json({ users: [1, 2, 3] }));
router.get('/:userId', (req, res) => res.json({ id: req.params.userId }));
router.use('/:userId/posts', postRouter);
module.exports = router;

// routes/index.js
const router = require('express').Router();
router.use('/users', require('./user.routes'));
module.exports = router;

// app.js
const express = require('express');
const app = express();
app.use('/api', require('./routes'));
app.listen(3000, () => console.log('http://localhost:3000'));
// GET /api/users/5/posts โ†’ { "message": "Posts for user 5" }

๐ŸŽฏ Quick Quiz

Question 1: A router defines router.get('/:id', ...) and is mounted with app.use('/api/products', router). What URL reaches that handler?

Question 2: Inside a nested posts router, req.params.userId is undefined. What's the fix?

Question 3: What does router.use() middleware apply to?

Best Practices

โœ… DoโŒ Avoid
One router per resource, combined in a single index.jsScattering mounts across many files
Keep folder structure shallow and names consistent (user.routes.js)Deep nesting that hides where a route lives
Use mergeParams for every nested router that needs parent IDsReaching for globals to pass params down
Scope middleware with router.use() instead of repeating itCopy-pasting the same guard onto every route
Version with separate router modules under a version prefixAdding if (version === 2) branches inside handlers

Summary & Quiz

๐ŸŽ‰ Key Takeaways

  • express.Router() is a mountable mini-app: create โ†’ define โ†’ export โ†’ mount.
  • The mount path is prepended to every route inside the router.
  • router.use() scopes middleware to one router (and only routes declared after it).
  • Organize by resource, wired together in routes/index.js.
  • Nested routers need mergeParams: true to read parent params.
  • Versioning and factories keep a growing API consistent and backward-compatible.

๐Ÿ“š Further Reading

๐Ÿš€ What's Next?

Your routers still hold their logic inline. Next we'll extract that logic into controllers, so routes describe what the API exposes and controllers own how it works.

๐ŸŽ‰ Well organized!

Your app is now a tidy tree of modules. Let's separate routes from their logic.