Next.js middleware sits between the incoming request and your application. It runs before the page renders, before the API route executes, before anything else happens. That makes it the single most powerful interception point in a Next.js application.
But most tutorials stop at "redirect if not logged in." Middleware can do significantly more than that. This guide covers the patterns that actually matter in production: authentication, redirects, geolocation routing, rate limiting, and how middleware fits alongside instrumentation.ts for monitoring.
How Next.js Middleware Works
Middleware is a single file: middleware.ts (or .js) at the root of your project (or inside src/ if you use that convention). It exports a function that receives a NextRequest and must return a NextResponse.
// middleware.ts
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
export function middleware(request: NextRequest) {
// Runs on every matched request
return NextResponse.next()
}
export const config = {
matcher: ['/dashboard/:path*', '/api/:path*'],
}Key characteristics:
- Edge Runtime. Middleware runs on the Edge Runtime, not Node.js. This means no native
fs, no native database drivers, no heavy Node.js libraries. - Runs before everything. Before static files are served, before API routes execute, before pages render.
- Single file. You cannot have multiple middleware files. One file handles all routes, and you use the
matcherconfig or conditional logic inside the function to scope behavior. - Fast by design. Because it runs on the edge, it adds minimal latency (typically under 5ms).
Pattern 1: Authentication Guards
The most common middleware pattern. Instead of checking auth in every page component or API route, centralize it in middleware.
// middleware.ts
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
const PUBLIC_PATHS = ['/', '/login', '/signup', '/api/auth']
export function middleware(request: NextRequest) {
const { pathname } = request.nextUrl
// Allow public paths
if (PUBLIC_PATHS.some(path => pathname.startsWith(path))) {
return NextResponse.next()
}
// Check for session token
const token = request.cookies.get('session-token')?.value
if (!token) {
const loginUrl = new URL('/login', request.url)
loginUrl.searchParams.set('redirect', pathname)
return NextResponse.redirect(loginUrl)
}
// Optionally verify token (JWT decode works on Edge)
try {
const payload = decodeJWT(token) // Your JWT decode function
if (payload.exp < Date.now() / 1000) {
return NextResponse.redirect(new URL('/login', request.url))
}
} catch {
return NextResponse.redirect(new URL('/login', request.url))
}
return NextResponse.next()
}
export const config = {
matcher: ['/dashboard/:path*', '/settings/:path*', '/api/protected/:path*'],
}This approach has a significant advantage over checking auth inside each route handler: a single source of truth. If you add a new protected route, the matcher catches it. No risk of forgetting to add an auth check to a new API endpoint.
Important caveat: JWT verification on the Edge is limited. You can decode the payload and check expiry, but full signature verification requires crypto libraries compatible with the Edge Runtime. Libraries like jose work well for this.
Pattern 2: Geolocation-Based Redirects
When your Next.js app is deployed on Vercel or Cloudflare, the edge provides geolocation data automatically. You can use this for locale redirects, region-specific content, or compliance.
// middleware.ts
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
const LOCALE_MAP: Record<string, string> = {
US: 'en',
GB: 'en',
ES: 'es',
MX: 'es',
AR: 'es',
BR: 'pt',
PT: 'pt',
FR: 'fr',
DE: 'de',
}
export function middleware(request: NextRequest) {
const { pathname } = request.nextUrl
// Skip if already has locale prefix
if (/^\/(en|es|pt|fr|de)\//.test(pathname)) {
return NextResponse.next()
}
// Skip API routes and static files
if (pathname.startsWith('/api/') || pathname.startsWith('/_next/')) {
return NextResponse.next()
}
const country = request.geo?.country || 'US'
const locale = LOCALE_MAP[country] || 'en'
// Don't redirect if already on default locale
if (locale === 'en') {
return NextResponse.next()
}
return NextResponse.redirect(
new URL(`/${locale}${pathname}`, request.url)
)
}The request.geo object includes country, region, city, and latitude/longitude on platforms that support it. On Vercel, this is available out of the box. On self-hosted deployments, you need a reverse proxy that sets the appropriate headers.
Pattern 3: Rate Limiting
Rate limiting in middleware protects your API routes from abuse before the request even reaches your handler. Here's a simple implementation using an in-memory store:
// middleware.ts
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
// In-memory store (works for single instance; use Redis/Upstash for distributed)
const rateLimit = new Map<string, { count: number; resetTime: number }>()
const RATE_LIMIT = 100 // requests
const WINDOW_MS = 60 * 1000 // 1 minute
function getRateLimitInfo(key: string) {
const now = Date.now()
const entry = rateLimit.get(key)
if (!entry || now > entry.resetTime) {
const newEntry = { count: 1, resetTime: now + WINDOW_MS }
rateLimit.set(key, newEntry)
return { allowed: true, remaining: RATE_LIMIT - 1 }
}
entry.count++
if (entry.count > RATE_LIMIT) {
return { allowed: false, remaining: 0 }
}
return { allowed: true, remaining: RATE_LIMIT - entry.count }
}
export function middleware(request: NextRequest) {
if (!request.nextUrl.pathname.startsWith('/api/')) {
return NextResponse.next()
}
const ip = request.headers.get('x-forwarded-for')?.split(',')[0] || 'unknown'
const { allowed, remaining } = getRateLimitInfo(ip)
if (!allowed) {
return NextResponse.json(
{ error: 'Too many requests' },
{
status: 429,
headers: {
'Retry-After': '60',
'X-RateLimit-Limit': RATE_LIMIT.toString(),
'X-RateLimit-Remaining': '0',
},
}
)
}
const response = NextResponse.next()
response.headers.set('X-RateLimit-Limit', RATE_LIMIT.toString())
response.headers.set('X-RateLimit-Remaining', remaining.toString())
return response
}Production note: The in-memory Map works when you have a single server instance. On Vercel (serverless), each function invocation gets its own memory, so the rate limit resets constantly. For distributed rate limiting, use Upstash Rate Limit or a similar edge-compatible Redis solution.
Pattern 4: A/B Testing with Cookies
Middleware can assign users to experiment groups before the page renders, enabling server-side A/B testing without layout shift:
// middleware.ts
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
export function middleware(request: NextRequest) {
const bucket = request.cookies.get('ab-bucket')?.value
if (bucket) {
return NextResponse.next()
}
// Assign to bucket
const newBucket = Math.random() < 0.5 ? 'control' : 'variant'
const response = NextResponse.next()
response.cookies.set('ab-bucket', newBucket, {
maxAge: 60 * 60 * 24 * 30, // 30 days
httpOnly: true,
sameSite: 'lax',
})
return response
}Your page component then reads the cookie to render the appropriate variant. Because the assignment happens in middleware, the page always renders the correct version on the first request. No flash of wrong content.
Pattern 5: Request Logging and Headers
Middleware is a natural place to add request IDs, CORS headers, and security headers:
// middleware.ts
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
import { randomUUID } from 'crypto'
export function middleware(request: NextRequest) {
const requestId = randomUUID()
const response = NextResponse.next()
// Add request ID for tracing
response.headers.set('X-Request-ID', requestId)
// Security headers
response.headers.set('X-Content-Type-Options', 'nosniff')
response.headers.set('X-Frame-Options', 'DENY')
response.headers.set('Referrer-Policy', 'strict-origin-when-cross-origin')
return response
}The X-Request-ID header is particularly useful. When a user reports an issue, they can share the request ID, and you can trace it through your logs to find exactly what happened.
Combining Multiple Patterns
In production, you usually need several of these patterns at once. The key is to structure your middleware as a pipeline:
// middleware.ts
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
export function middleware(request: NextRequest) {
const { pathname } = request.nextUrl
// 1. Rate limiting (API routes only)
if (pathname.startsWith('/api/')) {
const rateLimitResult = checkRateLimit(request)
if (!rateLimitResult.allowed) {
return NextResponse.json({ error: 'Too many requests' }, { status: 429 })
}
}
// 2. Authentication (protected routes)
if (pathname.startsWith('/dashboard') || pathname.startsWith('/api/protected')) {
const token = request.cookies.get('session-token')?.value
if (!token) {
if (pathname.startsWith('/api/')) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
return NextResponse.redirect(new URL('/login', request.url))
}
}
// 3. Geolocation redirect (pages only)
if (!pathname.startsWith('/api/') && !pathname.startsWith('/_next/')) {
const localeRedirect = handleLocaleRedirect(request)
if (localeRedirect) return localeRedirect
}
// 4. Add headers
const response = NextResponse.next()
response.headers.set('X-Request-ID', crypto.randomUUID())
return response
}Order matters. Rate limiting comes first because you want to block abusive traffic before doing any other work. Auth comes next. Redirects last, because they only apply to legitimate, authenticated traffic.
What Middleware Cannot Do
Understanding the limitations is just as important as knowing the patterns:
- No database access with native drivers. The Edge Runtime doesn't support
pg,mysql2, ormongoose. Use HTTP-based clients (Prisma Accelerate, Neon serverless, PlanetScale serverless). - No file system access. You cannot read or write files from middleware.
- No long-running operations. Middleware has execution time limits (typically 25-30 seconds on Vercel, but you should aim for under 100ms).
- No response body reading. You can modify headers and redirect, but you cannot read or modify the response body from downstream handlers.
- No per-route response timing. Middleware runs before the handler. It cannot measure how long your API route actually took to process the request.
That last point matters more than most people realize. Middleware is excellent at gating and routing requests. It is not designed for observability. You know a request arrived, but you do not know how it was handled, how long it took, or whether it succeeded.
Where instrumentation.ts Complements Middleware
This is where instrumentation.ts enters the picture. While middleware operates on the edge before the request, the instrumentation hook runs on the Node.js runtime when the server starts. It is designed specifically for observability: tracing, logging, and monitoring.
// instrumentation.ts
export async function register() {
if (process.env.NEXT_RUNTIME === 'nodejs') {
// Initialize monitoring, tracing, logging
// Runs once at server start
}
}Here's how they complement each other:
| Capability | middleware.ts | instrumentation.ts |
|---|---|---|
| Runtime | Edge | Node.js |
| Runs | Every request | Once at startup |
| Auth checks | Yes | No |
| Redirects | Yes | No |
| Rate limiting | Yes | No |
| Response time tracking | No | Yes |
| Error tracking | No | Yes |
| Database access | Limited (HTTP only) | Full (Node.js) |
Think of middleware as the bouncer at the door and instrumentation as the security cameras inside. You need both for a complete picture.
Monitoring Your Middleware in Production
Once your middleware patterns are in production, you need to know if they are working. Are rate limits too aggressive? Is the auth redirect loop catching users? Are geo-redirects sending people to the wrong locale?
Middleware itself cannot answer these questions because it has no observability built in. You need something running inside the server that tracks what actually happens after middleware does its job.
Nurbak Watch does this using the instrumentation hook. Five lines in instrumentation.ts and it monitors every API route from inside the server, tracking response times, error rates, and status codes. It sends alerts via Slack, email, or WhatsApp in under 10 seconds when something breaks.
// instrumentation.ts
import { initWatch } from '@nurbak/watch'
export function register() {
initWatch({
apiKey: process.env.NURBAK_WATCH_KEY,
})
}At $29/month (free during beta), it is a practical complement to your middleware layer. Middleware handles the "before" logic. Nurbak Watch handles the "what happened after" visibility.

