The Problem With Tutorial Routing
app.get('/', (req, res) => {
res.send('Hello World');
});
It works. It’s clean. And it will destroy your codebase if you keep writing routes this way.
I’ve onboarded onto codebases where every single route — 80 of them — lived in `index.js`. No separation, no organization, no sanity. Don’t be that engineer.
In this post, I’ll walk you through the routing patterns I actually use in production: modular routers, versioned APIs, and smart project organization that scales.
The Express Router
`express.Router()` is the core building block for modular routing. Think of it as a mini Express app — it has its own middleware stack and route definitions, and you mount it on a path in your main app.
Basic Router Setup
// src/routes/user.routes.ts
import { Router } from 'express';
import { getUsers, getUserById, createUser } from '../controllers/user.controller.js';
const router = Router();
router.get('/', getUsers);
router.get('/:id', getUserById);
router.post('/', createUser);
export default router;
```
```typescript
// src/index.ts
import express from 'express';
import userRoutes from './routes/user.routes.js';
const app = express();
app.use(express.json());
app.use('/api/users', userRoutes);
app.listen(3000);
Nested Routes and RESTful Design
// src/routes/post.routes.ts
import { Router } from 'express';
const router = Router({ mergeParams: true }); // mergeParams gives you access to parent params
router.get('/', async (req, res) => {
const { userId } = req.params; // from parent route
// fetch posts for this user
res.json({ userId, posts: [] });
});
export default router;
```
```typescript
// src/routes/user.routes.ts
import { Router } from 'express';
import postRoutes from './post.routes.js';
const router = Router();
router.get('/', getUsers);
router.get('/:id', getUserById);
router.use('/:userId/posts', postRoutes); // mount nested router
export default router;
Common Mistake
Forgetting `{ mergeParams: true }` when you need parent route params inside a nested router. The params will be `{}` without it.
Dynamic Route Parameters, Wildcards, and Regex
Named Parameters
// Matches: /users/42, /users/abc
router.get('/users/:id', (req, res) => {
res.json({ id: req.params.id });
});
Optional Parameters
// Matches: /posts or /posts/2027
router.get('/posts/:year?', (req, res) => {
const year = req.params.year ?? 'all';
res.json({ year });
});
Wildcard Routes
// Catch-all — must be last
router.get('*', (req, res) => {
res.status(404).json({ error: 'Route not found' });
});
Express v5 Change
API Versioning
// src/routes/index.ts
import { Router } from 'express';
import v1Routes from './v1/index.js';
import v2Routes from './v2/index.js';
const router = Router();
router.use('/v1', v1Routes);
router.use('/v2', v2Routes);
export default router;
// src/index.ts
import apiRoutes from './routes/index.js';
app.use('/api', apiRoutes);
// Result:
// GET /api/v1/users → v1 users handler
// GET /api/v2/users → v2 users handler
Auto-Loading Routes
// src/routes/loader.ts
import { readdirSync } from 'fs';
import { Router } from 'express';
import path from 'path';
import { fileURLToPath } from 'url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
export async function loadRoutes(app: ReturnType<typeof import('express').default>) {
const routeFiles = readdirSync(__dirname).filter(
file => file.endsWith('.routes.ts') || file.endsWith('.routes.js')
);
for (const file of routeFiles) {
const { default: router, prefix } = await import(path.join(__dirname, file));
if (router && prefix) {
app.use(prefix, router);
console.log(`Loaded route: ${prefix}`);
}
}
}
// src/routes/user.routes.ts
export const prefix = '/api/v1/users';
export default router;
Route Design Best Practices
- Use plural nouns for resources: `/users`, `/posts`, not `/user`, `/getPost`
- Use HTTP methods to express intent: GET retrieves, POST creates, PUT replaces, PATCH updates, DELETE removes
- Keep routes thin: route handlers should call a controller, not contain business logic
- Version from day one: adding `/v1` later is painful; doing it from the start is free
Explore project snapshots or discuss custom web solutions.
Programs must be written for people to read, and only incidentally for machines to execute.
Thank You for Spending Your Valuable Time
I truly appreciate you taking the time to read blog. Your valuable time means a lot to me, and I hope you found the content insightful and engaging!
Frequently Asked Questions
`app.get()` matches only GET requests on an exact path. `app.use()` matches *any* HTTP method and treats the path as a prefix — all sub-routes match. Use `app.use()` to mount routers.
Yes. Even if a resource has only 2 routes today, using a Router from the start keeps it consistent and scalable.
Add a catch-all route *after* all other routes: `app.use('*', (req, res) => res.status(404).json({ error: 'Not found' }))`.
Yes — this is REST! `GET /users` lists users, `POST /users` creates one. Express handles them as separate handlers.
The `path-to-regexp` library was upgraded to v8.x, which removed regex sub-expression support for security. If you used patterns like `:id(\\d+)`, you'll need to validate params at the application level instead.
Comments are closed