Next.js Middleware: Auth Guards and Edge Functions (2026)
Next.js Middleware runs at the Edge — before a request reaches a route. It can read and modify request/response headers, rewrite URLs, redirect, and set cookies — all without spinning up a full Node.js server. The key use case is authentication: check a session token centrally in middleware and redirect unauthenticated users to login, rather than checking auth in every route.
Table of Contents
Middleware Basics
Create middleware.ts in the project root (same level as app/):
// middleware.ts
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
export function middleware(request: NextRequest) {
// Runs before every matched request
console.log('Path:', request.nextUrl.pathname)
// Pass through (default behaviour)
return NextResponse.next()
}
// Optional: limit which paths middleware runs on
export const config = {
matcher: '/about/:path*',
}
Edge Runtime: Middleware runs in the Edge Runtime — a lightweight V8 environment. It's faster than Node.js but has no access to the filesystem, Node.js APIs, or most npm packages. Use Web APIs only.
Matcher Config
export const config = {
matcher: [
// Match all paths except static files and _next
'/((?!_next/static|_next/image|favicon.ico|public/).*)',
// Match specific paths
'/dashboard/:path*',
'/admin/:path*',
'/api/:path*',
// Regex — match all except certain extensions
'/((?!.*\\.(?:jpg|jpeg|png|gif|svg|ico|css|js)$).*)',
],
}
Use the missing option to skip middleware when certain headers are absent:
export const config = {
matcher: [
{
source: '/((?!_next/static|_next/image|favicon.ico).*)',
missing: [
{ type: 'header', key: 'next-router-prefetch' },
{ type: 'header', key: 'purpose', value: 'prefetch' },
],
},
],
}
Auth Guard Pattern
// middleware.ts
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
import { verifyJWT } from './lib/auth-edge' // Must be edge-compatible
const PUBLIC_PATHS = ['/', '/login', '/register', '/about', '/api/auth']
const ADMIN_PATHS = ['/admin']
function isPublic(pathname: string) {
return PUBLIC_PATHS.some(p => pathname === p || pathname.startsWith(p + '/'))
}
function isAdmin(pathname: string) {
return ADMIN_PATHS.some(p => pathname.startsWith(p))
}
export async function middleware(request: NextRequest) {
const { pathname } = request.nextUrl
if (isPublic(pathname)) return NextResponse.next()
// Read session token
const token = request.cookies.get('token')?.value
?? request.headers.get('authorization')?.replace('Bearer ', '')
if (!token) {
const loginUrl = new URL('/login', request.url)
loginUrl.searchParams.set('callbackUrl', pathname)
return NextResponse.redirect(loginUrl)
}
try {
const payload = await verifyJWT(token)
// Admin route check
if (isAdmin(pathname) && payload.role !== 'admin') {
return NextResponse.redirect(new URL('/unauthorized', request.url))
}
// Forward user info to route via header
const response = NextResponse.next()
response.headers.set('x-user-id', payload.sub)
response.headers.set('x-user-role', payload.role)
return response
} catch {
const loginUrl = new URL('/login', request.url)
loginUrl.searchParams.set('callbackUrl', pathname)
const response = NextResponse.redirect(loginUrl)
response.cookies.delete('token') // Clear invalid token
return response
}
}
export const config = {
matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'],
}
Redirects and Rewrites
export function middleware(request: NextRequest) {
const { pathname } = request.nextUrl
// Permanent redirect (308)
if (pathname.startsWith('/old-blog')) {
return NextResponse.redirect(
new URL(pathname.replace('/old-blog', '/blog'), request.url),
{ status: 308 }
)
}
// Rewrite — URL stays the same, content from different path
if (pathname.startsWith('/products')) {
const url = request.nextUrl.clone()
url.pathname = '/shop' + pathname.slice('/products'.length)
return NextResponse.rewrite(url)
}
// Locale redirect
const locale = request.cookies.get('locale')?.value ?? 'en'
if (!pathname.startsWith(`/${locale}`)) {
return NextResponse.redirect(new URL(`/${locale}${pathname}`, request.url))
}
return NextResponse.next()
}
Request and Response Headers
export function middleware(request: NextRequest) {
const response = NextResponse.next({
request: {
// Add headers to the request (visible in Server Components via headers())
headers: new Headers({
...Object.fromEntries(request.headers),
'x-pathname': request.nextUrl.pathname,
'x-request-id': crypto.randomUUID(),
}),
},
})
// Add headers to the response (visible to the browser)
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')
response.headers.set(
'Content-Security-Policy',
"default-src 'self'; script-src 'self' 'unsafe-inline'"
)
return response
}
Geolocation and Edge Data
import { geolocation, ipAddress } from '@vercel/functions'
export function middleware(request: NextRequest) {
// Vercel provides geo data automatically
const { country, city, region } = geolocation(request)
const ip = ipAddress(request)
// Redirect users from restricted countries
if (country === 'XX') {
return NextResponse.redirect(new URL('/geo-restricted', request.url))
}
// Pass geo data to pages
const response = NextResponse.next()
response.headers.set('x-user-country', country ?? 'unknown')
response.headers.set('x-user-city', city ?? 'unknown')
return response
}
A/B Testing
export function middleware(request: NextRequest) {
// Get or assign bucket
let bucket = request.cookies.get('ab-bucket')?.value
if (!bucket) {
bucket = Math.random() < 0.5 ? 'a' : 'b'
}
// Rewrite to variant page
const url = request.nextUrl.clone()
if (request.nextUrl.pathname === '/landing') {
url.pathname = bucket === 'b' ? '/landing-b' : '/landing'
}
const response = NextResponse.rewrite(url)
// Persist bucket in cookie
if (!request.cookies.get('ab-bucket')) {
response.cookies.set('ab-bucket', bucket, {
maxAge: 60 * 60 * 24 * 30, // 30 days
httpOnly: true,
})
}
return response
}
Rate Limiting at the Edge
// Simple in-memory rate limiting (use Upstash Redis for production)
import { Ratelimit } from '@upstash/ratelimit'
import { Redis } from '@upstash/redis'
const ratelimit = new Ratelimit({
redis: Redis.fromEnv(),
limiter: Ratelimit.slidingWindow(10, '10 s'), // 10 requests per 10 seconds
})
export async function middleware(request: NextRequest) {
if (request.nextUrl.pathname.startsWith('/api/')) {
const ip = request.headers.get('x-forwarded-for') ?? '127.0.0.1'
const { success, limit, remaining, reset } = await ratelimit.limit(ip)
if (!success) {
return NextResponse.json(
{ error: 'Too Many Requests' },
{
status: 429,
headers: {
'X-RateLimit-Limit': String(limit),
'X-RateLimit-Remaining': String(remaining),
'X-RateLimit-Reset': String(reset),
},
}
)
}
}
return NextResponse.next()
}
Edge Runtime Limitations
Middleware runs in the Edge Runtime — not full Node.js. Constraints to know:
- No
fs,path,child_processor other Node.js built-ins - No native npm packages (those using Node.js APIs)
- JWT verification must use Web Crypto API (
joselibrary works,jsonwebtokendoes not) - No database connections — use HTTP-based edge-compatible services (Upstash, PlanetScale HTTP driver)
- Max 1MB bundle size per middleware file
- Max 25MB response size
// Edge-compatible JWT verification with jose
import { jwtVerify } from 'jose'
const secret = new TextEncoder().encode(process.env.JWT_SECRET)
export async function verifyJWT(token: string) {
const { payload } = await jwtVerify(token, secret)
return payload
}