Express Router & Modular Routes
As an app grows, piling every route into one server.js becomes unmaintainable. express.Router() lets you group related routes into separate modules — a mini-app you mount under a path prefix. This is how real Express projects stay organized: one router per resource, each in its own file, composed together in the main app.
Creating a router module
routes/users.js
import { Router } from 'express'
const router = Router()
// Paths here are RELATIVE to where the router is mounted:
router.get('/', (req, res) => res.json(getAllUsers()))
router.get('/:id', (req, res) => res.json(getUser(req.params.id)))
router.post('/', (req, res) => res.status(201).json(create(req.body)))
router.delete('/:id', (req, res) => res.sendStatus(204))
export default routerserver.js
import express from 'express'
import usersRouter from './routes/users.js'
import postsRouter from './routes/posts.js'
const app = express()
app.use(express.json())
// Mount each router under a prefix:
app.use('/users', usersRouter) // router '/' → '/users', '/:id' → '/users/:id'
app.use('/posts', postsRouter)
app.listen(3000)Why this matters
Separation — each resource (users, posts, orders) lives in its own file.
Reusability — mount the same router at multiple paths or versions (
/api/v1/...).Scoped middleware — attach auth/validation to a whole router, not globally.
Testability — import and test a router in isolation.
Router-level middleware
Middleware registered on a router applies to all its routes — perfect for protecting or instrumenting an entire resource group:
routes/admin.js
import { Router } from 'express'
const router = Router()
// Runs for EVERY route in this router (mounted under /admin):
router.use((req, res, next) => {
if (!req.user?.isAdmin) return res.status(403).json({ error: 'Forbidden' })
next()
})
router.get('/stats', (req, res) => res.json(getStats()))
router.get('/users', (req, res) => res.json(getAllUsers()))
export default routerNested routers
Routers can mount other routers, building a tree that mirrors your URL hierarchy. To access parent params (like :userId) in a child router, pass { mergeParams: true }:
// routes/comments.js — nested under a post
import { Router } from 'express'
const router = Router({ mergeParams: true }) // ← inherit parent's params
router.get('/', (req, res) => {
// req.params.postId comes from the PARENT mount point:
res.json(getComments(req.params.postId))
})
export default router
// routes/posts.js
import comments from './comments.js'
postsRouter.use('/:postId/comments', comments) // → /posts/:postId/commentsAPI versioning with routers
import v1Users from './routes/v1/users.js'
import v2Users from './routes/v2/users.js'
app.use('/api/v1/users', v1Users)
app.use('/api/v2/users', v2Users) // new behavior, old clients unaffectedA clean structure
src/ ├── server.js # create app, mount routers, listen ├── routes/ │ ├── index.js # optional: combine + export all routers │ ├── users.js │ ├── posts.js │ └── admin.js ├── middleware/ │ └── auth.js └── controllers/ # handler logic, imported by routers