What is Express router and how to use it for modular routing?
Express Router is a standalone routing instance created with express.Router() that lets you define route handlers in separate files and mount them to your main app at a specific path prefix.
Theory
TL;DR
- Router is a mini app with its own middleware stack, but no
.listen(). Mount it, never run it standalone. - Routes inside a router are relative to the mount point:
router.get('/profile')mounted at/api/usersbecomes/api/users/profile. - Fewer than 10 routes total? Define them directly on
app. Split across files or teams? Use Router. app.use('/users', router)is the one line that connects the two.
Quick Example
// routes/users.js
const express = require('express');
const router = express.Router();
router.get('/', (req, res) => res.json({ users: [] })); // GET /api/users
router.get('/:id', (req, res) => res.json({ id: req.params.id })); // GET /api/users/123
module.exports = router;
// app.js
const userRouter = require('./routes/users');
app.use('/api/users', userRouter); // all routes above become relative to /api/usersrouter.get('/') does not mean the server root. It means "whatever path I get mounted at."
Why Paths Are Relative
Without Router, every route lives in app.js with absolute paths. One resource is 5 routes. Ten resources is 50 routes in one file. That file hits 1000 lines fast.
Router creates a self-contained collection. When you call app.use('/api/v1/users', userRouter), Express prepends /api/v1/users to every path in that router during request matching. You write router.get('/profile') once. It resolves to /api/v1/users/profile automatically. Change the mount point and all routes inside move with it.
When to Use
- Fewer than 10 routes total - define them directly on
app. No extra files needed. - 10+ routes for one resource - create
routes/users.js, mount at/users. - Team splits by domain - one router per area (
auth,products,admin), each owned by a different person. - Versioned API -
v1Routerandv2Router, mounted at/api/v1and/api/v2. No rewrite when bumping versions. - Middleware per feature - put
requireAuthinside the router so it only guards that resource.
Router-Level Middleware
Middleware attached with router.use() applies only to routes in that router. This is different from app.use(), which hits every incoming request.
// routes/admin.js
const router = express.Router();
const { requireAdmin } = require('../middleware/auth');
router.use(requireAdmin); // guards everything below this line
router.get('/users', getAllUsers);
router.delete('/users/:id', deleteUser);
module.exports = router;One rule about order: global middleware like express.json() belongs on app, not inside each router. If you put body parsing only inside userRouter, requests to productRouter won't have req.body. I've seen this break APIs in production.
How Express Handles the Mount
When you call express.Router(), Express creates a new Router instance with its own internal stack array. Each route you define gets added as a Layer object in that stack, storing the HTTP method, path pattern, and handler.
At request time, Express walks the main app's stack first. When it hits app.use('/api/users', userRouter), it strips the prefix from the URL and passes what remains to the router's own stack matcher. If a route matches, the handler runs. If nothing matches, control returns to the main app.
No magic. Just nested stacks.
Common Mistakes
Absolute paths inside a router
// WRONG: mounted at /users, this becomes /users/users
router.get('/users', (req, res) => res.json({ users: [] }));
// RIGHT
router.get('/', (req, res) => res.json({ users: [] }));Paths in a router are always relative to the mount point. This is the most common mistake in Express code reviews at the junior level.
Forgetting module.exports
// users.js defines everything but forgets this line
module.exports = router;
// app.js
app.use('/users', require('./users')); // TypeError: router is not a functionThe error looks confusing but the fix is always the same: add the export.
Global middleware placed after the mount
// WRONG
app.use('/users', userRouter);
app.use(express.json()); // runs after mount, too late
// RIGHT
app.use(express.json()); // always before routes
app.use('/users', userRouter);Missing { mergeParams: true } in nested routers
If you nest a router under a parameterized path and need the parent param, the child router won't see it by default.
// without mergeParams, req.params.userId is undefined here
const orderRouter = express.Router({ mergeParams: true });
orderRouter.get('/', (req, res) => {
res.json({ userId: req.params.userId, orders: [] }); // works only with mergeParams
});
router.use('/:userId/orders', orderRouter);This trips up a lot of mid-level developers. If req.params looks empty in a child router, check this option first.
Real-World Usage
- MERN stack projects (like fullstackopen.com) -
routes/api/users.jsmounted at/apifor frontend fetch calls. - NestJS - wraps Express Router internally: each
@Controller('users')compiles down to a Router instance. - FeathersJS - every service mounts as a Router at the resource path.
- Express boilerplates (like hagopj13/node-express-boilerplate on GitHub) - one router per domain, mounted in
app.js.
Follow-up Questions
Q: What is the difference between app and router objects?
A: Both have .use(), .get(), .post() and the rest. The key difference: router has no .listen(). You cannot start a server from a router. It only works when mounted into an app.
Q: How does router.use() scope differ from app.use()?
A: router.use(fn) applies only to routes in that specific router. app.use(fn) runs for every incoming request before any router gets it.
Q: What is router.param() and when would you use it?
A: router.param('id', fn) runs a callback whenever :id appears in a matched route, before the route handler executes. Useful for loading a user from the database once and attaching it to req.user, instead of repeating that logic in every handler.
Q: Can a router have its own error handler?
A: Yes. Add a four-argument middleware at the end: router.use((err, req, res, next) => { ... }). It catches errors thrown inside that router. If it calls next(err), the error bubbles up to the app-level handler.
Q: How do you version an API with routers?
A: Create v1Router and v2Router, mount them at /api/v1 and /api/v2. Old clients keep hitting v1, new clients use v2. No changes to existing handlers.
Examples
Basic: One Resource in Its Own File
// routes/products.js
const express = require('express');
const router = express.Router();
router.get('/', (req, res) => res.json({ products: ['laptop', 'phone'] })); // GET /products
router.post('/', (req, res) => res.status(201).json({ id: 1 })); // POST /products
router.get('/:id', (req, res) => res.json({ id: req.params.id })); // GET /products/42
module.exports = router;
// app.js
const express = require('express');
const app = express();
app.use(express.json());
app.use('/products', require('./routes/products'));
app.listen(3000);Three routes, one file. app.js stays clean regardless of how many handlers you add inside products.js.
Intermediate: Protected User API with Middleware
This is closer to what you'd see in a real MERN project.
// middleware/auth.js
const requireAuth = (req, res, next) => {
const token = req.headers.authorization;
if (!token) return res.status(401).json({ error: 'Unauthorized' });
// JWT verification would go here
next();
};
module.exports = { requireAuth };
// routes/users.js
const express = require('express');
const { requireAuth } = require('../middleware/auth');
const router = express.Router();
router.use(requireAuth); // all routes below require auth
router.get('/', async (req, res) => {
res.json({ users: [{ id: 1, name: 'Alice' }] }); // GET /api/users (protected)
});
router.post('/', async (req, res) => {
const { name } = req.body;
res.status(201).json({ id: 2, name }); // POST /api/users (protected)
});
module.exports = router;
// app.js
app.use(express.json());
app.use('/api/users', require('./routes/users'));The auth check runs once for the whole router. No need to repeat it in every handler.
Advanced: Nested Routers with mergeParams
Nested resources like /users/123/orders need a specific setup to work.
// routes/orders.js
const express = require('express');
const orderRouter = express.Router({ mergeParams: true }); // inherit :userId from parent
orderRouter.get('/', (req, res) => {
// req.params.userId is available because of mergeParams
res.json({ userId: req.params.userId, orders: [] });
});
module.exports = orderRouter;
// routes/users.js
const express = require('express');
const router = express.Router();
const orderRouter = require('./orders');
router.param('userId', (req, res, next, id) => {
req.user = { id, name: 'Alice' }; // pre-load user before any handler runs
next();
});
router.use('/:userId/orders', orderRouter); // GET /users/123/orders
module.exports = router;Without { mergeParams: true }, req.params.userId is undefined in orderRouter. This is a recurring bug in multi-level REST APIs.
Short Answer
Interview readyA concise answer to help you respond confidently on this topic during an interview.