Express Routing, Middleware & Controllers
Structure Express routes with Router, write middleware for logging/auth/validation, build a clean controller layer, and handle errors globally.
Express routes are just functions. Middleware is just a function that runs before your route handler. Understanding this mental model makes everything else click.
The Request-Response Cycle
Every Express request flows through a pipeline of middleware functions:
Request → Middleware 1 → Middleware 2 → Route Handler → ResponseEach function has the signature (req, res, next):
req— the incoming request (headers, body, params, query)res— the outgoing responsenext()— call the next middleware/route
Defining Routes
import express, { Request, Response } from 'express';
const app = express();
app.use(express.json());
// HTTP methods map to CRUD
app.get('/products', getAllProducts); // Read all
app.get('/products/:id', getProductById); // Read one
app.post('/products', createProduct); // Create
app.put('/products/:id', updateProduct); // Replace
app.patch('/products/:id', patchProduct); // Partial update
app.delete('/products/:id', deleteProduct); // Delete
function getAllProducts(req: Request, res: Response): void {
res.json({ products: [] });
}
function getProductById(req: Request, res: Response): void {
const id = Number(req.params.id);
res.json({ id, name: 'Widget' });
}Router — Organise Routes by Feature
Don't put all routes in index.ts. Use express.Router():
src/routes/products.ts
import { Router } from 'express';
import {
getAllProducts,
getProductById,
createProduct,
updateProduct,
deleteProduct,
} from '../controllers/product.controller';
import { authenticate } from '../middleware/auth.middleware';
import { validateBody } from '../middleware/validate.middleware';
import { createProductSchema, updateProductSchema } from '../schemas/product.schema';
export const productRouter = Router();
productRouter.get('/', getAllProducts);
productRouter.get('/:id', getProductById);
productRouter.post('/', authenticate, validateBody(createProductSchema), createProduct);
productRouter.put('/:id', authenticate, updateProduct);
productRouter.delete('/:id', authenticate, deleteProduct);Mount in app.ts:
app.use('/api/products', productRouter);
app.use('/api/users', userRouter);
app.use('/api/orders', orderRouter);Controller Layer
Controllers handle request parsing and response formatting. Business logic lives in services.
src/controllers/product.controller.ts
import { Request, Response, NextFunction } from 'express';
import { ProductService } from '../services/product.service';
const productService = new ProductService();
export async function getAllProducts(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const page = Number(req.query.page ?? 1);
const pageSize = Number(req.query.pageSize ?? 20);
const products = await productService.findAll({ page, pageSize });
res.json({ data: products, page, pageSize });
} catch (err) {
next(err); // forward to error handler
}
}
export async function getProductById(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const product = await productService.findById(Number(req.params.id));
if (!product) {
res.status(404).json({ error: 'Product not found' });
return;
}
res.json(product);
} catch (err) {
next(err);
}
}
export async function createProduct(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const product = await productService.create(req.body);
res.status(201).json(product);
} catch (err) {
next(err);
}
}
export async function deleteProduct(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
await productService.delete(Number(req.params.id));
res.status(204).send();
} catch (err) {
next(err);
}
}Middleware
Built-in Middleware
app.use(express.json()); // parse JSON bodies
app.use(express.urlencoded({ extended: true })); // parse form data
app.use(express.static('public')); // serve static filesCustom Middleware
import { Request, Response, NextFunction } from 'express';
// Request logger
export function requestLogger(req: Request, res: Response, next: NextFunction): void {
const start = Date.now();
res.on('finish', () => {
console.log(`${req.method} ${req.path} ${res.statusCode} ${Date.now() - start}ms`);
});
next();
}
// Rate limit check (simplified)
export function rateLimit(maxPerMinute: number) {
const counts = new Map<string, number>();
return (req: Request, res: Response, next: NextFunction): void => {
const ip = req.ip ?? 'unknown';
const count = (counts.get(ip) ?? 0) + 1;
counts.set(ip, count);
setTimeout(() => counts.set(ip, (counts.get(ip) ?? 1) - 1), 60_000);
if (count > maxPerMinute) {
res.status(429).json({ error: 'Too many requests' });
return;
}
next();
};
}Register globally or per route:
app.use(requestLogger); // all routes
productRouter.post('/', rateLimit(10), createProduct); // specific routeAuthentication Middleware
import jwt from 'jsonwebtoken';
interface JwtPayload {
userId: number;
email: string;
}
declare global {
namespace Express {
interface Request {
user?: JwtPayload;
}
}
}
export function authenticate(req: Request, res: Response, next: NextFunction): void {
const authHeader = req.headers.authorization;
if (!authHeader?.startsWith('Bearer ')) {
res.status(401).json({ error: 'Missing or invalid Authorization header' });
return;
}
const token = authHeader.split(' ')[1];
try {
const payload = jwt.verify(token, process.env.JWT_SECRET!) as JwtPayload;
req.user = payload;
next();
} catch {
res.status(401).json({ error: 'Invalid or expired token' });
}
}Validation Middleware with Zod
npm install zodsrc/schemas/product.schema.ts
import { z } from 'zod';
export const createProductSchema = z.object({
name: z.string().min(1).max(100),
price: z.number().positive(),
category: z.string().min(1),
stock: z.number().int().min(0).default(0),
});
export type CreateProductDto = z.infer<typeof createProductSchema>;src/middleware/validate.middleware.ts
import { ZodSchema } from 'zod';
export function validateBody(schema: ZodSchema) {
return (req: Request, res: Response, next: NextFunction): void => {
const result = schema.safeParse(req.body);
if (!result.success) {
res.status(400).json({
error: 'Validation failed',
details: result.error.flatten().fieldErrors,
});
return;
}
req.body = result.data; // replace with validated + transformed data
next();
};
}Global Error Handler
Add a 4-argument middleware at the END of your middleware chain:
import { Request, Response, NextFunction } from 'express';
export class AppError extends Error {
constructor(
public statusCode: number,
message: string,
) {
super(message);
this.name = 'AppError';
}
}
export function errorHandler(
err: Error,
req: Request,
res: Response,
next: NextFunction, // must have 4 params — Express identifies error handlers this way
): void {
console.error(err);
if (err instanceof AppError) {
res.status(err.statusCode).json({ error: err.message });
return;
}
// Unexpected errors — don't leak stack traces in production
res.status(500).json({
error: process.env.NODE_ENV === 'production'
? 'Internal server error'
: err.message,
});
}Register last in app.ts:
app.use('/api/products', productRouter);
app.use('/api/users', userRouter);
app.use(errorHandler); // must be lastThrow from controllers:
import { AppError } from '../middleware/error.middleware';
if (!product) throw new AppError(404, 'Product not found');
if (!canEdit) throw new AppError(403, 'Forbidden');Quick Reference
Router: const r = Router(); r.get('/', handler); app.use('/api/res', r)
Route params: req.params.id
Query strings: req.query.page
Request body: req.body (requires express.json())
Headers: req.headers.authorization
Middleware: (req, res, next) => { ...; next() }
Error handler: (err, req, res, next) => {} — 4 args, registered last
Validation: zod .safeParse() in middleware, replace req.body with result.data
Auth: jwt.verify() in middleware, attach to req.user
HTTP status: res.status(201).json(data) / res.status(204).send()Found this helpful?
Leave a comment
Have a question, correction, or just found this helpful? Leave a note below.