Skip to main content

Middleware Patterns

What is Middleware?

Middleware is the heart of Express. Every time a request comes in, it passes through a pipeline of middleware functions before reaching your route handler. Each function can:

  • Execute any code
  • Modify req or res objects
  • End the request-response cycle
  • Call next() to pass control to the next middleware
Request → Middleware 1 → Middleware 2 → Middleware 3 → Route Handler → Response
↓ ↓ ↓
next() next() next()

A middleware function has this signature:

function myMiddleware(req, res, next) {
// do something
next(); // pass to next middleware
}

Your First Custom Middleware

A request logger — logs every incoming request:

function requestLogger(req, res, next) {
const start = Date.now();

res.on("finish", () => {
const duration = Date.now() - start;
console.log(`${req.method} ${req.originalUrl} ${res.statusCode}${duration}ms`);
});

next();
}

app.use(requestLogger); // applies to ALL routes

Output:

GET /api/users 200 — 45ms
POST /api/posts 201 — 123ms
DELETE /api/posts/5 401 — 8ms

Applying Middleware

Globally (all routes)

app.use(express.json()); // parse JSON bodies
app.use(requestLogger); // your custom logger
app.use(cors()); // CORS headers

Specific route prefix

app.use("/api/admin", requireAdmin); // only admin routes
app.use("/api", rateLimiter); // all /api routes

Single route

app.get("/profile", requireAuth, profileController);
app.post("/upload", requireAuth, multerUpload, uploadController);

Multiple middleware on one route

app.post(
"/api/posts",
requireAuth, // 1. check authentication
validatePostBody, // 2. validate request body
postController // 3. handle the request
);

Authentication Middleware

The most common custom middleware you'll write:

middleware/requireAuth.js
import jwt from "jsonwebtoken";

function requireAuth(req, res, next) {
const authHeader = req.headers.authorization;

if (!authHeader || !authHeader.startsWith("Bearer ")) {
return res.status(401).json({ error: "No token provided" });
}

const token = authHeader.split(" ")[1];

try {
const decoded = jwt.verify(token, process.env.JWT_SECRET);
req.user = decoded; // attach user to request
next();
} catch (err) {
return res.status(401).json({ error: "Invalid or expired token" });
}
}

export default requireAuth;

Usage:

import requireAuth from "./middleware/requireAuth.js";

// All routes in this router require auth
router.use(requireAuth);

// Or per-route
router.get("/profile", requireAuth, getProfile);
router.delete("/account", requireAuth, deleteAccount);

Inside route handlers, req.user now contains the decoded JWT payload:

async function getProfile(req, res) {
const user = await User.findById(req.user.id);
res.json(user);
}

Role-Based Authorization

Build on top of requireAuth for role checks:

middleware/requireRole.js
function requireRole(...roles) {
return (req, res, next) => {
if (!req.user) {
return res.status(401).json({ error: "Not authenticated" });
}
if (!roles.includes(req.user.role)) {
return res.status(403).json({ error: "Insufficient permissions" });
}
next();
};
}

export default requireRole;

The factory pattern (requireRole(...roles)) returns a configured middleware:

router.delete("/users/:id", requireAuth, requireRole("admin"), deleteUser);
router.get("/reports", requireAuth, requireRole("admin", "manager"), getReports);

Request Validation Middleware

Validate request bodies before they reach controllers:

middleware/validate.js
function validate(schema) {
return (req, res, next) => {
const { error, value } = schema.validate(req.body, { abortEarly: false });

if (error) {
const errors = error.details.map((d) => ({
field: d.path.join("."),
message: d.message,
}));
return res.status(400).json({ errors });
}

req.body = value; // use the validated/sanitized value
next();
};
}

export default validate;

With Joi schema:

import Joi from "joi";
import validate from "./middleware/validate.js";

const createUserSchema = Joi.object({
name: Joi.string().min(2).max(50).required(),
email: Joi.string().email().required(),
password: Joi.string().min(8).required(),
});

router.post("/users", validate(createUserSchema), createUser);

Error Handling Middleware

Error-handling middleware has four parameters — the extra err parameter is what tells Express it's an error handler:

middleware/errorHandler.js
function errorHandler(err, req, res, next) {
console.error(err.stack);

// Operational errors (expected failures)
if (err.isOperational) {
return res.status(err.statusCode).json({ error: err.message });
}

// Programming errors (unexpected — hide details in production)
const statusCode = err.statusCode || 500;
const message = process.env.NODE_ENV === "production"
? "Internal server error"
: err.message;

res.status(statusCode).json({ error: message });
}

export default errorHandler;

Register it last, after all routes:

app.use("/api", router);
app.use(errorHandler); // must be last

Pass errors to it with next(err):

async function getUser(req, res, next) {
try {
const user = await User.findById(req.params.id);
if (!user) return res.status(404).json({ error: "User not found" });
res.json(user);
} catch (err) {
next(err); // forwards to errorHandler
}
}

Async Middleware Wrapper

Writing try/catch in every async handler is repetitive. A simple wrapper removes the boilerplate:

utils/asyncHandler.js
const asyncHandler = (fn) => (req, res, next) =>
Promise.resolve(fn(req, res, next)).catch(next);

export default asyncHandler;

Now handlers can be async without explicit try/catch:

import asyncHandler from "./utils/asyncHandler.js";

router.get("/users/:id", asyncHandler(async (req, res) => {
const user = await User.findById(req.params.id);
if (!user) return res.status(404).json({ error: "User not found" });
res.json(user);
}));
// Any thrown error is automatically passed to next(err)

Middleware Execution Order

Order matters. This is a common gotcha:

// CORRECT — json parsing before routes that need req.body
app.use(express.json());
app.use(cors());
app.use(requestLogger);
app.use("/api", router);
app.use(errorHandler); // always last

// WRONG — routes come before json parser, req.body will be undefined
app.use("/api", router);
app.use(express.json()); // too late

A solid ordering for most apps:

// 1. Security and parsing
app.use(helmet());
app.use(cors(corsOptions));
app.use(express.json({ limit: "10kb" }));
app.use(express.urlencoded({ extended: false }));

// 2. Logging and rate limiting
app.use(requestLogger);
app.use("/api", rateLimiter);

// 3. Routes
app.use("/api/auth", authRouter);
app.use("/api/users", requireAuth, usersRouter);

// 4. 404 handler
app.use((req, res) => res.status(404).json({ error: "Route not found" }));

// 5. Error handler (must be last)
app.use(errorHandler);