Back to blog
nodejsbeginner

Express Routing, Middleware & Controllers

Structure Express routes with Router, write middleware for logging/auth/validation, build a clean controller layer, and handle errors globally.

LearnixoApril 16, 20265 min read
Node.jsExpressRoutingMiddlewareControllersBeginner
Share:𝕏

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 → Response

Each function has the signature (req, res, next):

  • req — the incoming request (headers, body, params, query)
  • res — the outgoing response
  • next() — call the next middleware/route

Defining Routes

TYPESCRIPT
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

TYPESCRIPT
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:

TYPESCRIPT
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

TYPESCRIPT
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

TYPESCRIPT
app.use(express.json());                    // parse JSON bodies
app.use(express.urlencoded({ extended: true })); // parse form data
app.use(express.static('public'));          // serve static files

Custom Middleware

TYPESCRIPT
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:

TYPESCRIPT
app.use(requestLogger);                     // all routes
productRouter.post('/', rateLimit(10), createProduct);  // specific route

Authentication Middleware

TYPESCRIPT
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

Bash
npm install zod

src/schemas/product.schema.ts

TYPESCRIPT
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

TYPESCRIPT
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:

TYPESCRIPT
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:

TYPESCRIPT
app.use('/api/products', productRouter);
app.use('/api/users', userRouter);
app.use(errorHandler);  // must be last

Throw from controllers:

TYPESCRIPT
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()

Enjoyed this article?

Explore the learning path for more.

Found this helpful?

Share:𝕏

Leave a comment

Have a question, correction, or just found this helpful? Leave a note below.