Skip to main content

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/users becomes /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

js
// 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/users

router.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 - v1Router and v2Router, mounted at /api/v1 and /api/v2. No rewrite when bumping versions.
  • Middleware per feature - put requireAuth inside 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.

js
// 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

js
// 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

js
// users.js defines everything but forgets this line module.exports = router; // app.js app.use('/users', require('./users')); // TypeError: router is not a function

The error looks confusing but the fix is always the same: add the export.

Global middleware placed after the mount

js
// 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.

js
// 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.js mounted at /api for 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

js
// 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.

js
// 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.

js
// 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 ready
Premium

A concise answer to help you respond confidently on this topic during an interview.

Finished reading?