Skip to content

Router and Controller Organization

A flat index.ts with all routes quickly becomes unmaintainable. Express Router lets you group related routes into separate files, and separating business logic into controllers keeps route files clean.

express.Router() creates a mini-Express application — it has the same .get(), .post(), .use() API but is mounted at a path:

src/routes/posts.ts
import { Router } from 'express'
const router = Router()
router.get('/', (req, res) => {
res.json({ message: 'List all posts' })
})
router.get('/:id', (req, res) => {
res.json({ message: `Get post ${req.params.id}` })
})
router.post('/', (req, res) => {
res.status(201).json({ message: 'Create post' })
})
export default router

Mount it in index.ts:

src/index.ts
import postsRouter from './routes/posts.js'
app.use('/posts', postsRouter)

Now GET /postsrouter.get('/') and GET /posts/42router.get('/:id').

src/
├── routes/
│ ├── auth.ts ← register, login
│ ├── posts.ts ← CRUD + upvotes
│ └── comments.ts ← comment CRUD
├── controllers/
│ ├── auth.ts ← business logic for auth
│ ├── posts.ts ← business logic for posts
│ └── comments.ts ← business logic for comments
└── index.ts ← mounts routers

Route handlers that contain business logic should live in controller files. The router file handles routing; the controller handles logic:

src/controllers/posts.ts
import { Request, Response } from 'express'
import { db } from '../db/index.js'
export function listPosts(req: Request, res: Response) {
const sort = req.query.sort === 'top'
? 'upvotes DESC'
: 'created_at DESC'
const posts = db.prepare(`SELECT * FROM posts ORDER BY ${sort}`).all()
res.json(posts)
}
export function getPost(req: Request, res: Response) {
const post = db.prepare('SELECT * FROM posts WHERE id = ?')
.get(parseInt(req.params.id))
if (!post) return res.status(404).json({ error: 'Post not found' })
res.json(post)
}
export function createPost(req: Request, res: Response) {
const { title, body } = req.body
const userId = res.locals.user.id
const result = db.prepare(
'INSERT INTO posts (title, body, user_id) VALUES (?, ?, ?)'
).run(title, body, userId)
res.status(201).json({ id: result.lastInsertRowid, title, body })
}
src/routes/posts.ts
import { Router } from 'express'
import { listPosts, getPost, createPost } from '../controllers/posts.js'
import { authenticate } from '../middleware/authenticate.js'
const router = Router()
router.get('/', listPosts)
router.get('/:id', getPost)
router.post('/', authenticate, createPost)
export default router

The route file just maps paths to controller functions. Clean, readable, testable.

src/index.ts
import authRouter from './routes/auth.js'
import postsRouter from './routes/posts.js'
import commentsRouter from './routes/comments.js'
app.use('/auth', authRouter)
app.use('/posts', postsRouter)
app.use('/posts', commentsRouter) // /posts/:id/comments shares the base
  1. Create src/routes/posts.ts with a Router and move your post routes to it.
  2. Create src/controllers/posts.ts and move the handler logic there.
  3. Import and mount the router in src/index.ts.
  4. Verify all routes still work.
  • express.Router() creates a sub-application mounted at a path with app.use('/path', router).
  • Separate route files by resource: routes/posts.ts, routes/auth.ts, etc.
  • Controllers contain the business logic; route files just map paths to controller functions.
  • This structure makes the codebase navigable and each piece independently testable.