Express Routing
Routers, Parameters, and Folder Structure
LinkedIn Hook
"Your Express app has 47 routes in a single
app.jsfile. Adding a new endpoint feels like defusing a bomb."Every Node.js developer starts the same way. One file. A handful of
app.get(...)calls. It works — until the team grows, the product expands, and that one file balloons to 2,000 lines of tangled handlers, inline database queries, and copy-pasted validation logic.The fix is not "more files." The fix is structure. Express ships with
express.Router()— a mini-application you can mount anywhere — and once you combine it with a feature-based folder layout (routes, controllers, services, models), your codebase scales from prototype to production without a rewrite.In Lesson 6.3, I break down route parameters, query strings, nested routers, API versioning, and the MVC-ish folder structure that every senior Node.js engineer reaches for.
Read the full lesson -> [link]
#NodeJS #ExpressJS #BackendDevelopment #CleanCode #InterviewPrep
What You'll Learn
- How
express.Router()creates modular, mountable mini-applications - Route parameters (
:id), query strings, and the difference between them - Nested routers and route grouping by feature (users, posts, auth)
- The clean folder layout:
routes/,controllers/,services/,models/ - The MVC-ish structure used in production Node.js APIs
- Mounting sub-routers with
app.use('/api/v1/users', userRouter) - API versioning strategies (URL, header, query parameter)
The Office Building Analogy — Why Routers Matter
Imagine a large office building. Visitors do not walk into one giant room and shout for the person they want. They enter a lobby, check the directory, take an elevator to the right floor, walk to the right department, and only then approach the right desk.
Each floor is a self-contained mini-office with its own receptionist, its own meeting rooms, and its own rules. The building does not need to know what happens on floor 7 — it just knows "anything addressed to floor 7 goes up the elevator." If accounting moves to a new building, you do not rebuild the lobby.
That is exactly what express.Router() gives you. The main app is the lobby. Each Router is a floor — a self-contained module with its own routes, its own middleware, and its own logic. You "mount" a router at a path (app.use('/api/v1/users', userRouter)) and Express forwards every matching request to it. The main app does not care what is inside.
Without routers, every route lives in app.js. With routers, each feature lives in its own file, owned by its own team, tested in isolation, and versioned independently.
+---------------------------------------------------------------+
| ROUTER MOUNTING — THE BIG PICTURE |
+---------------------------------------------------------------+
| |
| Incoming Request: GET /api/v1/users/42 |
| | |
| v |
| +-------------------------------+ |
| | app (main lobby) | |
| +-------------------------------+ |
| | |
| app.use('/api/v1', apiRouter) |
| | |
| v |
| +-------------------------------+ |
| | apiRouter (floor 1) | |
| +-------------------------------+ |
| | |
| apiRouter.use('/users', userRouter) |
| | |
| v |
| +-------------------------------+ |
| | userRouter (department) | |
| | GET /:id -> getUserById | |
| +-------------------------------+ |
| | |
| v |
| controllers/userController |
| | |
| v |
| services/userService |
| | |
| v |
| models/User (database) |
| |
+---------------------------------------------------------------+
Basic Routing — The Starting Point
Every Express app begins with route handlers attached directly to the application object. This works for prototypes but breaks down fast.
// app.js — the "everything in one file" approach
const express = require('express');
const app = express();
app.use(express.json());
// GET all users
app.get('/users', (req, res) => {
res.json([{ id: 1, name: 'Alice' }, { id: 2, name: 'Bob' }]);
});
// GET one user by ID
app.get('/users/:id', (req, res) => {
// req.params.id is always a string — convert if needed
const userId = Number(req.params.id);
res.json({ id: userId, name: 'Alice' });
});
// POST a new user
app.post('/users', (req, res) => {
// req.body comes from express.json() middleware
const newUser = req.body;
res.status(201).json({ id: 3, ...newUser });
});
// PUT update a user
app.put('/users/:id', (req, res) => {
res.json({ id: req.params.id, ...req.body });
});
// DELETE a user
app.delete('/users/:id', (req, res) => {
res.status(204).send();
});
app.listen(3000);
This is fine for ten routes. At fifty routes across users, posts, comments, and auth, this file becomes unmaintainable. The fix is to split each feature into its own router.
Route Parameters and Query Strings — Know the Difference
Two different ways to pass data through a URL. Confusing them is one of the most common mistakes in code review.
// routes/products.js
const express = require('express');
const router = express.Router();
// ROUTE PARAMETER (:id) — part of the URL path
// Use for identifying a specific resource
// URL: GET /products/42
router.get('/:id', (req, res) => {
// req.params holds path parameters
const productId = req.params.id; // "42" (always a string)
res.json({ id: productId, name: 'Widget' });
});
// QUERY STRING — appended after a "?"
// Use for filtering, sorting, pagination, optional flags
// URL: GET /products?category=tools&sort=price&page=2
router.get('/', (req, res) => {
// req.query holds query string parameters
const { category, sort, page = 1, limit = 20 } = req.query;
// All query values are strings — coerce when needed
const pageNum = Number(page);
const limitNum = Number(limit);
res.json({
filters: { category, sort },
pagination: { page: pageNum, limit: limitNum },
});
});
// MULTIPLE PARAMETERS in a single path
// URL: GET /products/42/reviews/7
router.get('/:productId/reviews/:reviewId', (req, res) => {
const { productId, reviewId } = req.params;
res.json({ productId, reviewId });
});
// OPTIONAL PARAMETER using "?"
// URL: GET /products/42 OR /products/42/details
router.get('/:id/:section?', (req, res) => {
const { id, section } = req.params;
res.json({ id, section: section || 'overview' });
});
module.exports = router;
Rule of thumb: if the value identifies which resource, use a route parameter. If the value modifies how you return it, use a query string. /users/42 identifies a user. /users?role=admin&active=true filters a collection.
express.Router() — Splitting Routes Into Modules
Now we extract each feature into its own file. This is the single biggest structural improvement you can make to an Express app.
// routes/userRoutes.js
const express = require('express');
const router = express.Router();
// Routes are defined relative to where the router is mounted
// If mounted at "/users", these become /users, /users/:id, etc.
router.get('/', (req, res) => {
res.json({ message: 'List all users' });
});
router.get('/:id', (req, res) => {
res.json({ message: `User ${req.params.id}` });
});
router.post('/', (req, res) => {
res.status(201).json({ message: 'User created', data: req.body });
});
router.put('/:id', (req, res) => {
res.json({ message: `User ${req.params.id} updated` });
});
router.delete('/:id', (req, res) => {
res.status(204).send();
});
module.exports = router;
// routes/postRoutes.js
const express = require('express');
const router = express.Router();
router.get('/', (req, res) => res.json({ message: 'List posts' }));
router.get('/:id', (req, res) => res.json({ message: `Post ${req.params.id}` }));
router.post('/', (req, res) => res.status(201).json({ message: 'Post created' }));
module.exports = router;
// routes/authRoutes.js
const express = require('express');
const router = express.Router();
router.post('/login', (req, res) => res.json({ token: 'jwt-token-here' }));
router.post('/register', (req, res) => res.status(201).json({ message: 'Registered' }));
router.post('/logout', (req, res) => res.json({ message: 'Logged out' }));
module.exports = router;
// app.js — clean and tiny
const express = require('express');
const userRoutes = require('./routes/userRoutes');
const postRoutes = require('./routes/postRoutes');
const authRoutes = require('./routes/authRoutes');
const app = express();
app.use(express.json());
// Mount each router at its base path
// Every request matching the prefix is forwarded to the router
app.use('/api/v1/users', userRoutes);
app.use('/api/v1/posts', postRoutes);
app.use('/api/v1/auth', authRoutes);
app.listen(3000, () => console.log('Server on http://localhost:3000'));
The main app.js is now five lines of routing instead of five hundred. Each feature lives in its own file, can be tested in isolation, and can be developed by a different team member without merge conflicts.
Nested Routers — Routes Inside Routes
Sometimes a feature has sub-features. Posts have comments. Users have sessions. Express lets you mount routers inside routers — building a tree of mini-applications.
// routes/commentRoutes.js
const express = require('express');
// mergeParams: true lets this router access :postId from the parent router
const router = express.Router({ mergeParams: true });
// Mounted at /posts/:postId/comments
router.get('/', (req, res) => {
// req.params.postId comes from the PARENT router
res.json({ postId: req.params.postId, comments: [] });
});
router.post('/', (req, res) => {
res.status(201).json({
postId: req.params.postId,
comment: req.body,
});
});
router.delete('/:commentId', (req, res) => {
res.status(204).send();
});
module.exports = router;
// routes/postRoutes.js
const express = require('express');
const router = express.Router();
const commentRoutes = require('./commentRoutes');
router.get('/', (req, res) => res.json({ message: 'All posts' }));
router.get('/:postId', (req, res) => res.json({ id: req.params.postId }));
// Mount the comments router as a sub-router of posts
// Final URL: /api/v1/posts/:postId/comments
router.use('/:postId/comments', commentRoutes);
module.exports = router;
// app.js
app.use('/api/v1/posts', postRoutes);
// Now you have:
// GET /api/v1/posts
// GET /api/v1/posts/:postId
// GET /api/v1/posts/:postId/comments
// POST /api/v1/posts/:postId/comments
// DELETE /api/v1/posts/:postId/comments/:commentId
The mergeParams: true option is critical. Without it, a child router cannot see parameters defined in the parent router's mount path. Forgetting this is one of the most common Express bugs in nested setups.
The Clean MVC-ish Folder Structure
A router file should not contain business logic, database queries, or validation. It should only define HTTP endpoints and delegate to a controller. The controller orchestrates. The service holds business rules. The model talks to the database. This separation is what makes a Node.js codebase scale to dozens of contributors.
+---------------------------------------------------------------+
| PRODUCTION EXPRESS FOLDER LAYOUT |
+---------------------------------------------------------------+
| |
| my-api/ |
| | |
| +-- src/ |
| | | |
| | +-- app.js (Express app setup, middleware) |
| | +-- server.js (HTTP server bootstrap) |
| | | |
| | +-- config/ |
| | | +-- db.js (Database connection) |
| | | +-- env.js (Environment variables) |
| | | |
| | +-- routes/ (HTTP endpoints only) |
| | | +-- index.js (Combines all feature routers) |
| | | +-- userRoutes.js |
| | | +-- postRoutes.js |
| | | +-- authRoutes.js |
| | | |
| | +-- controllers/ (Request/response handling) |
| | | +-- userController.js |
| | | +-- postController.js |
| | | +-- authController.js |
| | | |
| | +-- services/ (Business logic, no req/res) |
| | | +-- userService.js |
| | | +-- postService.js |
| | | +-- authService.js |
| | | |
| | +-- models/ (Database schemas/queries) |
| | | +-- User.js |
| | | +-- Post.js |
| | | |
| | +-- middleware/ (Auth, validation, errors) |
| | | +-- authMiddleware.js |
| | | +-- errorHandler.js |
| | | |
| | +-- utils/ (Helpers, formatters) |
| | +-- logger.js |
| | +-- validators.js |
| | |
| +-- tests/ |
| +-- package.json |
| +-- .env |
| |
+---------------------------------------------------------------+
Here is what each layer looks like in code. Notice how the route file is just plumbing — no logic at all.
// routes/userRoutes.js — only HTTP wiring
const express = require('express');
const router = express.Router();
const userController = require('../controllers/userController');
const authMiddleware = require('../middleware/authMiddleware');
router.get('/', userController.getAll);
router.get('/:id', userController.getById);
router.post('/', userController.create);
router.put('/:id', authMiddleware, userController.update);
router.delete('/:id', authMiddleware, userController.remove);
module.exports = router;
// controllers/userController.js — req/res only, no business logic
const userService = require('../services/userService');
exports.getAll = async (req, res, next) => {
try {
// Controller only translates HTTP to/from the service
const users = await userService.listUsers(req.query);
res.json(users);
} catch (err) {
next(err);
}
};
exports.getById = async (req, res, next) => {
try {
const user = await userService.findUserById(req.params.id);
if (!user) return res.status(404).json({ error: 'Not found' });
res.json(user);
} catch (err) {
next(err);
}
};
exports.create = async (req, res, next) => {
try {
const created = await userService.createUser(req.body);
res.status(201).json(created);
} catch (err) {
next(err);
}
};
exports.update = async (req, res, next) => {
try {
const updated = await userService.updateUser(req.params.id, req.body);
res.json(updated);
} catch (err) {
next(err);
}
};
exports.remove = async (req, res, next) => {
try {
await userService.deleteUser(req.params.id);
res.status(204).send();
} catch (err) {
next(err);
}
};
// services/userService.js — pure business logic, no Express
const User = require('../models/User');
exports.listUsers = async (filters) => {
// Apply business rules, pagination, filtering
return User.find(filters);
};
exports.findUserById = async (id) => {
return User.findById(id);
};
exports.createUser = async (data) => {
// Validate, hash password, send welcome email, etc.
return User.create(data);
};
exports.updateUser = async (id, data) => {
return User.findByIdAndUpdate(id, data, { new: true });
};
exports.deleteUser = async (id) => {
return User.findByIdAndDelete(id);
};
// routes/index.js — single mounting point for all features
const express = require('express');
const router = express.Router();
router.use('/users', require('./userRoutes'));
router.use('/posts', require('./postRoutes'));
router.use('/auth', require('./authRoutes'));
module.exports = router;
// app.js — the entire API mounted under one prefix
const express = require('express');
const apiRoutes = require('./routes');
const app = express();
app.use(express.json());
// Single mount point — versioning happens here
app.use('/api/v1', apiRoutes);
module.exports = app;
The benefit of this layout: when a service is reused (CLI script, background worker, GraphQL resolver), you import the service directly without dragging Express along. The business logic has zero coupling to HTTP.
API Versioning — Don't Break Existing Clients
The day you publish your first API, mobile clients lock to its shape. Six months later you need to change a response field. If you change /api/users, you break every app in the wild. The fix is versioning — keep /api/v1 running while you build /api/v2 alongside it.
// app.js — running v1 and v2 simultaneously
const express = require('express');
const v1Routes = require('./routes/v1');
const v2Routes = require('./routes/v2');
const app = express();
app.use(express.json());
// Old clients keep working
app.use('/api/v1', v1Routes);
// New clients adopt v2 at their own pace
app.use('/api/v2', v2Routes);
app.listen(3000);
Three common versioning strategies:
- URL versioning:
/api/v1/users— most common, easiest to debug, visible in logs. - Header versioning:
Accept: application/vnd.myapi.v2+json— clean URLs but invisible in browser bars. - Query versioning:
/api/users?version=2— simple but messy for clients to manage.
URL versioning wins in 90% of real codebases because it is explicit, cacheable, and obvious in error reports.
Common Mistakes
1. Putting business logic inside route handlers.
The biggest sin in Express. A route file should be 5-10 lines per endpoint, not 200. The moment you write a database query inside router.get(...), your code becomes untestable, unreusable, and impossible to refactor. Move it to a service.
2. Forgetting mergeParams: true on nested routers.
When you mount a child router under a parent path with parameters (/posts/:postId/comments), the child router cannot see req.params.postId unless you create it with express.Router({ mergeParams: true }). Skipping this is a silent bug — req.params.postId becomes undefined.
3. Treating route parameters and query strings as interchangeable.
Use route parameters (:id) for resource identification. Use query strings (?filter=x) for filtering, sorting, and pagination. Mixing them up makes your API confusing and inconsistent — /users/42 is "the user 42", but /users?id=42 implies filtering a collection.
4. Skipping API versioning until it is too late.
Day-one APIs without versioning lock you into the original shape forever. Even if you only have /v1 and never plan a /v2, having the prefix from day one costs nothing and unlocks future evolution. Adding versioning to a live API later requires breaking changes.
5. Mounting routers in the wrong order.
Express matches routes in registration order. If you app.use('/api/v1', apiRoutes) after app.use('/api', catchAllRoutes), the catch-all will swallow your v1 requests. Always mount specific routes before general ones.
Interview Questions
1. "What is express.Router() and why would you use it instead of attaching routes directly to the app?"
express.Router() creates a mini Express application — a self-contained module with its own routes, middleware, and parameter handlers. You use it to split a large app into feature-based modules: one router for users, one for posts, one for auth. The main app.js then mounts each router at a base path with app.use('/api/v1/users', userRouter). The benefits are huge: each feature lives in its own file, can be tested in isolation, can be reused across multiple apps, and avoids merge conflicts when multiple developers work on the same codebase. Without routers, every endpoint lives in a single bloated file that becomes impossible to maintain past a few dozen routes.
2. "What is the difference between req.params and req.query?"
req.params holds route parameters — values captured from the URL path using placeholders like :id. They are part of the resource identifier. For /users/42, req.params.id is "42". req.query holds query string parameters — key-value pairs after a ? in the URL. They are typically used for filtering, sorting, or pagination. For /users?role=admin&page=2, req.query is { role: 'admin', page: '2' }. Both are always strings — you must coerce numbers manually. The convention is: route parameters identify which resource, query strings modify how you return it.
3. "What does mergeParams: true do in express.Router()?"
By default, an Express router only sees parameters defined in its own routes. If you mount a child router under a parent path with parameters — say app.use('/posts/:postId/comments', commentRouter) — the comment router cannot access req.params.postId because that parameter belongs to the parent. Setting express.Router({ mergeParams: true }) tells the child router to inherit parameters from its parent's mount path. Without this option, nested routes silently break — req.params.postId becomes undefined and the bug is hard to spot. Any time you have nested routers with parent parameters, you need mergeParams: true.
4. "How do you organize routes, controllers, services, and models in a production Express app?"
The standard pattern is MVC-ish layering: routes define HTTP endpoints and delegate to controllers; controllers handle req/res translation and call services; services contain pure business logic with no Express dependencies; models talk to the database. The folder structure mirrors this: routes/userRoutes.js, controllers/userController.js, services/userService.js, models/User.js. The route file is just wiring — five lines per endpoint pointing to a controller method. The controller is a thin adapter between HTTP and the service. The service holds the actual logic. This separation means you can reuse services in CLI scripts, background workers, or GraphQL resolvers without dragging Express along, and you can unit-test business logic without spinning up an HTTP server.
5. "How do you implement API versioning in Express, and which strategy is best?"
The most common approach is URL versioning: mount each version's router under a different prefix — app.use('/api/v1', v1Routes) and app.use('/api/v2', v2Routes). Both versions run side by side, so old clients keep working while new clients adopt v2 at their own pace. Other strategies include header versioning (Accept: application/vnd.myapi.v2+json) and query parameter versioning (?version=2). URL versioning wins in production for three reasons: it is explicit and visible in logs, it is easy to test with curl or a browser, and it can be cached separately by CDNs. Even if you only ever have v1, putting /api/v1 in your URLs from day one costs nothing and gives you an escape hatch when breaking changes become necessary later.
Quick Reference — Routing Cheat Sheet
+---------------------------------------------------------------+
| EXPRESS ROUTING CHEAT SHEET |
+---------------------------------------------------------------+
| |
| CREATE A ROUTER: |
| const router = express.Router() |
| router.get('/:id', handler) |
| module.exports = router |
| |
| MOUNT A ROUTER: |
| app.use('/api/v1/users', userRouter) |
| |
| ROUTE PARAMETER (path): |
| router.get('/:id', (req, res) => req.params.id) |
| |
| QUERY STRING (filter/sort/page): |
| router.get('/', (req, res) => req.query.page) |
| |
| NESTED ROUTER (inherits parent params): |
| const child = express.Router({ mergeParams: true }) |
| parent.use('/:postId/comments', child) |
| |
| VERSIONING: |
| app.use('/api/v1', v1Routes) |
| app.use('/api/v2', v2Routes) |
| |
+---------------------------------------------------------------+
+---------------------------------------------------------------+
| FOLDER LAYERS — WHO DOES WHAT |
+---------------------------------------------------------------+
| |
| routes/ -> Define HTTP endpoints, delegate to controller |
| controllers/ -> Translate req/res, call services |
| services/ -> Pure business logic, no Express imports |
| models/ -> Database schemas and queries |
| middleware/ -> Auth, validation, error handling |
| config/ -> DB connection, environment variables |
| utils/ -> Helpers, formatters, loggers |
| |
+---------------------------------------------------------------+
| Concept | Syntax | Purpose |
|---|---|---|
| Route parameter | /:id | Identify a specific resource |
| Query string | ?key=value | Filter, sort, paginate |
| Optional param | /:id/:section? | Optional path segment |
| Router creation | express.Router() | Mini-app for a feature |
| Mount point | app.use('/path', router) | Attach router to app |
| Nested router | Router({ mergeParams: true }) | Inherit parent params |
| URL versioning | /api/v1/... | Run multiple versions side by side |
Prev: Lesson 6.2 -- Middleware Deep Dive Next: Lesson 6.4 -- Error Handling Middleware
This is Lesson 6.3 of the Node.js Interview Prep Course -- 10 chapters, 42 lessons.