Routing

Express.js Fundamentals — Routing

Express.js Fundamentals

Chapter 2 of 8  ·  Routing

Routing

Routing is how Express decides which handler to call for a given request. A route is a combination of an HTTP method, a URL pattern, and a handler function. Express matches routes in the order they were registered — the first match wins. Understanding that ordering rule, how URL parameters and query strings differ, and how express.Router() keeps large applications manageable are the three ideas that make Express routing genuinely simple rather than just seeming simple.

URL Parameters — req.params

A colon in a route path declares a named parameter. Express captures whatever appears at that position in the actual URL and puts it in req.params.

// Named parameter: :id app.get('/users/:id', (req, res) => { // GET /users/42 → req.params = { id: '42' } // GET /users/abc → req.params = { id: 'abc' } // Note: params are ALWAYS strings — parseInt/Number() if you need a number const userId = parseInt(req.params.id, 10); if (isNaN(userId)) { return res.status(400).json({ error: 'id must be an integer' }); } res.json({ userId }); }); // Multiple parameters in one route app.get('/orgs/:org/repos/:repo', (req, res) => { // GET /orgs/express/repos/express → { org: 'express', repo: 'express' } const { org, repo } = req.params; res.json({ org, repo }); }); // Optional parameter — the ? makes the segment optional app.get('/posts/:year/:month?', (req, res) => { // GET /posts/2025 → { year: '2025', month: undefined } // GET /posts/2025/06 → { year: '2025', month: '06' } res.json(req.params); });

⚠ Params Are Always Strings

req.params.id is always a string, even if the URL was /users/42. Compare with === against numbers and you'll get false with no error. Always coerce: parseInt(req.params.id, 10) for integers, Number(req.params.price) for floats. Validate with isNaN() after coercing.

Query Strings — req.query

Everything after the ? in a URL is the query string. Express parses it automatically using the qs library and exposes it as req.query — no route pattern needed, no colon syntax. Query strings are for optional filters, sort order, pagination, and search — anything that refines rather than identifies a resource.

// GET /articles?sort=date&order=desc&page=2&limit=10 app.get('/articles', (req, res) => { // req.query = { sort: 'date', order: 'desc', page: '2', limit: '10' } // Again: all values are strings — coerce and validate before use const page = Math.max(1, parseInt(req.query.page ?? '1', 10)); const limit = Math.min(100, parseInt(req.query.limit ?? '20', 10)); const sort = ['date', 'title', 'views'].includes(req.query.sort) ? req.query.sort : 'date'; res.json({ page, limit, sort, order: req.query.order ?? 'asc' }); }); // Repeated keys → array: GET /tags?tag=js&tag=node&tag=express app.get('/tags', (req, res) => { // req.query.tag = ['js', 'node', 'express'] // Single value → string; multiple → array. Normalise with [].concat(): const tags = [].concat(req.query.tag ?? []); res.json({ tags }); }); // Nested objects via qs syntax: GET /search?filter[active]=true&filter[role]=admin app.get('/search', (req, res) => { // req.query.filter = { active: 'true', role: 'admin' } res.json(req.query); });
PatternWhat ends up inExample
/users/:idreq.params.id/users/42'42'
?sort=datereq.query.sort/items?sort=date'date'
?tag=a&tag=breq.query.tag (array)['a', 'b']
?f[k]=vreq.query.f.k (object){ k: 'v' }

Params vs Query — Which to Use?

URL Parameters (:id)

Use for identity — they identify a specific resource. Required, never optional in the path itself.

GET /users/42 — "give me user 42"

GET /posts/2025/06/my-slug — "this exact post"

Without the param, the URL refers to a different (or no) resource.

Query Strings (?key=val)

Use for options — filters, sorting, pagination, search. All optional; omitting them falls back to a default.

GET /users?role=admin&page=2 — "filtered list"

GET /search?q=express&limit=10 — "search results"

The resource (list of users) exists with or without these params.

Route Matching Rules

Express tests routes in registration order and calls the handler of the first match. This has two important consequences: more specific routes must be registered before more general ones, and a catch-all 404 handler must come last.

// CORRECT order — specific before general app.get('/users/me', getMeHandler); // matches /users/me first app.get('/users/:id', getUserHandler); // catches /users/anything-else // WRONG order — /users/me would never be reached // app.get('/users/:id', getUserHandler); ← /users/me matches here and stops // app.get('/users/me', getMeHandler); ← unreachable
// Route patterns Express supports // Wildcard — matches any path segment(s) at that position app.get('/files/*', handler); // /files/a, /files/a/b/c, etc. // String patterns — limited regex-like syntax app.get('/ab?cd', handler); // /acd or /abcd app.get('/ab+cd', handler); // /abcd, /abbcd, /abbbcd … app.get('/ab(cd)?e', handler); // /abe or /abcde // RegExp — pass a real regex object app.get(/.*fly$/, handler); // /butterfly, /dragonfly // Multiple paths at once — pass an array app.get(['/about', '/about-us'], handler);

express.Router() — Modular Routing

