Routing — Static Routes, Dynamic Parameters, and Query Strings

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
Note: URL parameters (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).
Tip: Use 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.
Warning: Route order matters in Express. Routes are matched top-to-bottom in the order they are registered. A wildcard route like 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)

🧠 Test Yourself

A router has these two routes registered in this order: router.get('/:id', getById) then router.get('/export', exportAll). What happens when GET /export is requested?