Routing is how Express decides which handler function to call for a given HTTP request. It matches on two things: the HTTP method (GET, POST, PUT, DELETE, PATCH) and the URL path. Understanding every routing pattern — static paths, dynamic URL parameters, optional parameters, wildcard routes, regular expressions, and query strings — lets you design clean, predictable API URLs. Express Router lets you group related routes into mini-applications, keeping your codebase organised as it grows.
Route Definition Patterns
| Pattern | Matches | req.params |
|---|---|---|
/api/tasks |
Exact: /api/tasks |
{} |
/api/tasks/:id |
/api/tasks/42, /api/tasks/abc |
{ id: '42' } |
/api/tasks/:id/:action |
/api/tasks/42/complete |
{ id: '42', action: 'complete' } |
/api/tasks/:id? |
/api/tasks or /api/tasks/42 |
{} or { id: '42' } |
/api/files/* |
/api/files/a/b/c.txt |
{ '0': 'a/b/c.txt' } |
/api/v:version/tasks |
/api/v2/tasks |
{ version: '2' } |
Regex: /^\/api\/tasks\/(\d+)$/ |
Only numeric IDs | { '0': '42' } |
HTTP Method Routing
| Method | app method | Typical Use |
|---|---|---|
| GET | app.get() |
Retrieve resource(s) |
| POST | app.post() |
Create a new resource |
| PUT | app.put() |
Replace entire resource |
| PATCH | app.patch() |
Partial update of a resource |
| DELETE | app.delete() |
Remove a resource |
| ALL | app.all() |
Match any HTTP method — useful for middleware on a path |
| Multiple methods | app.route('/path').get(fn).post(fn) |
Chain handlers for same path |
Query String vs URL Parameters — When to Use Which
| Use | Mechanism | Example |
|---|---|---|
| Identify a specific resource | URL parameter :id |
GET /api/tasks/42 |
| Filter a collection | Query string | GET /api/tasks?status=completed&priority=high |
| Paginate results | Query string | GET /api/tasks?page=2&limit=10 |
| Sort results | Query string | GET /api/tasks?sort=createdAt&order=desc |
| Search | Query string | GET /api/tasks?q=meeting |
| Nested resource | URL parameter | GET /api/users/42/tasks |
req.params) are always strings — even if the value looks like a number. req.params.id for /api/tasks/42 gives you the string '42', not the number 42. When using it as a MongoDB ObjectId with Mongoose, this is fine because Mongoose accepts string IDs. When doing arithmetic, always parse it: parseInt(req.params.id, 10).express.Router() to group routes by resource. Each router file handles one resource (tasks, users, auth) and is mounted with a prefix in app.js. This means the route files use short paths (router.get('/:id')) while the full path (/api/tasks/:id) is assembled by Express when the router is mounted. It also means you can move a resource to a different URL prefix by changing only one line in app.js.app.get('*', handler) or a route with an optional parameter will match requests intended for more specific routes registered after it. Always register more specific routes before more general ones, and register your 404 catch-all last.Basic Example
const express = require('express');
const app = express();
app.use(express.json());
// ── Static routes ─────────────────────────────────────────────────────────
app.get('/api/tasks', (req, res) => res.json({ route: 'GET all tasks' }));
app.post('/api/tasks', (req, res) => res.json({ route: 'POST create task' }));
app.get('/api/tasks/stats', (req, res) => res.json({ route: 'GET task statistics' }));
// NOTE: /tasks/stats must be BEFORE /tasks/:id — otherwise 'stats' matches :id
// ── Dynamic URL parameters ────────────────────────────────────────────────
app.get('/api/tasks/:id', (req, res) => {
const { id } = req.params; // always a string
res.json({ route: 'GET one task', id });
});
app.put('/api/tasks/:id', (req, res) => {
const { id } = req.params;
const updates = req.body;
res.json({ route: 'PUT update task', id, updates });
});
app.delete('/api/tasks/:id', (req, res) => {
res.status(204).end();
});
// ── Multiple parameters ───────────────────────────────────────────────────
app.get('/api/users/:userId/tasks/:taskId', (req, res) => {
const { userId, taskId } = req.params;
res.json({ userId, taskId });
});
// ── Query string parameters ───────────────────────────────────────────────
// GET /api/tasks?status=completed&priority=high&page=2&limit=10&sort=createdAt&order=desc
app.get('/api/tasks', (req, res) => {
const {
status,
priority,
page = '1',
limit = '10',
sort = 'createdAt',
order = 'desc',
q, // search query
} = req.query;
const pageNum = parseInt(page, 10);
const limitNum = parseInt(limit, 10);
res.json({
filters: { status, priority, q },
pagination: { page: pageNum, limit: limitNum },
sort: { field: sort, direction: order },
});
});
// ── app.route() — chain methods on the same path ──────────────────────────
app.route('/api/tasks/:id')
.get( (req, res) => res.json({ action: 'get', id: req.params.id }))
.put( (req, res) => res.json({ action: 'update', id: req.params.id }))
.delete( (req, res) => res.status(204).end());
// ── Optional parameter ────────────────────────────────────────────────────
app.get('/api/users/:id?', (req, res) => {
if (req.params.id) {
res.json({ action: 'get one', id: req.params.id });
} else {
res.json({ action: 'get all' });
}
});
app.listen(3000);
Express Router — Organising Routes by Resource
// ── routes/task.routes.js ────────────────────────────────────────────────
const express = require('express');
const router = express.Router();
const controller = require('../controllers/task.controller');
const auth = require('../middleware/authenticate');
const validate = require('../middleware/validate');
const { body } = require('express-validator');
// Validation rules
const taskValidation = [
body('title').trim().notEmpty().withMessage('Title is required')
.isLength({ max: 200 }).withMessage('Title must be under 200 characters'),
body('priority').optional().isIn(['low', 'medium', 'high'])
.withMessage('Priority must be low, medium, or high'),
];
// All routes in this file are protected
router.use(auth);
// Collection routes — /api/tasks
router.route('/')
.get( controller.getAll)
.post( taskValidation, validate, controller.create);
// Document routes — /api/tasks/:id
router.route('/:id')
.get( controller.getById)
.put( taskValidation, validate, controller.update)
.patch( controller.partialUpdate)
.delete(controller.remove);
// Custom action route — /api/tasks/:id/complete
router.patch('/:id/complete', controller.markComplete);
module.exports = router;
// ── app.js — mount the router ────────────────────────────────────────────
const taskRoutes = require('./routes/task.routes');
const authRoutes = require('./routes/auth.routes');
app.use('/api/auth', authRoutes); // /api/auth/login, /api/auth/register
app.use('/api/tasks', taskRoutes); // /api/tasks, /api/tasks/:id
// The full paths:
// GET /api/tasks → taskRoutes GET /
// POST /api/tasks → taskRoutes POST /
// GET /api/tasks/:id → taskRoutes GET /:id
// PATCH /api/tasks/:id/complete → taskRoutes PATCH /:id/complete
How It Works
Step 1 — Express Builds a Route Table at Startup
When you call app.get('/path', handler), Express adds an entry to an internal routing table: { method: 'GET', path: '/path', handler: fn }. This happens synchronously at startup, not at request time. The order entries are added is the order they will be checked. Mounting a Router with app.use('/prefix', router) adds all the router’s routes to the table with the prefix prepended.
Step 2 — Each Request Is Dispatched Through the Route Table
When an HTTP request arrives, Express starts at the top of the route table and checks each entry. For a route to match, the HTTP method must match (or be app.all()), and the URL must match the pattern. Dynamic segments like :id match any value in that position and extract it into req.params. The first matching handler is called.
Step 3 — Router Creates a Scoped Sub-Application
An Express Router behaves like a mini app — it has its own route table and middleware stack. When mounted with app.use('/api/tasks', taskRouter), Express strips the prefix /api/tasks from the URL before passing it to the router. This is why routes inside the router file use short paths like '/' and '/:id' rather than the full paths.
Step 4 — Query Strings Are Parsed Automatically
Express parses the query string from the URL automatically using Node’s built-in querystring module (or the qs library when extended: true is set). The result is available on req.query as a plain object. All values are strings by default — you must parse numbers with parseInt() or parseFloat() before using them in arithmetic or database queries.
Step 5 — app.route() Reduces Repetition for the Same Path
app.route('/api/tasks/:id').get(fn1).put(fn2).delete(fn3) registers three route handlers for the same path without repeating the path string. This is purely syntactic convenience — it produces the same route table entries as three separate app.get/put/delete calls. It is most useful at the app.js level for simple apps.
Real-World Example: Full Tasks Router with Filtering
// routes/task.routes.js — complete example with filtering, pagination, sorting
const express = require('express');
const router = express.Router();
const Task = require('../models/task.model');
const asyncHandler = require('../utils/asyncHandler');
// GET /api/tasks?status=pending&priority=high&page=1&limit=10&sort=-createdAt&q=meeting
router.get('/', asyncHandler(async (req, res) => {
const {
status, priority, page = 1, limit = 10,
sort = '-createdAt', q,
} = req.query;
// Build MongoDB filter
const filter = { user: req.user.id };
if (status) filter.status = status;
if (priority) filter.priority = priority;
if (q) filter.title = { $regex: q, $options: 'i' };
const pageNum = Math.max(1, parseInt(page, 10));
const limitNum = Math.min(100, parseInt(limit, 10));
const skip = (pageNum - 1) * limitNum;
const [tasks, total] = await Promise.all([
Task.find(filter)
.sort(sort)
.skip(skip)
.limit(limitNum)
.lean(),
Task.countDocuments(filter),
]);
res.set('X-Total-Count', total);
res.json({
success: true,
data: tasks,
meta: {
total,
page: pageNum,
limit: limitNum,
totalPages: Math.ceil(total / limitNum),
},
});
}));
// GET /api/tasks/:id
router.get('/:id', asyncHandler(async (req, res) => {
const task = await Task.findOne({ _id: req.params.id, user: req.user.id });
if (!task) return res.status(404).json({ success: false, message: 'Task not found' });
res.json({ success: true, data: task });
}));
module.exports = router;
Common Mistakes
Mistake 1 — Specific routes registered after wildcard routes
❌ Wrong — /tasks/stats is matched by /:id before it gets to its own route:
router.get('/:id', getById); // matches /stats — returns 'stats' as id!
router.get('/stats', getStats); // NEVER REACHED
✅ Correct — specific routes must come before dynamic ones:
router.get('/stats', getStats); // matches /stats exactly
router.get('/:id', getById); // only reached if not /stats
Mistake 2 — Not parsing query string values before using them as numbers
❌ Wrong — all query string values are strings:
const { page, limit } = req.query; // '2', '10' — strings!
const skip = (page - 1) * limit; // JS coerces: ('2' - 1) * '10' = 10 — works by accident
// But: page + 1 = '21' — string concatenation, not addition!
✅ Correct — always parse numeric query params:
const page = parseInt(req.query.page, 10) || 1;
const limit = parseInt(req.query.limit, 10) || 10;
const skip = (page - 1) * limit; // correct arithmetic
Mistake 3 — Using app directly instead of Router in route files
❌ Wrong — importing app into route files creates circular dependencies:
// task.routes.js
const app = require('../app'); // circular: app requires routes, routes require app
app.get('/api/tasks', handler);
✅ Correct — use express.Router() and export it:
const express = require('express');
const router = express.Router();
router.get('/', handler);
module.exports = router;
Quick Reference
| Task | Code |
|---|---|
| Static route | app.get('/api/tasks', handler) |
| Dynamic param | app.get('/api/tasks/:id', handler) → req.params.id |
| Query string | req.query.page, req.query.status |
| Multiple params | '/api/users/:userId/tasks/:taskId' |
| Optional param | '/api/tasks/:id?' |
| Wildcard | '/api/files/*' |
| Chain methods | app.route('/tasks/:id').get(fn).put(fn).delete(fn) |
| Create router | const router = express.Router() |
| Mount router | app.use('/api/tasks', taskRouter) |