Mastering Routing in Express: Beyond `app.get()`

Front
Back
Right
Left
Top
Bottom
THE PROBLEM

The Problem With Tutorial Routing

Every Express tutorial starts with this:
Copy to clipboard
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.

ROUTER
Your First Step to Sanity

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
Copy to clipboard
// 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);
Now `/api/users` is handled entirely by `user.routes.ts`. Clean, isolated, testable.
Nested Routes and RESTful Design
Real-world APIs have relationships. A user has posts. A post has comments. Here’s how to nest routers properly:
Copy to clipboard
// 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;
Now `GET /api/users/42/posts` works perfectly, and `req.params.userId` is available in the posts router because of `mergeParams: true`.
Common Mistake

Forgetting `{ mergeParams: true }` when you need parent route params inside a nested router. The params will be `{}` without it.
DYNAMIC ROUTE

Dynamic Route Parameters, Wildcards, and Regex

Named Parameters
Copy to clipboard
// Matches: /users/42, /users/abc
router.get('/users/:id', (req, res) => {
  res.json({ id: req.params.id });
});
Optional Parameters
Copy to clipboard
// Matches: /posts or /posts/2027
router.get('/posts/:year?', (req, res) => {
  const year = req.params.year ?? 'all';
  res.json({ year });
});
Wildcard Routes
Copy to clipboard
// Catch-all — must be last
router.get('*', (req, res) => {
  res.status(404).json({ error: 'Route not found' });
});
Express v5 Change
Complex regex patterns (like `/users/:id(\\d+)`) are removed for security. Use Zod or a validation layer to validate param types instead. See Post 06 for that.
VERSION
The Right Way

API Versioning

A production API needs versioning. When you ship a breaking change, `/api/v1` should still work while `/api/v2` gets the new behavior.
Copy to clipboard
// 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;
Copy to clipboard
// 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
his way, clients using v1 continue working uninterrupted while you ship v2 improvements.
AUTO-LOADING
Convention Over Config

Auto-Loading Routes

As your API grows, manually importing every router into `src/routes/index.ts` becomes a chore. Here’s how I auto-load routes using Node.js’s built-in `fs` module:
Copy to clipboard
// 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}`);
    }
  }
}
Each route file exports a `prefix`:
Copy to clipboard
// src/routes/user.routes.ts
export const prefix = '/api/v1/users';
export default router;
Now adding a new resource is just creating a new file — no manual registration required.
BEST PRACTICE

Route Design Best Practices

Before we wrap up, here’s a quick checklist I apply to every API I build:

Explore project snapshots or discuss custom web solutions.

Programs must be written for people to read, and only incidentally for machines to execute.

Harold Abelson & Gerald Jay Sussman, Structure and Interpretation of Computer Programs

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!
Front
Back
Right
Left
Top
Bottom
FAQ's

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