Middleware Patterns
Coverage
The discipline of designing Next.js middleware: the one-file-per-project contract (middleware.ts at the root or under src/, single default export), the Edge Runtime constraints that govern what code can and cannot run there, the matcher config that filters which paths trigger middleware, the NextRequest / NextResponse API surface (cookies, geo, IP, headers), the four response shapes (next, rewrite, redirect, direct response), the canonical pattern library (authentication gate, locale routing, A/B testing, security header injection, geo-routing, bot blocking, request-id correlation), the performance discipline that every matched request pays the cost, and the central design rule: middleware is for cross-cutting concerns that apply across many routes — never for per-route business logic.
Philosophy
The Pages Router's request lifecycle was: server hits getServerSideProps, which returns props, which render the page. The App Router added more layers (Server Components, Server Actions, Route Handlers), but kept one thing constant — they all run after the route is resolved.
Middleware runs before. It is the only layer where you can intercept a request without knowing which route it will eventually hit. That makes it the right home for concerns that apply across the entire app or large subsets of it: "every request needs an auth check", "every request needs a request-id header", "every request to /admin/* needs a role check", "every page needs a CSP nonce".
The architectural trade is breadth for power. Middleware:
- Runs on the Edge Runtime — limited APIs, no Node-specific dependencies, no large packages.
- Runs on every matched request — performance ceiling matters because the cost multiplies.
- Has no per-route knowledge until the rewrite/redirect resolves — cannot read route-specific params or query the database for that route's data.
- Cannot read the response body — it sits in front of the response, not over it.
In exchange, it can shape the entire request/response edge in a single place. Done well, it removes ceremony from every route; done badly, it adds latency to every request and concentrates business logic in a file that's hard to test.
The discipline of middleware is to keep it small, fast, and cross-cutting. When a piece of logic only applies to one route, it does not belong here. When it requires a database lookup that adds 50ms, it does not belong here. When the code is hard to reason about, it definitely does not belong here.
The File Contract
// middleware.ts (project root, or src/ if using src layout)
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
export async function middleware(request: NextRequest) {
// ... transform or gate the request ...
return NextResponse.next()
}
export const config = {
matcher: ['/((?!api|_next/static|_next/image|favicon.ico).*)'],
}
- One file per project. There is no chain of middleware files. Compose multiple concerns inside a single
middlewarefunction. - Default export. The named export pattern (
export async function middleware) and the default export both work; pick one. config.matcherfilters which paths trigger middleware. Critical: the default — no matcher — runs on every single request including static assets. Always set a matcher.
Matcher syntax
export const config = {
matcher: [
'/dashboard/:path*', // glob
'/((?!api|_next/static|_next/image|favicon.ico).*)', // negative lookahead — everything except these
{
source: '/api/admin/:path*',
missing: [{ type: 'header', key: 'next-action' }], // exclude Server Action calls
},
],
}
Matchers compile to regular expressions at build time. They cannot use runtime values. Complex negative lookaheads are common because the default-matches-everything behavior is rarely what you want — image fetches, static assets, prefetch requests, and webhook routes should usually be excluded.
The biggest middleware footgun is forgetting to exclude /_next/static and /_next/image, which makes every image fetch run middleware code on the hot path.
The Four Response Shapes
import { NextResponse } from 'next/server'
// 1. Pass through — let the request continue to its route
return NextResponse.next()
// 2. Rewrite — internally route to a different path; URL bar unchanged
return NextResponse.rewrite(new URL('/en/about', request.url))
// 3. Redirect — send a 30x to the browser; URL bar changes
return NextResponse.redirect(new URL('/login', request.url))
// 4. Direct response — short-circuit; return a response without hitting any route
return new NextResponse('Forbidden', { status: 403 })
Choose based on what the user should see and what should change:
| Goal | Use |
|---|---|
| Continue to the originally requested route, possibly with modified headers/cookies | next() (often with .headers.set() on the response) |
| Serve a different route's content under the same URL (A/B test, locale variant, feature flag) | rewrite |
| Send the user to a different URL (login redirect, canonical redirect, locale-detection redirect) | redirect |
| Block the request entirely (rate limit hit, bot blocked, missing auth on protected API) | direct response with appropriate status |
Rewrite vs redirect is a load-bearing distinction: rewrites are invisible to the user (the URL stays the same), redirects are visible (the URL changes and the browser does a second request). If you want the user to see they've been moved (/old-path → /new-path), redirect. If you want to keep their URL and serve different content (A/B variant, internal locale path), rewrite.
Edge Runtime Constraints
Middleware runs on Edge. Things to know:
| Capability | Available |
|---|---|
fetch |
✅ |
Web Crypto (crypto.subtle, crypto.randomUUID) |
✅ |
Web Streams (ReadableStream, TransformStream) |
✅ |
URL, URLSearchParams, Request, Response |
✅ |
setTimeout / setInterval |
⚠️ best-effort, may not fire after response |
Node crypto module |
❌ |
Node fs, child_process, net, dns |
❌ |
| Most npm packages that aren't pure JS | ❌ |
| Large bundle sizes | ❌ (Vercel: ~1MB ceiling on middleware code) |
Middleware code is bundled and shipped to Edge nodes globally. Cold-start is fast (~10–50ms) but the trade is a tight capability surface. Anything you import — including transitive dependencies — must be Edge-compatible. A single import of a Node-only package breaks the build.
Practical consequence: don't reach for ORMs, full SDKs, or complex libraries in middleware. Hand-roll the small piece you need (decode a JWT, hash a token, parse a cookie). If the work genuinely needs Node — verifying a webhook signature with a vendor SDK, hitting a database — push it down into a Route Handler or Server Action instead.
The Canonical Pattern Library
1. Authentication gate
export async function middleware(request: NextRequest) {
const session = request.cookies.get('session')?.value
const { pathname } = request.nextUrl
const isProtected = pathname.startsWith('/dashboard') || pathname.startsWith('/admin')
if (isProtected && !session) {
const url = new URL('/login', request.url)
url.searchParams.set('redirectTo', pathname)
return NextResponse.redirect(url)
}
return NextResponse.next()
}
export const config = {
matcher: ['/dashboard/:path*', '/admin/:path*'],
}
The session cookie is checked, not verified. Cryptographic verification belongs inside the routes — middleware is for fast cookie presence checks. A signed-cookie verification that requires a JWT library is fine if the library is Edge-compatible; a database lookup to validate the session is not — that 50ms hits every protected page load.
2. Locale routing
const LOCALES = ['en', 'es', 'fr', 'de']
const DEFAULT_LOCALE = 'en'
export async function middleware(request: NextRequest) {
const { pathname } = request.nextUrl
const hasLocale = LOCALES.some((l) => pathname.startsWith(`/${l}/`) || pathname === `/${l}`)
if (hasLocale) return NextResponse.next()
const accept = request.headers.get('accept-language') ?? ''
const detected = LOCALES.find((l) => accept.includes(l)) ?? DEFAULT_LOCALE
return NextResponse.redirect(new URL(`/${detected}${pathname}`, request.url))
}
A first-visit user lands on /about, gets redirected to /en/about. Subsequent visits to locale-prefixed paths pass through. The redirect-once pattern keeps the URL canonical and lets the rest of the app assume locale is in the path.
3. A/B testing via rewrite
export async function middleware(request: NextRequest) {
if (request.nextUrl.pathname !== '/pricing') return NextResponse.next()
let variant = request.cookies.get('pricing-variant')?.value
if (!variant) {
variant = Math.random() < 0.5 ? 'a' : 'b'
}
const url = new URL(`/pricing-${variant}`, request.url)
const response = NextResponse.rewrite(url)
response.cookies.set('pricing-variant', variant, { maxAge: 60 * 60 * 24 * 30 })
return response
}
The user sees /pricing in their URL bar but receives /pricing-a or /pricing-b. The cookie pins their variant so subsequent visits are consistent. The rewrite preserves the canonical URL for analytics and sharing.
4. Security header injection (with per-request CSP nonce)
export async function middleware(request: NextRequest) {
const nonce = Buffer.from(crypto.randomUUID()).toString('base64')
const csp = [
`default-src 'self'`,
`script-src 'self' 'nonce-${nonce}' 'strict-dynamic'`,
`style-src 'self' 'nonce-${nonce}'`,
`img-src 'self' blob: data:`,
`font-src 'self'`,
`object-src 'none'`,
`base-uri 'self'`,
`form-action 'self'`,
`frame-ancestors 'none'`,
`upgrade-insecure-requests`,
].join('; ')
const requestHeaders = new Headers(request.headers)
requestHeaders.set('x-nonce', nonce)
requestHeaders.set('content-security-policy', csp)
const response = NextResponse.next({ request: { headers: requestHeaders } })
response.headers.set('content-security-policy', csp)
response.headers.set('x-content-type-options', 'nosniff')
response.headers.set('referrer-policy', 'strict-origin-when-cross-origin')
response.headers.set('permissions-policy', 'camera=(), microphone=(), geolocation=()')
return response
}
The nonce flows to the request headers (so Server Components can read it via headers() and inject it into <script> tags) and to the response headers (so the browser enforces the CSP). The policy itself is just an example; the actual rules belong to the broader security strategy in security-fundamentals.
5. Geo-routing
export async function middleware(request: NextRequest) {
const country = request.geo?.country ?? 'US' // populated by Vercel
if (country === 'GB' && !request.nextUrl.pathname.startsWith('/uk')) {
return NextResponse.redirect(new URL(`/uk${request.nextUrl.pathname}`, request.url))
}
return NextResponse.next()
}
request.geo is populated by Vercel from the request's IP-derived location. On other hosts it may be undefined — read it defensively.
6. Bot blocking
const BLOCKED_AGENTS = [/AhrefsBot/i, /SemrushBot/i, /MJ12bot/i]
export async function middleware(request: NextRequest) {
const ua = request.headers.get('user-agent') ?? ''
if (BLOCKED_AGENTS.some((re) => re.test(ua))) {
return new NextResponse('Forbidden', { status: 403 })
}
return NextResponse.next()
}
UA strings are trivially spoofable — bot blocking via user-agent works for honest crawlers but does not stop adversaries. Use this for noise reduction, not security.
7. Request-ID correlation
export async function middleware(request: NextRequest) {
const requestId = request.headers.get('x-request-id') ?? crypto.randomUUID()
const requestHeaders = new Headers(request.headers)
requestHeaders.set('x-request-id', requestId)
const response = NextResponse.next({ request: { headers: requestHeaders } })
response.headers.set('x-request-id', requestId)
return response
}
The request-id flows through to the route (readable via headers()) and back out to the client (visible in DevTools). Pair with structured logging that includes the id, and you get end-to-end traceability.
Composing Multiple Concerns
There is one middleware.ts. Combine concerns inside it — typically in a clear order:
export async function middleware(request: NextRequest) {
// 1. Block bots first — fast reject
const ua = request.headers.get('user-agent') ?? ''
if (BLOCKED_AGENTS.some((re) => re.test(ua))) {
return new NextResponse('Forbidden', { status: 403 })
}
// 2. Locale detection — redirect once for first-visit users
const localeRedirect = applyLocaleRouting(request)
if (localeRedirect) return localeRedirect
// 3. Auth gate — redirect to login for protected routes
const authRedirect = applyAuthGate(request)
if (authRedirect) return authRedirect
// 4. Pass through with security headers + request-id
return applyHeaders(request)
}
Each helper returns either a short-circuit NextResponse or null (continue). The shape is a small chain of guards; the file stays readable. When the chain grows past ~5 concerns, it's a signal that the middleware is doing too much — push something down to per-route logic or to a separate request-time hook.
Performance Discipline
Every matched request pays the middleware cost. Three rules:
- Tune the matcher. If only
/dashboard/*needs auth, match only/dashboard/*. Don't run auth checks against image fetches. - Cap the time budget. Target <10ms p99 for middleware execution. Slow middleware degrades every page on the site.
- No database calls. A network round-trip in middleware is a tax on every request. Cache aggressively; use signed cookies that carry the data middleware needs without a lookup; defer DB checks to the route.
The performance budget is invisible until you load-test the site and see middleware dominate the latency profile. Build it in from the start.
Common Anti-Patterns
| Anti-pattern | Why it's wrong | Fix |
|---|---|---|
No matcher — middleware runs on /_next/static, /_next/image, /favicon.ico |
Adds latency to every image fetch and static asset | Set a matcher with negative lookahead excluding _next paths and assets |
| Database query in middleware | Network round-trip on every request | Use signed cookies that carry the data, or push the lookup down to the route |
| Putting per-route business logic in middleware | Centralized file that hides the logic from the route that owns it | Move to the route; keep middleware for genuine cross-cutting |
| Importing a Node-only package | Edge build fails | Use Edge-compatible alternatives, or move the work to a Route Handler |
| Verifying a JWT signature against a remote JWKS endpoint without caching | Network call per request | Cache the JWKS in memory; or defer verification to the route |
redirect when rewrite was meant (or vice versa) |
URL changes when it shouldn't, or stays the same when it should | Choose based on whether the user should see the URL change |
| Forgetting to copy headers to the response when modifying request headers | Request-side changes invisible to client | Use NextResponse.next({ request: { headers: ... } }) AND set the same on response.headers if the client needs to see them |
| Setting cookies on the request — middleware can't modify the request cookies the client sees | Cookie set silently lost | Set cookies on the response via response.cookies.set(...) |
| Running middleware on webhook routes that need raw body access | Middleware can consume the body or otherwise interfere with HMAC verification | Exclude webhook paths from the matcher |
| Single 100-line middleware doing 8 different things | Untestable, slow, hard to reason about | Decompose into named helpers; consider whether some concerns belong per-route |
Verification
After applying this skill, verify:
-
config.matcheris set and excludes_next/static,_next/image,favicon.ico, and any other paths that don't need middleware (typically webhooks). - No database queries or other I/O that adds >10ms to the request happen inside middleware.
- All imported packages are Edge-Runtime-compatible (no Node
crypto,fs,child_process,net). - Cookies that need to reach the client are set on the response (
response.cookies.set), not on the request. - Modified request headers use
NextResponse.next({ request: { headers } })so they flow to the route. - The choice between
rewriteandredirectmatches whether the URL should visibly change. - Webhook routes are excluded from the matcher to preserve raw-body access.
- Multiple concerns are decomposed into named helpers; one concern per helper.
- Auth checks are fast cookie/signature checks, not database lookups — deeper verification is deferred to the route.
- Security-header injection (if used) coordinates with the broader security strategy defined in
security-fundamentals.
Grounding Sources
- Next.js docs — Middleware. The canonical reference for the
middleware.tsconvention. - Next.js docs — Matcher config. The path-filtering rules.
- Vercel docs — Edge Runtime API reference. The capability surface middleware runs against.
- MDN — Fetch API: Request and Response. The Web-standard interface underlying
NextRequest/NextResponse. - RFC 9110 — HTTP Semantics. The protocol middleware operates on — methods, status codes, headers.
- OWASP — Secure Headers Project. The canonical reference for the security-header set middleware can inject.
- Vercel — Building a strict CSP with nonces in Next.js. The canonical per-request nonce pattern.
Do NOT Use When
| Instead of this skill | Use | Why |
|---|---|---|
| Per-route HTTP endpoint logic — JSON APIs, webhook handlers, streaming responses | route-handler-design |
Route Handlers own per-route per-method logic; middleware owns cross-cutting preprocessing. |
| Internal mutations triggered from this app's UI | server-actions-design |
Server Actions are the in-app mutation surface; middleware sits upstream of them but does not replace them. |
| Understanding what each HTTP status or method means in the abstract | http-semantics |
http-semantics owns the protocol; this skill owns honoring it in middleware. |
| Designing the full Content Security Policy or the broader hardening discipline | security-fundamentals |
security-fundamentals owns the policy; middleware is one delivery surface. |
| The cross-cutting streaming model (Web Streams, SSE, backpressure) | streaming-architecture |
Middleware can set headers for streamed responses but does not author the streaming logic. |
The serialization/directive mechanics of 'use client' and 'use server' |
client-server-boundary |
Different boundary — that's the bundler component split, not the HTTP request edge. |
| The full webhook reliability story (HMAC verification, idempotency, retries) | webhook-integration |
Webhook routes should generally be excluded from middleware; their handler owns the reliability discipline. |