Putting every route in one index.js works until it doesn’t — by route 50, the file is 800 lines and impossible to navigate. express.Router() is how we split routes into modules.
In simple language — a Router is a mini Express app. It has the same .get/.post/.use methods, but instead of starting a server it gets “mounted” onto the main app at some path prefix.
The basic pattern
// routes/users.js
const express = require('express');
const router = express.Router();
router.get('/', (req, res) => {
res.json([{ id: 1, name: 'Manish' }]);
});
router.get('/:id', (req, res) => {
res.json({ id: req.params.id });
});
router.post('/', (req, res) => {
res.status(201).json({ created: true });
});
module.exports = router;
// app.js
const express = require('express');
const usersRouter = require('./routes/users');
const app = express();
app.use(express.json());
app.use('/api/users', usersRouter);
app.listen(3000);
Now GET /api/users/42 lands in the router’s /:id handler. The mount path (/api/users) gets stripped before the router sees the URL.
app ├── /api │ ├── /users ─→ usersRouter │ │ ├── GET / (list) │ │ ├── GET /:id (get one) │ │ └── POST / (create) │ ├── /posts ─→ postsRouter │ │ ├── GET / │ │ └── POST / │ └── /auth ─→ authRouter │ ├── POST /login │ └── POST /logout └── GET /healthz
Why this matters
- Separation of concerns — one file per resource (users, posts, auth)
- Per-router middleware — apply auth only to certain routes
- Reusability — mount the same router under multiple paths
- Testability — import the router directly and test in isolation
Per-router middleware
router.use() works just like app.use() but only affects that router:
// routes/admin.js
const router = express.Router();
const requireAdmin = (req, res, next) => {
if (req.user?.role !== 'admin') return res.sendStatus(403);
next();
};
router.use(requireAdmin); // every route below requires admin
router.get('/stats', (req, res) => res.json({ users: 1000 }));
router.delete('/users/:id', (req, res) => res.sendStatus(204));
module.exports = router;
Mount it at /admin and now every /admin/* request runs requireAdmin first. Clean.
mergeParams — passing params down
A subtle one. If we have nested resources like /users/:userId/posts, the posts router doesn’t see :userId by default. We need mergeParams: true:
// routes/users.js
const router = express.Router();
const postsRouter = require('./posts');
router.use('/:userId/posts', postsRouter);
// routes/posts.js
const router = express.Router({ mergeParams: true });
router.get('/', (req, res) => {
res.json({ userId: req.params.userId }); // works only with mergeParams
});
module.exports = router;
Without mergeParams, req.params.userId is undefined inside the posts router. Easy to get bitten by this.
A realistic layout
src/
├── app.js
├── routes/
│ ├── index.js # mounts all sub-routers
│ ├── users.js
│ ├── posts.js
│ └── auth.js
├── middleware/
│ ├── auth.js
│ └── errorHandler.js
└── controllers/ # actual business logic
├── users.js
└── posts.js
// routes/index.js
const express = require('express');
const router = express.Router();
router.use('/users', require('./users'));
router.use('/posts', require('./posts'));
router.use('/auth', require('./auth'));
module.exports = router;
// app.js
app.use('/api', require('./routes'));
Now app.js has one route line. Adding a new resource means creating one file and adding one router.use.
Routes vs controllers
A common pattern — the router defines URLs and delegates to a controller function:
// routes/users.js
const router = express.Router();
const ctrl = require('../controllers/users');
router.get('/', ctrl.list);
router.get('/:id', ctrl.get);
router.post('/', ctrl.create);
module.exports = router;
This keeps the router file readable as a “URL map” and pushes logic into testable controller functions. Worth doing once the codebase grows past a few files.