Middleware
The invisible pipeline that processes every request
You've been through airport security. Boarding pass check. Bag on the belt. Walk through the scanner. Passport stamp. Each checkpoint does ONE job. If any one rejects you, you're not getting on that plane. You don't argue with the boarding pass checker about your bag -- that's not their job. And you can't skip the metal detector just because your passport looks great. Every checkpoint. In order. No exceptions. That's middleware. Every request to your web server passes through a series of checkpoints, each one doing one specific job: parsing data, checking credentials, logging activity, blocking abusers. If any checkpoint rejects the request, it's over -- the request never reaches your actual code. And just like the airport upgrades security protocols without redesigning the terminal, you can add, remove, or reorder middleware without touching your route handlers.
What Is Middleware (and Why Not Just Put Everything in the Route Handler)?
You have 50 API routes. Every single one needs to check if the user is authenticated. Do you copy-paste that authentication code 50 times?
You could. It would work. Until you need to change how authentication works, and now you're updating 50 files and praying you didn't miss one.
Middleware solves this. It's code that sits between the incoming request and your route handler -- a series of checkpoints in a pipeline. Each middleware function gets three things: the request, the response, and a `next` function. That `next` function is the key to everything. Call it, and the request moves to the next checkpoint. Don't call it, and the request stops right there. The passenger doesn't board the plane.
Here's why this matters to you personally: middleware is how you stop writing the same code over and over. Want to parse JSON bodies? Write it once as middleware, apply it everywhere. Want to log every request? One middleware function. Want to block users who are hammering your server with too many requests? One function. Your route handlers stay clean and focused on their actual job -- the business logic that makes your app unique.
The mental model is a pipeline. Request comes in, flows through middleware A, then B, then C, then reaches the route handler. The response flows back through C, B, and A on the way out. Each middleware can read the request, modify it, add data to it, or stop it entirely. If you used Express in Chapter 5, you already saw hints of this. Now we're going deep.
Middleware Pipeline
Each request passes through a chain of middleware before reaching the handler
Records request method, URL, and timestamp
Checks allowed origins and sets headers
Verifies JWT token, rejects if invalid
Parses JSON body into request object
Validates request data against schema
Executes business logic, returns response
The Standard Checklist Every Web Server Needs
There's a standard checklist every web server needs, and middleware is how you check every box. Think of it as the non-negotiable security and operations setup -- the stuff that has to happen on every request before your app-specific code even runs.
Body parsing is first. When a client sends JSON data in a POST request, the raw body is just bytes. Meaningless bytes. Body parsing middleware (`express.json()`) reads those bytes, parses them into a JavaScript object, and attaches it to `req.body`. Without it, you'd have to manually read the request stream and call `JSON.parse()` in every single route that accepts data.
CORS (Cross-Origin Resource Sharing) is next. Here's the problem: your React frontend runs on `localhost:3000`. Your API runs on `localhost:3001`. You make a fetch request from the frontend to the API. The browser blocks it. Not your server. The browser. It's a security feature called the Same-Origin Policy -- browsers won't let a page talk to a different domain unless the server explicitly says it's okay. CORS middleware adds the headers that say "yes, this origin is allowed." Without it, your frontend literally cannot talk to your own API.
Logging middleware records every request -- method, path, status code, how long it took. When something breaks at 2 AM, these logs are how you figure out what happened.
Rate limiting middleware counts how many requests each IP address has made recently. If someone is hitting your API 1000 times per second (whether it's a bot, an attack, or a buggy client), rate limiting cuts them off with a 429 "Too Many Requests" response. This protects your server and your database from being overwhelmed.
Before reading on, think about this: which of these middleware functions needs to run first? Does the order matter?
import express from "express"
import cors from "cors"
const app = express()
// --- Body Parsing Middleware ---
// Parse JSON request bodies
app.use(express.json())
// Parse URL-encoded form data
app.use(express.urlencoded({ extended: true }))
// --- CORS Middleware ---
// Allow requests from your frontend's domain
app.use(cors({
origin: "http://localhost:3000",
methods: ["GET", "POST", "PUT", "DELETE"],
credentials: true,
}))
// --- Logging Middleware ---
// Runs for EVERY request
app.use((req, res, next) => {
const start = Date.now()
console.log(`--> ${req.method} ${req.path}`)
// When the response finishes, log the duration
res.on("finish", () => {
const duration = Date.now() - start
console.log(
`<-- ${req.method} ${req.path} ${res.statusCode} (${duration}ms)`
)
})
next() // Pass control to the next middleware
})
// --- Rate Limiting Middleware ---
// Note: this is a simplified example for learning.
// In production, use a proven library like express-rate-limit
// (this in-memory Map never cleans up old entries).
const requestCounts = new Map<string, number[]>()
function rateLimit(maxRequests: number, windowMs: number) {
return (req: express.Request, res: express.Response, next: express.NextFunction) => {
const ip = req.ip || "unknown"
const now = Date.now()
const timestamps = requestCounts.get(ip) || []
// Remove old timestamps outside the window
const recent = timestamps.filter((t) => now - t < windowMs)
recent.push(now)
requestCounts.set(ip, recent)
if (recent.length > maxRequests) {
return res.status(429).json({ error: "Too many requests" })
}
next()
}
}
// Apply rate limiting: 100 requests per minute
app.use("/api", rateLimit(100, 60_000))Auth Middleware: The Bouncer at the Door
This is the bouncer at the door. The most important middleware you'll write.
In Chapter 7, we set up database access -- creating, reading, updating, and deleting data. But right now, anyone can do anything. Any random visitor can delete any user's posts. That's obviously unacceptable. Authentication middleware is what stands between your database and the outside world.
Here's how it works. When a user logs in, your server creates a token -- a signed, encoded string that contains the user's ID, email, and role. (We'll build this token system in Chapter 9.) The client stores this token and sends it with every subsequent request in the `Authorization` header: `Bearer eyJhbGciOi...`.
The auth middleware intercepts every request to a protected route. It looks for that Authorization header. If there's no header, the request is rejected immediately -- 401 Unauthorized. If there is a header, the middleware extracts the token, verifies its signature (making sure nobody tampered with it), checks that it hasn't expired, and if everything checks out, attaches the user's information to the request object. Then it calls `next()`, and the route handler can access `req.user` knowing for certain who is making the request.
Here's where it gets powerful: you can layer middleware. One function checks "is the user logged in?" Another checks "is the user an admin?" Stack them on a route, and the request has to pass both checkpoints. Like airport security -- passing the boarding pass check doesn't mean you skip the metal detector.
Here's a question: what's the difference between a 401 and a 403 response? The bouncer analogy helps. A 401 means "I don't know who you are -- show me your ID." A 403 means "I know who you are, but you're not on the VIP list."
import { Request, Response, NextFunction } from "express"
import jwt from "jsonwebtoken"
// Extend Express Request to include user info
interface AuthenticatedRequest extends Request {
user?: { id: number; email: string; role: string }
}
// Authentication middleware: verify JWT token
function authenticate(
req: AuthenticatedRequest,
res: Response,
next: NextFunction
) {
const authHeader = req.headers.authorization
if (!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!) as {
id: number
email: string
role: string
}
req.user = decoded // Attach user info to request
next() // Proceed to the next middleware/handler
} catch {
return res.status(401).json({ error: "Invalid token" })
}
}
// Authorization middleware: check for specific role
function requireRole(role: string) {
return (req: AuthenticatedRequest, res: Response, next: NextFunction) => {
if (req.user?.role !== role) {
return res.status(403).json({ error: "Insufficient permissions" })
}
next()
}
}
// Apply middleware to routes
app.get("/api/profile", authenticate, (req: AuthenticatedRequest, res) => {
// Only runs if authenticate() called next()
res.json({ user: req.user })
})
app.delete(
"/api/users/:id",
authenticate, // First: is the user logged in?
requireRole("admin"), // Second: are they an admin?
(req, res) => {
// Only runs if BOTH middleware passed
// Delete the user...
}
)Next.js Middleware: One File, Edge-Powered, Blazing Fast
Next.js does middleware differently. Instead of chaining multiple functions with `app.use()` like Express, you create a single file called `middleware.ts` at the root of your project. That's it. One file that runs before every matching request.
But here's the really interesting part: it runs on the Edge Runtime. That means your middleware doesn't execute on your main server sitting in one data center somewhere. It runs on servers distributed all over the world, close to your users. A user in Tokyo hits your middleware in Tokyo. A user in London hits middleware in London. The result: authentication checks, redirects, and header modifications happen in milliseconds, before the request even reaches your application server.
This is perfect for the checks that need to happen fast and often: redirecting logged-out users to the login page, detecting a user's language and routing to the right version, running A/B tests by sending different users to different page variants, and adding security headers to every response.
You control which routes your middleware applies to using a `matcher` in the config export. This lets you skip middleware for static files, images, and anything else that doesn't need processing. The middleware receives a `NextRequest` and must return a `NextResponse` -- either continuing the chain, redirecting, or rewriting to a different URL.
If you compare this to the Express middleware we just built, the philosophy is different but the goal is the same: intercept requests, make decisions, and either let them through or redirect them. Express gives you a chain of many small functions. Next.js gives you one powerful function at the edge.
// middleware.ts (at the root of your Next.js project)
import { NextResponse } from "next/server"
import type { NextRequest } from "next/server"
export function middleware(request: NextRequest) {
// Check if the user is trying to access a protected route
const isProtectedRoute = request.nextUrl.pathname.startsWith("/dashboard")
const token = request.cookies.get("session-token")?.value
// Redirect unauthenticated users to login
if (isProtectedRoute && !token) {
const loginUrl = new URL("/login", request.url)
loginUrl.searchParams.set("from", request.nextUrl.pathname)
return NextResponse.redirect(loginUrl)
}
// Add security headers to all responses
const response = NextResponse.next()
response.headers.set("X-Frame-Options", "DENY")
response.headers.set("X-Content-Type-Options", "nosniff")
response.headers.set("Referrer-Policy", "strict-origin-when-cross-origin")
return response
}
// Only run middleware on specific paths
export const config = {
matcher: [
// Match all paths except static files and images
"/((?!_next/static|_next/image|favicon.ico).*)",
],
}- Middleware is a pipeline of checkpoints -- like airport security for your server. Each function does one job, and a request must pass through all of them before reaching your route handler.
- The `next()` function is the gatekeeper: call it and the request proceeds to the next checkpoint; skip it and the pipeline stops dead. This is how auth middleware blocks unauthorized requests.
- Every web server needs a standard checklist: body parsing, CORS, logging, and rate limiting. Middleware lets you write each one once and apply it to every route.
- Auth middleware is your bouncer -- it verifies JWT tokens and attaches user info to the request. Layer it with role-checking middleware for fine-grained access control.
- Next.js middleware runs at the edge in a single `middleware.ts` file, making redirects and auth checks blazing fast by executing close to the user instead of on your main server.
Your auth middleware doesn't call `next()` when the token is invalid. What happens to the request?