As soon as you have more than a handful of routes, putting them all in app.js becomes unwieldy. express.Router() creates a mini-application — its own isolated middleware and route stack — that you mount on the main app at a path prefix.

app.js mounts routers at prefixes app ├── app.use('/users', usersRouter) ← routes/users.js ├── app.use('/articles', articlesRouter) ← routes/articles.js └── app.use('/auth', authRouter) ← routes/auth.js Inside routes/users.js: usersRouter ├── GET / ← full path: GET /users ├── POST / ← full path: POST /users ├── GET /:id ← full path: GET /users/:id └── DELETE /:id ← full path: DELETE /users/:id
// routes/users.js const express = require('express'); const router = express.Router(); // Paths here are relative to the mount point (/users) router.get('/', (req, res) => res.json({ msg: 'list users' })); router.post('/', (req, res) => res.json({ msg: 'create user' })); router.get('/:id', (req, res) => res.json({ id: req.params.id })); router.put('/:id', (req, res) => res.json({ msg: 'update user' })); router.delete('/:id', (req, res) => res.json({ msg: 'delete user' })); module.exports = router;
// src/app.js — mount routers at their prefixes const express = require('express'); const usersRouter = require('./routes/users'); const articlesRouter = require('./routes/articles'); const app = express(); app.use(express.json()); app.use('/users', usersRouter); app.use('/articles', articlesRouter); // 404 handler — must come after all other routes app.use((_req, res) => { res.status(404).json({ error: 'Route not found' }); }); module.exports = app;

router.route() — Reducing Repetition

When the same path handles multiple HTTP methods, router.route(path) chains them neatly without repeating the path string.

// Without route() — path repeated three times router.get('/:id', getUser); router.put('/:id', updateUser); router.delete('/:id', deleteUser); // With route() — path declared once, methods chained router.route('/:id') .get(getUser) .put(updateUser) .delete(deleteUser); // Same for the collection endpoint router.route('/') .get(listUsers) .post(createUser);

The 404 Handler

A request that reaches the end of Express's middleware/route stack without being handled doesn't automatically get a 404 response — it just hangs. You must add a catch-all handler as the very last app.use() call (no path = matches everything).

// At the very bottom of app.js, after all routers and middleware // 404 — no route matched app.use((_req, res) => { res.status(404).json({ error: 'Route not found' }); }); // Error handler — 4-parameter signature (covered in Chapter 6) app.use((err, _req, res, _next) => { res.status(err.status ?? 500).json({ error: err.message ?? 'Internal server error' }); });

💡 Router Options

express.Router() accepts an options object. The most useful option is { strict: true } — by default, /users and /users/ are treated identically (trailing slash ignored). With strict: true, they are distinct routes. The other option, { caseSensitive: true }, makes /Users and /users different routes (default is case-insensitive). Set these on the app too for consistency: app.set('case sensitive routing', true).

In practice, leave both as defaults — strict and case-sensitive routing creates friction for API consumers without meaningful benefit.

💡 Router Parameters — router.param()

router.param('id', callback) registers a callback that runs automatically whenever a route with :id is matched — useful for loading and validating a resource once, before any route handler runs. The callback receives (req, res, next, value) where value is the actual param value. It's essentially middleware scoped to a parameter name, and it's the right place to do "does this user/article/etc exist?" checks that multiple routes share.

Coding Challenges

Challenge 1 — Params & Query Strings

Build an Express app with these routes: GET /products/:id — validate that :id is a positive integer and return 400 with { error: "id must be a positive integer" } if not, otherwise return { productId: <number> }; GET /products — accept optional query params category (string, default 'all'), minPrice/maxPrice (numbers, default 0/Infinity, return 400 if non-numeric or min > max), and sort (must be one of price|name|newest, default 'newest'); GET /search — require a q query param (400 if missing/empty), accept optional tags (repeated key → normalise to array with [].concat()). Write supertest tests covering valid and invalid inputs for all three routes.

View sample solution ↗

Challenge 2 — Modular Router

Restructure the app using express.Router(). Create two router files: routes/products.js handling GET /, POST /, GET /:id, PUT /:id, DELETE /:id (stub responses are fine — focus on structure); and routes/categories.js handling GET / and GET /:slug/products. In src/app.js: mount productsRouter at /products and categoriesRouter at /categories; add a 404 handler after all routers. Use router.route() to chain methods on the same path. Write supertest tests confirming each mount point works and an unknown path returns 404.

View sample solution ↗

Challenge 3 — router.param() Resource Loader

Build a routes/articles.js router that uses router.param('articleId', ...) to validate and "load" an article before any route handler runs. The param callback should: check that articleId is a positive integer (400 if not); look up the article in a small in-memory array of 3 articles (404 with { error: "Article not found" } if the ID doesn't exist); attach the found article to req.article; call next(). Then define three routes — GET /:articleId, PUT /:articleId, DELETE /:articleId — each of which simply reads from req.article without re-validating. Write supertest tests covering: valid ID, non-integer ID, and a valid integer ID that doesn't exist in the array.

View sample solution ↗