New to Rust? Grab our free Rust for Beginners eBook Get it free →
Express Router: A complete reference for Node.js developers

When you start building a Node.js API, it’s tempting to pile every route handler onto the top-level app object. That works for three routes. By the time you have thirty, the file is impossible to reason about. The express router is Express’s answer to that problem: a mountable, middleware-capable routing layer that lets you split a large application into focused modules without touching the core server setup.
This guide covers how the express router works, how to structure it for real projects, the edge cases that trip developers up, and patterns like route chaining and controllers that keep large codebases clean.
What is the Express Router?
express.Router() creates a mini application: a self-contained instance of Express’s routing and middleware system. Think of it as a smaller app object you can configure independently, then bolt onto the main app at a URL prefix. The official Express docs describe it as a complete middleware and routing system, which means everything you can do on app like define routes, attach middleware, handle errors, you can do on a router instance.
const express = require('express');
const router = express.Router();
router.get('/', (req, res) => {
res.json({ message: 'router is working' });
});
module.exports = router;
In the main server file you mount it with app.use():
const userRouter = require('./routes/users');
app.use('/users', userRouter);
Every path defined inside userRouter is now prefixed with /users. A handler for '/' inside the router responds to GET /users. A handler for '/:id' responds to GET /users/:id. The prefix is declared once and the router file never has to repeat it.
Express Router vs App Routing
Express gives you two ways to define routes: directly on the app object (app.get, app.post) or on a Router instance. For one or two endpoints the difference is cosmetic. For anything larger it matters.
When all routes live on app, you get one big file where unrelated concerns sit next to each other: user registration next to product listings next to admin utilities. Middleware you want to apply to just one group of routes has to be applied to every route or filtered manually by path.
A router instance fixes both problems. Routes for a feature go in their own file. Middleware applied to that router only runs for that router’s routes. The main app.js becomes a mounting manifest, not a route directory.
// Without routers -- everything in one place
app.get('/users', getAllUsers);
app.post('/users', createUser);
app.get('/products', getAllProducts);
app.post('/orders', createOrder);
// With routers -- clearly separated
app.use('/users', require('./routes/users'));
app.use('/products', require('./routes/products'));
app.use('/orders', require('./routes/orders'));
The second form is also easier to test: you can import just the users router and write tests against it without booting the full server.
Setting up Express Router
Install Express if you haven’t already. If you’re starting from scratch, our Node.js tutorial covers the full project setup including npm init and folder structure.
npm install express
Create a routes folder at the project root. Inside it, create a file per feature: users.js, products.js and so on. Here is a minimal working structure:
project/
├── app.js
└── routes/
├── users.js
└── products.js
Inside routes/users.js:
const express = require('express');
const router = express.Router();
router.get('/', (req, res) => {
res.json({ users: [] });
});
router.post('/', (req, res) => {
res.status(201).json({ message: 'user created' });
});
module.exports = router;
Inside app.js:
const express = require('express');
const app = express();
app.use(express.json());
app.use('/users', require('./routes/users'));
app.listen(3000, () => console.log('Server running on port 3000'));
The server now handles GET /users and POST /users. Adding a new route means editing only routes/users.js.
Route parameters and parameterized paths
Route parameters let you capture values from the URL. You declare them with a colon prefix and read them from req.params:
router.get('/:id', (req, res) => {
const { id } = req.params;
res.json({ userId: id });
});
A request to GET /users/42 makes req.params.id equal to '42'. Parameters are always strings, so parse them before using them as numbers. When you’re ready to send data back, res.json() is the standard choice for API responses, our guide on sending JSON responses with Express covers the method in detail.
You can also define optional parameters by appending a ?:
router.get('/:id?', (req, res) => {
if (req.params.id) {
res.json({ userId: req.params.id });
} else {
res.json({ message: 'no id provided' });
}
});
One consistent mistake is defining a parameterized route before a static one that shares the same HTTP method. Express matches routes in the order they are defined. If /:id comes before /create, then GET /users/create matches the parameterized route and create becomes the ID value.
// Wrong -- /create will be captured as /:id
router.get('/:id', getUserById);
router.get('/create', showCreateForm);
// Correct -- static routes before parameterized ones
router.get('/create', showCreateForm);
router.get('/:id', getUserById);
Router-level middleware
Middleware in Express is any function with the signature (req, res, next). When you attach middleware to a router with router.use(), it only runs for requests handled by that router. This makes it straightforward to add auth checks, logging, or input parsing to a group of routes without affecting others.
const express = require('express');
const router = express.Router();
// Runs for every request to this router
router.use((req, res, next) => {
console.log(`${req.method} ${req.originalUrl}`);
next();
});
router.get('/', (req, res) => {
res.json({ users: [] });
});
module.exports = router;
The next() call passes control to the next handler in the chain. If you forget it, the request hangs and no response reaches the client. Calling next(err) skips the remaining middleware and hands the error to Express’s error-handling layer.
You can also attach middleware to one route rather than the whole router:
function authCheck(req, res, next) {
if (!req.headers.authorization) {
return res.status(401).json({ error: 'Unauthorized' });
}
next();
}
router.get('/admin', authCheck, (req, res) => {
res.json({ secret: 'data' });
});
Here authCheck only runs for GET /admin. Other routes in the same router are unaffected. This pattern is how you build per-route guards without littering every handler with the same boilerplate.
Route chaining with router.route()
When the same path supports multiple HTTP methods, you can chain them on a single router.route() call instead of repeating the path three times:
router.route('/:id')
.get((req, res) => {
res.json({ userId: req.params.id });
})
.put((req, res) => {
res.json({ message: `updated user ${req.params.id}` });
})
.delete((req, res) => {
res.json({ message: `deleted user ${req.params.id}` });
});
This is more than cosmetic. The path is declared once, so a typo on /users/:id doesn’t silently create a second route that never matches. If the path changes later, there’s one place to update it.
router.all() is the wildcard version: it runs for every HTTP method at the given path. It’s useful for applying pre-processing to a resource before method-handlers take over:
router.all('/:id', (req, res, next) => {
// Validate that id is a number before any CRUD handler runs
if (isNaN(req.params.id)) {
return res.status(400).json({ error: 'id must be a number' });
}
next();
});
Separating Route handlers into controllers
A router file can get crowded when each handler contains actual business logic. The standard fix is a controller layer: separate files that export the handler functions, imported into the router.
// controllers/userController.js
exports.getAll = (req, res) => {
res.json({ users: [] });
};
exports.getById = (req, res) => {
res.json({ userId: req.params.id });
};
exports.create = (req, res) => {
res.status(201).json({ message: 'created' });
};
exports.update = (req, res) => {
res.json({ message: `updated ${req.params.id}` });
};
exports.remove = (req, res) => {
res.json({ message: `deleted ${req.params.id}` });
};
// routes/users.js
const express = require('express');
const router = express.Router();
const controller = require('../controllers/userController');
router.get('/', controller.getAll);
router.post('/', controller.create);
router.route('/:id')
.get(controller.getById)
.put(controller.update)
.delete(controller.remove);
module.exports = router;
The router file now reads like a routing table. You can see the whole API surface at once. The controller file contains the logic. Each is independently testable. For async database operations, use async/await in the controller and pair it with Express 5’s built-in promise error propagation, or wrap handlers with a try/catch block that calls next(err) on failure. Our RESTful API with Node.js and Express walkthrough shows this full pattern with MongoDB as the database layer.
Error handling in Express Router
Route-level error handling flows through Express’s middleware chain. Any middleware that takes four arguments(err, req, res, next) is treated as an error handler. You define it after all routes, and it catches anything passed via next(err). For a deeper look at how this fits into a full API project, see our guide on error handling in Express using middleware.
// app.js
const usersRouter = require('./routes/users');
app.use('/users', usersRouter);
// 404 handler -- must come after all routes
app.use((req, res) => {
res.status(404).json({ error: 'Route not found' });
});
// Error handler -- four parameters required
app.use((err, req, res, next) => {
console.error(err.stack);
res.status(err.status || 500).json({ error: err.message });
});
The 404 handler works because Express processes app.use() calls in order. If no earlier route matched, execution falls through to this catch-all. The error handler only fires when next(err) is called somewhere in the chain, so it doesn’t run on ordinary 404s — that’s why you need both.
Inside a router, you can also scope error handling to just that router by adding a four-parameter middleware at the end of the file:
// routes/users.js
router.use((err, req, res, next) => {
res.status(400).json({ error: err.message });
});
This catches errors from within the users router before they reach the global error handler. It’s useful when different parts of your app need different error shapes: an API router might return JSON while a web router renders an HTML error page.
Router options: mergeParams, strict and caseSensitive
express.Router() accepts an optional configuration object:
const router = express.Router({
mergeParams: true,
strict: false,
caseSensitive: false
});
mergeParams is the one you’ll actually need. By default, a child router cannot access route parameters defined on the parent. Say you mount a comments router inside a posts route:
// app.js
app.use('/posts/:postId/comments', commentsRouter);
Without mergeParams: true, req.params.postId is undefined inside commentsRouter. With it, the parent’s params are merged into req.params, making postId available alongside any params defined in the comments router.
// routes/comments.js
const router = express.Router({ mergeParams: true });
router.get('/', (req, res) => {
// req.params.postId is available here
res.json({ postId: req.params.postId, comments: [] });
});
strict controls whether /users/ and /users are treated as the same route. By default they are. Enable it if you want to distinguish them (rare in practice).
caseSensitive makes route matching case-sensitive. /Users and /users would be different routes. Off by default.
Wildcards and catch-all Routes
Wildcards let you match patterns rather than exact paths. The asterisk captures multiple path segments:
// Express 5 wildcard syntax
router.get('/files/*filepath', (req, res) => {
const filePath = req.params.filepath.join('/');
res.json({ path: filePath });
});
The most common use for wildcards is a catch-all 404 at the end of a router or the main app. Because Express matches routes in order, a catch-all has to go last or it blocks all subsequent routes:
// After all other routes
router.get('*', (req, res) => {
res.status(404).json({ error: 'Not found' });
});
Note that * in Express 4 and Express 5 behave slightly differently. In Express 5, wildcards must have a name (*filepath, *splat) and can’t be anonymous. If you’re migrating from Express 4, check this point.
Key takeaways
express.Router()creates a mountable mini-application with its own route and middleware stack- Mount a router with
app.use('/prefix', router)and the prefix prepends every route in that file - Define static routes before parameterized
/:paramroutes because Express matches in declaration order - Use
router.use()for middleware scoped to that router only - Use
router.route('/path').get().post().delete()to chain methods on one path without repeating it - Set
mergeParams: trueto access parent route params from a router mounted on a parameterized path - A four-argument
(err, req, res, next)middleware is an error handler and must be placed after all routes - Move handler logic to controller files and keep router files as routing tables
Frequently Asked Questions (FAQ)
How is express.Router different from app in Express?
The app object is the Express application. express.Router() creates a sub-application. Both support the same routing API, but a router is mountable at a path prefix and can be exported and reused. The app object is the top-level entry point you call listen() on.
Can you put one router inside another?
Yes. A router can mount another router just like the app mounts a router. You use router.use('/sub', subRouter) inside a parent router. This composes path prefixes: if the parent is mounted at /api and the sub-router is mounted at /v1, routes inside the sub-router resolve to /api/v1/....
How do you handle async errors?
In Express 5, route handlers that return a rejected promise automatically call next(err). In Express 4, wrap async handlers manually:
// Express 4 pattern
router.get('/:id', async (req, res, next) => {
try {
const user = await db.findUser(req.params.id);
res.json(user);
} catch (err) {
next(err);
}
});
// Express 5 -- no try/catch needed
router.get('/:id', async (req, res) => {
const user = await db.findUser(req.params.id);
res.json(user);
});
What is req.baseUrl?
When a router is mounted at /users, req.baseUrl inside that router is /users. req.path is the matched sub-path. req.originalUrl is the full URL. This matters when building redirect logic or logging middleware that needs to report the full path.




