NodeJSExpress Router & Modular Routes

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

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 router

server.js

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)
Router paths are relative to the mount point
Inside `users.js`, `router.get('/')` and `router.get('/:id')` don't mention `/users` — that prefix comes from `app.use('/users', usersRouter)`. So the same router could be mounted at `/v1/users` without changing its code. This decoupling is the whole point: routers describe routes relative to *wherever* they're mounted.
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

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 router
Mount auth once on the router, not on every route
Putting `router.use(requireAdmin)` at the top guards every route below it in one line — far cleaner (and less error-prone) than repeating the middleware on each `router.get`. Combined with `app.use('/admin', adminRouter)`, the entire `/admin/*` surface is protected. Order still matters: the middleware must precede the routes.
Nested 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 }:

JS
// 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/comments
Child routers need `mergeParams: true` to see parent params
By default, a router only sees parameters from *its own* path patterns — so a comments router mounted under `/:postId/comments` would find `req.params.postId` **undefined**. Create it with `Router({ mergeParams: true })` to inherit params from the parent's mount path. Forgetting this is the classic "why is my parent param missing?" nested-router bug.
API versioning with routers

JS
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 unaffected
Routers make versioning a one-line mount
Because a router is just mounted at a prefix, supporting multiple API versions is as simple as mounting different router modules under `/api/v1` and `/api/v2`. Old clients keep hitting v1 while you evolve v2 — no risky in-place changes to a shared handler.
A clean structure

Text
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
Routers route; controllers do the work
A common refinement: keep routers thin (they only declare paths and wire middleware) and move the actual handler logic into **controller** functions that routers import. This separates "what URL" from "what it does," making both easier to test and reuse. The router file becomes a readable table of contents for the resource.
Next
Serve CSS, images, and client-side JS the Express way: [Serving Static Files](/nodejs/serving-static-express).