Middleware is the backbone of an Express application. Every request passes through a chain of middleware functions before reaching your route handler. In this lesson we'll build custom middleware, centralize error handling, and add essential production middleware.
How Middleware Works
Middleware functions execute in the order they are registered. Each can modify req/res, end the response, or call next() to pass control forward.
// Execution order: authMiddleware -> validateBody -> route handler
app.post("/api/users", authMiddleware, validateBody, (req, res) => {
res.status(201).json({ data: req.body });
});Custom Request Logger
A more robust logger that captures method, path, status, and duration:
// src/middleware/logger.ts
import type { Request, Response, NextFunction } from "express";
export function requestLogger(req: Request, res: Response, next: NextFunction) {
const start = performance.now();
const { method, originalUrl } = req;
res.on("finish", () => {
const duration = (performance.now() - start).toFixed(1);
const status = res.statusCode;
const level = status >= 400 ? "ERROR" : "INFO";
console.log(`[${level}] ${method} ${originalUrl} ${status} ${duration}ms`);
});
next();
}Centralized Error Handling
Never let unhandled errors crash your server. Create a custom error class and a centralized error handler.
Custom Error Class
// src/utils/app-error.ts
export class AppError extends Error {
constructor(
public statusCode: number,
public code: string,
message: string
) {
super(message);
this.name = "AppError";
}
}Error Handling Middleware
Express error middleware has four parameters. The err parameter tells Express this is an error handler:
// src/middleware/error-handler.ts
import type { Request, Response, NextFunction } from "express";
import { AppError } from "../utils/app-error.js";
export function errorHandler(
err: Error,
_req: Request,
res: Response,
_next: NextFunction
) {
// Known operational errors
if (err instanceof AppError) {
res.status(err.statusCode).json({
error: {
code: err.code,
message: err.message,
},
});
return;
}
// Unexpected errors — log the full stack, return a generic message
console.error("Unhandled error:", err);
res.status(500).json({
error: {
code: "INTERNAL_ERROR",
message: "Something went wrong",
},
});
}Using Errors in Routes
Throw AppError in your routes and let the error handler catch it:
// src/routes/users.ts
import { Router } from "express";
import { AppError } from "../utils/app-error.js";
const router = Router();
router.get("/:id", (req, res) => {
const user = users.find((u) => u.id === Number(req.params.id));
if (!user) {
throw new AppError(404, "NOT_FOUND", "User not found");
}
res.json({ data: user });
});
export default router;Catching Async Errors
Express 4 doesn't catch errors from async handlers automatically. Wrap them or use Express 5 which handles this natively:
// Wrapper for Express 4
function asyncHandler(fn: Function) {
return (req: Request, res: Response, next: NextFunction) => {
Promise.resolve(fn(req, res, next)).catch(next);
};
}
router.get(
"/:id",
asyncHandler(async (req: Request, res: Response) => {
const user = await db.users.findById(req.params.id);
if (!user) {
throw new AppError(404, "NOT_FOUND", "User not found");
}
res.json({ data: user });
})
);Register the Error Handler
The error handler must be registered after all routes:
// src/index.ts
import express from "express";
import { requestLogger } from "./middleware/logger.js";
import { errorHandler } from "./middleware/error-handler.js";
import usersRouter from "./routes/users.js";
const app = express();
app.use(express.json());
app.use(requestLogger);
app.use("/api/users", usersRouter);
// Must be last
app.use(errorHandler);
app.listen(3000);CORS Configuration
Cross-Origin Resource Sharing allows browsers to call your API from different domains:
npm install cors
npm install -D @types/corsimport cors from "cors";
// Allow all origins (development)
app.use(cors());
// Restrict origins (production)
app.use(
cors({
origin: ["https://myapp.com", "https://admin.myapp.com"],
methods: ["GET", "POST", "PUT", "PATCH", "DELETE"],
allowedHeaders: ["Content-Type", "Authorization"],
credentials: true,
})
);Rate Limiting
Prevent abuse by limiting how many requests a client can make:
npm install express-rate-limitimport rateLimit from "express-rate-limit";
// Global rate limiter — 100 requests per 15 minutes per IP
const limiter = rateLimit({
windowMs: 15 * 60 * 1000,
limit: 100,
standardHeaders: "draft-7",
legacyHeaders: false,
message: {
error: {
code: "RATE_LIMIT_EXCEEDED",
message: "Too many requests, try again later",
},
},
});
app.use(limiter);
// Stricter limiter for auth routes
const authLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
limit: 10,
});
app.use("/api/auth", authLimiter);Request ID Middleware
Assign a unique ID to every request for tracing across logs:
// src/middleware/request-id.ts
import { randomUUID } from "node:crypto";
import type { Request, Response, NextFunction } from "express";
export function requestId(req: Request, res: Response, next: NextFunction) {
const id = req.headers["x-request-id"]?.toString() || randomUUID();
req.headers["x-request-id"] = id;
res.set("X-Request-Id", id);
next();
}Putting It All Together
Here's the recommended middleware order for a production API:
// src/index.ts
import express from "express";
import cors from "cors";
import rateLimit from "express-rate-limit";
import { requestId } from "./middleware/request-id.js";
import { requestLogger } from "./middleware/logger.js";
import { errorHandler } from "./middleware/error-handler.js";
import usersRouter from "./routes/users.js";
const app = express();
// 1. Security & parsing
app.use(cors({ origin: "https://myapp.com" }));
app.use(express.json({ limit: "10kb" }));
app.use(rateLimit({ windowMs: 15 * 60 * 1000, limit: 100 }));
// 2. Tracing & logging
app.use(requestId);
app.use(requestLogger);
// 3. Routes
app.use("/api/users", usersRouter);
// 4. Error handling (must be last)
app.use(errorHandler);
app.listen(3000);