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
Section titled “Express Router”express.Router() creates a mini-Express application — it has the same .get(), .post(), .use() API but is mounted at a path:
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 routerMount it in index.ts:
import postsRouter from './routes/posts.js'
app.use('/posts', postsRouter)Now GET /posts → router.get('/') and GET /posts/42 → router.get('/:id').
The Bulletin API router structure
Section titled “The Bulletin API router structure”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 routersSeparating controllers
Section titled “Separating controllers”Route handlers that contain business logic should live in controller files. The router file handles routing; the controller handles logic:
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 })}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 routerThe route file just maps paths to controller functions. Clean, readable, testable.
Mounting all routers
Section titled “Mounting all routers”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 baseExercise
Section titled “Exercise”- Create
src/routes/posts.tswith a Router and move your post routes to it. - Create
src/controllers/posts.tsand move the handler logic there. - Import and mount the router in
src/index.ts. - Verify all routes still work.
express.Router()creates a sub-application mounted at a path withapp.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.