Last 30 Days
No notifications
The Express Router lets you split routes into separate files for cleaner code organization.
// routes/users.js
const router = express.Router();router.get('/', listUsers); // GET /api/users
router.get('/:id', getUser); // GET /api/users/:id
router.post('/', createUser); // POST /api/users
router.put('/:id', updateUser); // PUT /api/users/:id
router.delete('/:id', deleteUser); // DELETE /api/users/:id
module.exports = router;
// server.js
app.use('/api/users', userRoutes);
| Type | Example URL | Access |
| Params | /users/42 | req.params.id → '42' |
| Query | /users?page=1&limit=10 | req.query.page → '1' |
| Body | POST data | req.body.name |
router.route('/users/:id')
.get(getUser)
.put(updateUser)
.delete(deleteUser);A route says "when a request matches *this method* and *this URL pattern*, run *this function*." Express routing is a thin DSL for that:
app.get ('/users', listUsers);
app.post ('/users', createUser);
app.get ('/users/:id', getUser);
app.patch ('/users/:id', updateUser);
app.delete('/users/:id', deleteUser);That's a complete CRUD resource in 5 lines.
A segment that starts with : is a placeholder. Express puts the captured value on req.params:
app.get('/users/:id', (req, res) => {
res.json({ id: req.params.id });
});
// GET /users/42 → { id: "42" }Multiple params? Each one becomes a key:
app.get('/users/:userId/posts/:postId', (req, res) => {
const { userId, postId } = req.params;
});Everything after ? is parsed into req.query:
app.get('/search', (req, res) => {
const { q, page = '1', limit = '10' } = req.query;
res.json({ q, page: Number(page), limit: Number(limit) });
});
// GET /search?q=react&page=2 → { q: "react", page: 2, limit: 10 }Query values are always strings (or string arrays). Convert them yourself.
A single server.js with 50 routes becomes unreadable. Use Router to group related routes:
// routes/users.routes.js
import { Router } from 'express';
const router = Router();router.get ('/', listUsers);
router.post ('/', createUser);
router.get ('/:id', getUser);
export default router;
// app.js
import usersRouter from './routes/users.routes.js';
app.use('/api/users', usersRouter); // GET /api/users, POST /api/users, …A router is itself middleware, so you can stack auth on top:
app.use('/api/admin', requireAdmin, adminRouter);route() ChainingWhen one URL has many methods, group them:
router.route('/:id')
.get(getUser)
.patch(updateUser)
.delete(deleteUser);Easier to read than three separate router.get / router.patch / router.delete lines.
1. Confusing req.params and req.query. /users/42 → params. /users?id=42 → query.
2. Forgetting numbers are strings. req.params.id === 42 is false. Convert with Number(…) or use validation (Zod).
3. Route order traps. app.get('/users/:id', …) declared before app.get('/users/me', …) swallows /users/me (id = "me"). Put specific before generic.
4. Trailing-slash inconsistency. /users and /users/ are technically different. Pick one and redirect the other (or use a normaliser).
5. Hard-coding versions in every file. Mount v1Router once at /api/v1.
6. Putting business logic in the route. Routes should hand off to a controller / service. Otherwise you can't unit-test it.
mergeParamsWhen you mount one router under a parameterised path, the child needs to *see* the parent's params:
// /api/users/:userId/posts/...
import { Router } from 'express';
const posts = Router({ mergeParams: true }); // ← the magic flagposts.get('/', (req, res) => {
const { userId } = req.params; // available now
res.json({ userId, posts: [] });
});
router.use('/:userId/posts', posts);
Without mergeParams: true, req.params.userId would be undefined inside the child router.
Keep routes one-liners, push logic into controllers:
// controllers/users.controller.js
export const listUsers = async (req, res, next) => {
try {
const users = await userService.list(req.query);
res.json({ data: users });
} catch (e) { next(e); }
};// routes/users.routes.js
import { listUsers, createUser, getUser } from '../controllers/users.controller.js';
router.get('/', listUsers);
router.post('/', createUser);
router.get('/:id', getUser);Now the controller is unit-testable without spinning up Express.
Never break existing clients. Mount versions side-by-side:
app.use('/api/v1', v1Router);
app.use('/api/v2', v2Router);Deprecate v1 with a header (Deprecation: true, Sunset: ) and a docs link.
router.param — DRY Up Param Loadingrouter.param('id', async (req, res, next, id) => {
const user = await User.findById(id);
if (!user) return res.status(404).json({ error: 'not found' });
req.user = user;
next();
});router.get('/:id', (req, res) => res.json(req.user));
router.patch('/:id', updateUser);
router.delete('/:id', deleteUser);
One lookup, three handlers benefit. Stops every controller re-querying the user.
Always add a final catch-all so unmatched URLs get a clean JSON error:
app.use((req, res) => {
res.status(404).json({ error: Cannot ${req.method} ${req.originalUrl} });
});In Express 5, use app.all('/*splat', …) instead of the legacy '*' (which now throws).
Express 5 uses path-to-regexp v8. A few useful tricks:
// Optional segment
router.get('/posts/:slug?', …); // /posts and /posts/foo// Repeated / catch-all
router.get('/files/*splat', …); // /files/a/b/c → splat = ['a','b','c']
// Custom regex on a param ("only digits")
router.get('/users/:id(\\d+)', …);
Reach for these only when needed — most APIs do fine with plain :param.
Serve HTML or JSON from the same route based on Accept:
app.get('/users/:id', async (req, res) => {
const user = await User.findById(req.params.id);
res.format({
'application/json': () => res.json(user),
'text/html': () => res.render('user', { user }),
default: () => res.status(406).send('Not Acceptable'),
});
});req.originalUrl, base URLs)req.originalUrl // "/api/users/42?x=1" — full incoming URL
req.baseUrl // "/api/users" — prefix this router was mounted at
req.path // "/42" — within the routerUseful for building HATEOAS links, redirects, and pagination URLs.
Libraries like express-openapi-validator, zod-to-openapi, or Fastify's built-in JSON-schema take a schema and:
req.body / req.query automatically./docs (Swagger UI) for free.1. Build a Router for /api/posts with GET /, POST /, GET /:id, DELETE /:id.
2. Add a nested router /api/posts/:postId/comments using mergeParams: true.
3. Use router.param('postId', …) to load the post once and stick it on req.post.
4. Add a catch-all 404 JSON handler at the end and a versioned mount app.use('/api/v1', …).