Node.js Routing: Express Router Guide
If you've built anything beyond a toy Express application, you've experienced the pain of a bloated `server.js` file with dozens of route definitions. Express Router solves this by letting you create...
Key Insights
- Express Router enables modular route organization by creating mountable route handlers that function as mini-applications, essential for scaling beyond trivial apps
- Router-level middleware provides granular control over request processing, allowing authentication, validation, and logging to be scoped to specific route groups rather than applied globally
- Proper route organization by feature (users, products, auth) with nested routers and consistent patterns prevents the monolithic route files that plague unmaintainable Express applications
Introduction to Express Router
If you’ve built anything beyond a toy Express application, you’ve experienced the pain of a bloated server.js file with dozens of route definitions. Express Router solves this by letting you create modular, mountable route handlers.
The fundamental difference is architectural. Basic app-level routing couples your routes directly to your application instance:
// App-level routing - doesn't scale
const express = require('express');
const app = express();
app.get('/users', (req, res) => { /* ... */ });
app.post('/users', (req, res) => { /* ... */ });
app.get('/products', (req, res) => { /* ... */ });
app.post('/products', (req, res) => { /* ... */ });
// This gets ugly fast
Express Router decouples route definitions into separate modules:
// Modular routing with Router
const express = require('express');
const app = express();
const userRouter = require('./routes/users');
const productRouter = require('./routes/products');
app.use('/users', userRouter);
app.use('/products', productRouter);
Each router acts as a complete middleware and routing system, isolated and testable. This isn’t just cleaner—it’s necessary for maintainability.
Creating Your First Router
Start by creating a dedicated routes directory. Here’s a complete user routes module:
// routes/users.js
const express = require('express');
const router = express.Router();
// GET all users
router.get('/', async (req, res) => {
try {
const users = await getUsersFromDatabase();
res.json(users);
} catch (error) {
res.status(500).json({ error: 'Failed to fetch users' });
}
});
// GET single user
router.get('/:id', async (req, res) => {
try {
const user = await getUserById(req.params.id);
if (!user) {
return res.status(404).json({ error: 'User not found' });
}
res.json(user);
} catch (error) {
res.status(500).json({ error: 'Failed to fetch user' });
}
});
// POST create user
router.post('/', async (req, res) => {
try {
const newUser = await createUser(req.body);
res.status(201).json(newUser);
} catch (error) {
res.status(500).json({ error: 'Failed to create user' });
}
});
// PUT update user
router.put('/:id', async (req, res) => {
try {
const updatedUser = await updateUser(req.params.id, req.body);
res.json(updatedUser);
} catch (error) {
res.status(500).json({ error: 'Failed to update user' });
}
});
// DELETE user
router.delete('/:id', async (req, res) => {
try {
await deleteUser(req.params.id);
res.status(204).send();
} catch (error) {
res.status(500).json({ error: 'Failed to delete user' });
}
});
module.exports = router;
Mount this router in your main application file:
// app.js
const express = require('express');
const userRouter = require('./routes/users');
const app = express();
app.use(express.json());
app.use('/users', userRouter);
app.listen(3000);
Now all routes in userRouter are automatically prefixed with /users. A GET to /users/:id hits the second route handler.
Route Parameters and Query Strings
Route parameters capture dynamic segments of the URL path. Express makes these available via req.params:
// routes/posts.js
const express = require('express');
const router = express.Router();
// Single parameter
router.get('/:postId', (req, res) => {
const { postId } = req.params;
res.json({ message: `Fetching post ${postId}` });
});
// Multiple parameters for nested resources
router.get('/:postId/comments/:commentId', (req, res) => {
const { postId, commentId } = req.params;
res.json({
message: `Fetching comment ${commentId} from post ${postId}`
});
});
// Query strings for filtering and pagination
router.get('/', (req, res) => {
const { page = 1, limit = 10, category } = req.query;
// GET /posts?page=2&limit=20&category=tech
res.json({
page: parseInt(page),
limit: parseInt(limit),
category
});
});
module.exports = router;
Always validate and sanitize parameters. Don’t trust user input:
router.get('/:id', (req, res) => {
const id = parseInt(req.params.id);
if (isNaN(id) || id < 1) {
return res.status(400).json({ error: 'Invalid ID' });
}
// Proceed with valid ID
});
Router-Level Middleware
Router-level middleware applies only to routes within that router. This is powerful for feature-specific concerns like authentication:
// routes/admin.js
const express = require('express');
const router = express.Router();
// Authentication middleware for this router only
const requireAuth = (req, res, next) => {
const token = req.headers.authorization;
if (!token) {
return res.status(401).json({ error: 'No token provided' });
}
try {
req.user = verifyToken(token);
next();
} catch (error) {
res.status(401).json({ error: 'Invalid token' });
}
};
// Apply to all routes in this router
router.use(requireAuth);
// Now all these routes require authentication
router.get('/dashboard', (req, res) => {
res.json({ user: req.user });
});
router.post('/settings', (req, res) => {
// req.user is available here too
});
module.exports = router;
You can also apply middleware to specific routes:
const validateUser = (req, res, next) => {
if (!req.body.email || !req.body.password) {
return res.status(400).json({ error: 'Missing required fields' });
}
next();
};
router.post('/', validateUser, async (req, res) => {
// Validation already happened
const user = await createUser(req.body);
res.status(201).json(user);
});
Organizing Routes by Feature/Resource
Structure your routes directory by resource or feature. Here’s a production-ready organization:
routes/
├── index.js
├── users.js
├── products.js
├── auth.js
└── orders.js
Use an index file to combine and export all routers:
// routes/index.js
const express = require('express');
const userRoutes = require('./users');
const productRoutes = require('./products');
const authRoutes = require('./auth');
const orderRoutes = require('./orders');
const router = express.Router();
router.use('/users', userRoutes);
router.use('/products', productRoutes);
router.use('/auth', authRoutes);
router.use('/orders', orderRoutes);
module.exports = router;
Then mount everything at once:
// app.js
const routes = require('./routes');
app.use('/api/v1', routes);
For nested resources, create sub-routers:
// routes/posts.js
const express = require('express');
const router = express.Router();
const commentRouter = require('./comments');
router.get('/', getAllPosts);
router.get('/:id', getPost);
// Nested resource: comments belong to posts
router.use('/:postId/comments', commentRouter);
module.exports = router;
Advanced Routing Patterns
The router.route() method chains HTTP verbs for the same path, reducing repetition:
router.route('/:id')
.get(async (req, res) => {
const user = await getUser(req.params.id);
res.json(user);
})
.put(async (req, res) => {
const user = await updateUser(req.params.id, req.body);
res.json(user);
})
.delete(async (req, res) => {
await deleteUser(req.params.id);
res.status(204).send();
});
Middleware arrays let you compose multiple handlers:
const authenticate = (req, res, next) => { /* ... */ next(); };
const authorize = (req, res, next) => { /* ... */ next(); };
const validateInput = (req, res, next) => { /* ... */ next(); };
router.post(
'/admin/users',
[authenticate, authorize, validateInput],
async (req, res) => {
// All middleware passed
const user = await createUser(req.body);
res.status(201).json(user);
}
);
Version your API with route prefixing:
// app.js
const v1Routes = require('./routes/v1');
const v2Routes = require('./routes/v2');
app.use('/api/v1', v1Routes);
app.use('/api/v2', v2Routes);
Best Practices and Common Pitfalls
Route order matters. Express matches routes in definition order. Specific routes must come before generic ones:
// WRONG - generic route catches everything
router.get('/:id', getUser);
router.get('/me', getCurrentUser); // Never reached!
// CORRECT - specific first
router.get('/me', getCurrentUser);
router.get('/:id', getUser);
Always handle errors consistently. Create a centralized error handler:
// routes/users.js
router.get('/:id', async (req, res, next) => {
try {
const user = await getUser(req.params.id);
res.json(user);
} catch (error) {
next(error); // Pass to error handler
}
});
// app.js - error handling middleware
app.use((error, req, res, next) => {
console.error(error);
res.status(error.status || 500).json({
error: error.message || 'Internal server error'
});
});
Don’t create routers inside route handlers. This is a performance killer:
// WRONG - creates new router on every request
app.get('/users', (req, res) => {
const router = express.Router(); // NO!
});
// CORRECT - create router once at module level
const router = express.Router();
Use consistent response formats. Pick a structure and stick to it:
// Success
res.json({ data: users, meta: { total: 100 } });
// Error
res.status(400).json({ error: 'Invalid input', details: [] });
Express Router transforms chaotic route files into maintainable, modular applications. Organize by feature, use router-level middleware for scoped concerns, and follow consistent patterns. Your future self will thank you when debugging a production issue at 2 AM.