Learn how to split a growing Express 5 app into small route files with express.Router, mount them on a path prefix, and share middleware and URL parameters between them.

Bishal Biswas
Self Learner
What you will learn
- Why one big
app.jsfile becomes a problem as your app grows - What
express.Routeris and how it works like a “mini app” - How to mount a router on a path prefix such as
/users - How to add middleware that only runs for one group of routes
- How to read parent URL parameters with
mergeParams
The problem: one file that does everything
So far, every route lived in a single app.js. That is perfect for learning. But a real API might have routes for users, products, orders, payments and admin tools. Put them all in one file and it quickly grows to hundreds of lines.
A huge file is hard to read, hard to test, and painful when two people edit it at the same time. You also end up repeating the same prefix over and over:
// app.js (getting messy)
app.get('/users', listUsers);
app.post('/users', createUser);
app.get('/users/:id', getUser);
app.patch('/users/:id', updateUser);
app.delete('/users/:id', deleteUser);
app.get('/products', listProducts);
app.post('/products', createProduct);
// ...and 40 more lines like this
Think of it like a big office building. The reception desk (your app) does not handle every task itself. It sends you to the right department: “Users? Third floor. Products? Fifth floor.” Each department runs its own work. In Express, each department is a router.
What is express.Router?
express.Router() creates a router: a small, self-contained group of routes and middleware. It has the same methods you already know from app: get, post, put, patch, delete, use and route.
The difference is that a router does not start a server. It cannot listen(). It only becomes active when you plug it into your app. That step is called mounting.
Here is the simplest possible router:
// routes/hello.js
import { Router } from 'express';
const router = Router();
router.get('/', (req, res) => {
res.send('Hello from the router');
});
export default router;
Note:
import { Router } from 'express'andexpress.Router()do the same thing. Use whichever reads better to you. This lesson uses the named import.
And here is how the app mounts it:
// app.js
import express from 'express';
import helloRouter from './routes/hello.js';
const app = express();
app.use('/hello', helloRouter); // mount the router at /hello
app.listen(3000, (error) => {
if (error) throw error;
console.log('Server running on http://localhost:3000');
});
Try it:
curl http://localhost:3000/hello
Hello from the router
Common mistake: In ES modules you must write the file extension in local imports:
'./routes/hello.js', not'./routes/hello'. Without.js, Node throws anERR_MODULE_NOT_FOUNDerror.
How mounting works
When you write app.use('/hello', helloRouter), Express does two things for every request whose path starts with /hello:
- It strips the
/helloprefix from the path. - It hands the request to the router, which matches the rest of the path.
So inside the router, paths are relative to the mount point:
Request: GET /users/42
app.use('/users', usersRouter)
|
| prefix "/users" removed
v
usersRouter sees: GET /42 --> matches router.get('/:id', ...)
This is the big win. The router does not know or care where it is mounted. You could mount the same router at /users today and /api/v2/users tomorrow without changing a single line inside it.
Three useful URL properties
Because the prefix is stripped, Express gives you three properties to see the full picture:
| Property | Value for GET /users/42?sort=name |
Meaning |
|---|---|---|
req.originalUrl |
/users/42?sort=name |
The full URL the client asked for |
req.baseUrl |
/users |
The path where the router is mounted |
req.path |
/42 |
The path relative to the router |
req.originalUrl is handy for logging, because it always shows what the client really requested.
A realistic example: a users router
Let’s build a proper users module. We will keep the data in a simple array so you can focus on the routing. Later lessons connect a real database.
// routes/users.js
import { Router } from 'express';
const router = Router();
const users = [
{ id: 1, name: 'Asha' },
{ id: 2, name: 'Ravi' },
];
// GET /users
router.get('/', (req, res) => {
res.json(users);
});
// POST /users
router.post('/', (req, res) => {
const name = req.body?.name;
if (!name) {
return res.status(400).json({ error: 'name is required' });
}
const user = { id: users.length + 1, name };
users.push(user);
res.status(201).json(user);
});
// GET /users/:id
router.get('/:id', (req, res) => {
const user = users.find((u) => u.id === Number(req.params.id));
if (!user) {
return res.status(404).json({ error: 'User not found' });
}
res.json(user);
});
export default router;
Tip:
req.body?.nameuses optional chaining. In Express 5,req.bodyisundefinedwhen no body parser ran, so the?.stops your code from crashing if someone sends a request without JSON.
Now a second module for products:
// routes/products.js
import { Router } from 'express';
const router = Router();
router.get('/', (req, res) => {
res.json([{ id: 1, title: 'Keyboard' }]);
});
export default router;
And the app becomes short and easy to scan:
// app.js
import express from 'express';
import usersRouter from './routes/users.js';
import productsRouter from './routes/products.js';
const app = express();
app.use(express.json()); // parse JSON bodies for every router
app.use('/users', usersRouter);
app.use('/products', productsRouter);
app.listen(3000, (error) => {
if (error) throw error;
console.log('Server running on http://localhost:3000');
});
Test it:
curl http://localhost:3000/users/2
curl -X POST http://localhost:3000/users -H "Content-Type: application/json" -d '{"name":"Mei"}'
{"id":2,"name":"Ravi"}
{"id":3,"name":"Mei"}
A folder layout that scales
A common, beginner-friendly structure looks like this:
my-api/
package.json
app.js
routes/
index.js <- combines all routers
users.js
products.js
Some teams add one more layer: an index.js that gathers every router, so app.js only mounts one thing.
// routes/index.js
import { Router } from 'express';
import usersRouter from './users.js';
import productsRouter from './products.js';
const router = Router();
router.use('/users', usersRouter);
router.use('/products', productsRouter);
export default router;
// app.js (only the mounting line changes)
import apiRouter from './routes/index.js';
app.use('/api', apiRouter); // now: /api/users, /api/products
Routers can be nested like this as deep as you like. Each level strips its own prefix.
router.route(): one path, many methods
Often several methods share the same path, like GET, PATCH and DELETE on /:id. Instead of repeating the path, use router.route():
// routes/users.js (an alternative style)
router
.route('/:id')
.get((req, res) => {
res.json({ action: 'read', id: req.params.id });
})
.patch((req, res) => {
res.json({ action: 'update', id: req.params.id });
})
.delete((req, res) => {
res.status(204).end();
});
This reads like a small table of “what can I do with this URL”. It also avoids typos, because you type the path once.
Middleware that only runs for one router
Remember that middleware runs in order for every request it matches. When you call router.use(), the middleware runs only for requests that reach that router. This is called router-level middleware.
A classic use is protecting an admin area:
// routes/admin.js
import { Router } from 'express';
const router = Router();
// runs for every request that reaches this router
router.use((req, res, next) => {
if (req.get('X-Admin-Key') !== process.env.ADMIN_KEY) {
return res.status(403).json({ error: 'Admins only' });
}
next();
});
router.get('/stats', (req, res) => {
res.json({ users: 2, products: 1 });
});
export default router;
// app.js
import adminRouter from './routes/admin.js';
app.use('/admin', adminRouter);
Requests to /users never touch the admin check. Requests to /admin/stats always do. You can also attach middleware at mount time, which is useful for logging or rate limiting a whole section:
// app.js: logRequests runs only for /admin routes
app.use('/admin', logRequests, adminRouter);
Note: The header check above is just a teaching example. Real logins and permissions are covered in JWT Authentication and Role-Based Authorization.
Reading parent parameters with mergeParams
Sometimes the mount path itself contains a parameter. Think of “posts that belong to a user”: /users/:userId/posts.
// app.js
app.use('/users/:userId/posts', postsRouter);
By default, a router cannot see parameters from its parent’s path. Inside postsRouter, req.params.userId would be undefined. To fix that, create the router with the mergeParams option:
// routes/posts.js
import { Router } from 'express';
const router = Router({ mergeParams: true }); // see the parent's :userId
router.get('/', (req, res) => {
res.json({ message: `Posts for user ${req.params.userId}` });
});
router.get('/:postId', (req, res) => {
const { userId, postId } = req.params;
res.json({ message: `Post ${postId} by user ${userId}` });
});
export default router;
curl http://localhost:3000/users/7/posts/3
{"message":"Post 3 by user 7"}
router.param(): run code for a parameter
If many routes use :id and all of them first need to look up the item, you can register a param callback. It runs once, before any route that contains that parameter.
// routes/users.js
router.param('id', (req, res, next, id) => {
const user = users.find((u) => u.id === Number(id));
if (!user) {
return res.status(404).json({ error: 'User not found' });
}
req.user = user; // share it with the route handlers
next();
});
router.get('/:id', (req, res) => {
res.json(req.user); // already loaded
});
This keeps the “find or 404” logic in one place. Param callbacks belong to the router they are defined on, so they do not leak into other routers.
Common mistakes
Common mistake: Repeating the prefix inside the router. If you mount at
/usersand then writerouter.get('/users/:id', ...), the real URL becomes/users/users/:id. Inside a router, write paths relative to the mount point.
Common mistake: Forgetting
export default router(or importing with the wrong name). Ifapp.use()receivesundefinedinstead of a router, Express throws aTypeErrorat startup complaining that it expected a function.
Common mistake: Mounting the body parser after the routers.
app.use(express.json())must come beforeapp.use('/users', usersRouter), orreq.bodywill beundefinedinside the router.
Key takeaways
express.Router()creates a mini app with its own routes and middleware. It has nolisten().- Mounting with
app.use('/prefix', router)strips the prefix, so router paths stay relative and reusable. req.originalUrl,req.baseUrlandreq.pathshow the full URL, the mount point and the relative path.router.use()adds middleware that only runs for that router’s requests.- Use
mergeParams: truewhen a router needs parameters from its mount path, androuter.param()to share parameter lookups.
Practice
- Move every route from one of your earlier practice apps into a
routes/folder with at least two router files. - Create a
routes/index.jsthat combines your routers and mount it at/api. Check that/api/usersworks and/usersnow returns 404. - Add a router-level middleware to your products router that logs
req.baseUrlandreq.pathfor each request. - Build a
/users/:userId/ordersrouter withmergeParams: truethat returns theuserIdin its response.
What’s next
Routers group handlers together, but what happens inside a single route when it has several functions in a row? Continue with Route Handlers, next() and Chaining.
Was this explanation helpful?
Your feedback helps us improve the content for everyone.
Thank you for your feedback!
We appreciate you helping us improve.
What could we improve? (Optional)
Read aloud
Ready