React
|stacknotice.com
12 min left|
0%
|2,400 words
React

Next.js Middleware Complete Guide (2026)

Master Next.js middleware for authentication, redirects, A/B testing, rate limiting, and i18n. Covers matcher config, Edge Runtime, Clerk integration, and production patterns.

May 19, 202612 min read
Share:
Next.js Middleware Complete Guide (2026)
What this guide covers

Auth guards, redirects, rewrites, A/B testing, rate limiting, i18n routing, security headers, and geolocation — with real production code for each.

Next.js middleware runs on every request — before the page renders, before the API route executes, before anything. It runs at the Edge, close to the user, with sub-millisecond overhead. And it has access to the full request: headers, cookies, URL, and geolocation.

That combination makes middleware the right place for a set of patterns that are awkward to implement anywhere else: auth guards that work for both pages and API routes, geolocation-based redirects, A/B testing without layout shift, and header injection without touching every single component.

This guide covers middleware from basics to production patterns with real code.

What Middleware Can Do

Middleware runs between the request and the response. It can:

  • Read and modify headers — add auth headers, security headers, CORS
  • Redirect — based on auth state, locale, feature flags
  • Rewrite — transparently serve different pages for the same URL
  • Set and read cookies — for auth sessions, A/B test buckets
  • Return responses early — block requests without hitting your server
  • Access geolocation (Vercel only) — route based on country/region

What it can't do: access the database, use Node.js APIs (no fs, no crypto from Node), or run heavy computation. Middleware is Edge Runtime only — Node.js APIs are unavailable.

Edge Runtime only

No fs, no path, no crypto from Node.js. For JWT verification use jose (Web Crypto API). For DB access, pass data via headers to your Server Components or API routes.

Setup

Create middleware.ts at the root of your project (same level as app/):

middleware.ts
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
 
export function middleware(request: NextRequest) {
  return NextResponse.next()
}

This runs on every request by default. Add a matcher to limit it:

export const config = {
  matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'],
}

The Matcher

The matcher controls which requests trigger middleware. It's a glob or array of globs:

export const config = {
  matcher: [
    // All routes except static files and Next.js internals
    '/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)',
  ],
}

Or specific routes:

export const config = {
  matcher: [
    '/dashboard/:path*',    // dashboard and all sub-routes
    '/api/:path*',          // all API routes
    '/admin/:path*',        // admin section
  ],
}

The /((?!pattern).*) negative lookahead syntax is standard — always exclude static assets or your middleware runs for every image and CSS file.

Authentication Guard

The most common middleware pattern: redirect unauthenticated users to login, redirect logged-in users away from the login page.

middleware.ts
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
 
const PUBLIC_PATHS = ['/', '/login', '/register', '/about', '/blog']
const AUTH_PATHS = ['/login', '/register']
 
export function middleware(request: NextRequest) {
  const { pathname } = request.nextUrl
  const sessionToken = request.cookies.get('session-token')?.value
 
  const isPublic = PUBLIC_PATHS.some(
    (path) => pathname === path || pathname.startsWith(`${path}/`)
  )
 
  // Not authenticated, trying to access protected route
  if (!sessionToken && !isPublic) {
    const loginUrl = new URL('/login', request.url)
    loginUrl.searchParams.set('from', pathname)
    return NextResponse.redirect(loginUrl)
  }
 
  // Already authenticated, trying to access auth pages
  if (sessionToken && AUTH_PATHS.includes(pathname)) {
    return NextResponse.redirect(new URL('/dashboard', request.url))
  }
 
  return NextResponse.next()
}
 
export const config = {
  matcher: ['/((?!_next/static|_next/image|favicon.ico|api/auth).*)'],
}

With JWT Verification

For JWTs you can verify in the Edge Runtime using the jose library (Web Crypto API, no Node.js dependencies):

import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
import { jwtVerify } from 'jose'
 
const JWT_SECRET = new TextEncoder().encode(process.env.JWT_SECRET!)
 
async function verifyToken(token: string) {
  try {
    const { payload } = await jwtVerify(token, JWT_SECRET)
    return payload
  } catch {
    return null
  }
}
 
export async function middleware(request: NextRequest) {
  const token = request.cookies.get('auth-token')?.value
 
  if (!token) {
    return NextResponse.redirect(new URL('/login', request.url))
  }
 
  const payload = await verifyToken(token)
  if (!payload) {
    const response = NextResponse.redirect(new URL('/login', request.url))
    response.cookies.delete('auth-token')
    return response
  }
 
  // Pass user info to downstream handlers via headers
  const requestHeaders = new Headers(request.headers)
  requestHeaders.set('x-user-id', payload.sub as string)
  requestHeaders.set('x-user-role', payload.role as string)
 
  return NextResponse.next({ request: { headers: requestHeaders } })
}
 
export const config = {
  matcher: ['/dashboard/:path*', '/api/protected/:path*'],
}

Then in any Server Component or API route:

import { headers } from 'next/headers'
 
export default async function DashboardPage() {
  const userId = headers().get('x-user-id')
  const role = headers().get('x-user-role')
  // ...
}

Clerk Integration

If you're using Clerk for auth, middleware is where it lives. Clerk's middleware handles token verification, session management, and route protection automatically.

// middleware.ts
import { clerkMiddleware, createRouteMatcher } from '@clerk/nextjs/server'
 
const isPublicRoute = createRouteMatcher([
  '/',
  '/sign-in(.*)',
  '/sign-up(.*)',
  '/blog(.*)',
  '/api/webhook(.*)',
])
 
export default clerkMiddleware(async (auth, request) => {
  if (!isPublicRoute(request)) {
    await auth.protect()
  }
})
 
export const config = {
  matcher: ['/((?!_next/static|_next/image|favicon.ico).*)', '/(api|trpc)(.*)'],
}

The Clerk + Next.js authentication guide covers the full setup including auth() in Server Components and useAuth() in Client Components.

Role-Based Access Control

Protect routes based on user roles:

// middleware.ts
import { clerkMiddleware, createRouteMatcher } from '@clerk/nextjs/server'
 
const isAdminRoute = createRouteMatcher(['/admin(.*)'])
const isPublicRoute = createRouteMatcher(['/', '/sign-in(.*)', '/sign-up(.*)'])
 
export default clerkMiddleware(async (auth, request) => {
  const { userId, sessionClaims } = await auth()
 
  if (!isPublicRoute(request)) {
    if (!userId) {
      return auth.redirectToSignIn()
    }
 
    if (isAdminRoute(request) && sessionClaims?.metadata?.role !== 'admin') {
      return new Response('Forbidden', { status: 403 })
    }
  }
})

Redirects and Rewrites

Conditional Redirect

export function middleware(request: NextRequest) {
  const { pathname } = request.nextUrl
 
  // Redirect old blog URLs to new format
  if (pathname.startsWith('/posts/')) {
    const newPath = pathname.replace('/posts/', '/blog/')
    return NextResponse.redirect(new URL(newPath, request.url), { status: 301 })
  }
 
  // Trailing slash normalization
  if (pathname.endsWith('/') && pathname !== '/') {
    return NextResponse.redirect(new URL(pathname.slice(0, -1), request.url))
  }
 
  return NextResponse.next()
}

Transparent Rewrite

Rewrites serve different content at the same URL — the user never sees the internal path:

export function middleware(request: NextRequest) {
  // Serve maintenance page without changing the URL
  if (process.env.MAINTENANCE_MODE === 'true') {
    return NextResponse.rewrite(new URL('/maintenance', request.url))
  }
 
  // Proxy /docs to external service (same domain, different backend)
  if (request.nextUrl.pathname.startsWith('/docs')) {
    const targetUrl = new URL(request.nextUrl.pathname, 'https://docs.internal.company.com')
    return NextResponse.rewrite(targetUrl)
  }
 
  return NextResponse.next()
}

A/B Testing

Middleware is ideal for A/B testing: assign users to buckets at the edge, serve different versions via rewrite, track the bucket in a cookie. No layout shift, no client-side delay:

// middleware.ts
export function middleware(request: NextRequest) {
  const { pathname } = request.nextUrl
 
  // Only test the landing page
  if (pathname !== '/') return NextResponse.next()
 
  // Check for existing bucket assignment
  let bucket = request.cookies.get('ab-bucket')?.value
 
  if (!bucket) {
    bucket = Math.random() < 0.5 ? 'a' : 'b'
  }
 
  // Rewrite to the correct variant
  const url = request.nextUrl.clone()
  url.pathname = bucket === 'b' ? '/landing-b' : '/'
 
  const response = NextResponse.rewrite(url)
 
  // Persist the bucket assignment for 30 days
  if (!request.cookies.get('ab-bucket')) {
    response.cookies.set('ab-bucket', bucket, {
      maxAge: 60 * 60 * 24 * 30,
      sameSite: 'lax',
    })
  }
 
  return response
}
 
export const config = {
  matcher: ['/'],
}

In your analytics: log the ab-bucket cookie alongside conversion events.

Internationalization (i18n)

Route users to the correct locale based on their browser preferences:

// middleware.ts
import { match } from '@formatjs/intl-localematcher'
import Negotiator from 'negotiator'
 
const LOCALES = ['en', 'es', 'fr', 'de', 'pt']
const DEFAULT_LOCALE = 'en'
 
function getLocale(request: NextRequest): string {
  const negotiatorHeaders: Record<string, string> = {}
  request.headers.forEach((value, key) => { negotiatorHeaders[key] = value })
 
  const languages = new Negotiator({ headers: negotiatorHeaders }).languages()
 
  try {
    return match(languages, LOCALES, DEFAULT_LOCALE)
  } catch {
    return DEFAULT_LOCALE
  }
}
 
export function middleware(request: NextRequest) {
  const { pathname } = request.nextUrl
 
  // Check if pathname already has a locale
  const hasLocale = LOCALES.some(
    (locale) => pathname.startsWith(`/${locale}/`) || pathname === `/${locale}`
  )
 
  if (!hasLocale) {
    const locale = getLocale(request)
    return NextResponse.redirect(new URL(`/${locale}${pathname}`, request.url))
  }
 
  return NextResponse.next()
}
 
export const config = {
  matcher: ['/((?!_next|api|favicon.ico).*)'],
}

With this, /about automatically redirects to /en/about, /es/about, etc., based on the Accept-Language header.

Security Headers

Add security headers to every response without touching individual routes:

// middleware.ts
export function middleware(request: NextRequest) {
  const response = NextResponse.next()
 
  // Prevent clickjacking
  response.headers.set('X-Frame-Options', 'DENY')
 
  // Prevent MIME type sniffing
  response.headers.set('X-Content-Type-Options', 'nosniff')
 
  // Referrer policy
  response.headers.set('Referrer-Policy', 'strict-origin-when-cross-origin')
 
  // Remove server header
  response.headers.delete('Server')
 
  // Content Security Policy (adjust for your needs)
  response.headers.set(
    'Content-Security-Policy',
    [
      "default-src 'self'",
      "script-src 'self' 'unsafe-eval' 'unsafe-inline' https://va.vercel-scripts.com",
      "style-src 'self' 'unsafe-inline'",
      "img-src 'self' data: https:",
      "font-src 'self'",
      "connect-src 'self' https://api.stacknotice.com",
    ].join('; ')
  )
 
  return response
}

Rate Limiting

Basic rate limiting in the Edge Runtime using Vercel's KV (or Upstash Redis via REST API):

// middleware.ts
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
 
const RATE_LIMIT_WINDOW = 60 * 1000 // 1 minute
const MAX_REQUESTS = 20
 
// Using Upstash Redis REST API (works in Edge Runtime)
async function isRateLimited(ip: string): Promise<boolean> {
  const key = `rate_limit:${ip}`
  const now = Date.now()
  const windowStart = now - RATE_LIMIT_WINDOW
 
  const res = await fetch(`${process.env.UPSTASH_REDIS_REST_URL}/pipeline`, {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${process.env.UPSTASH_REDIS_REST_TOKEN}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify([
      ['zadd', key, now, now.toString()],
      ['zremrangebyscore', key, 0, windowStart],
      ['zcard', key],
      ['expire', key, 60],
    ]),
  })
 
  const data = await res.json()
  const count = data[2][1]
  return count > MAX_REQUESTS
}
 
export async function middleware(request: NextRequest) {
  if (request.nextUrl.pathname.startsWith('/api/')) {
    const ip = request.headers.get('x-forwarded-for') ?? '127.0.0.1'
 
    if (await isRateLimited(ip)) {
      return new NextResponse('Too Many Requests', {
        status: 429,
        headers: { 'Retry-After': '60' },
      })
    }
  }
 
  return NextResponse.next()
}

For simpler cases, @upstash/ratelimit provides a clean API wrapper.

Geolocation-Based Routing (Vercel)

On Vercel, request.geo provides country, region, and city:

export function middleware(request: NextRequest) {
  const country = request.geo?.country ?? 'US'
  const { pathname } = request.nextUrl
 
  // Redirect EU users to GDPR-compliant version
  const euCountries = ['DE', 'FR', 'IT', 'ES', 'PL', 'NL', 'BE', 'SE', 'AT', 'CH']
  if (euCountries.includes(country) && !pathname.startsWith('/eu')) {
    return NextResponse.redirect(new URL(`/eu${pathname}`, request.url))
  }
 
  // Block access from certain regions
  if (country === 'BLOCKED_COUNTRY') {
    return new NextResponse('Service not available in your region', { status: 451 })
  }
 
  // Pass country to downstream via header
  const response = NextResponse.next()
  response.headers.set('x-user-country', country)
  return response
}

Combining Multiple Concerns

Real production middleware often handles several things. Keep it readable:

// middleware.ts
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
 
function addSecurityHeaders(response: NextResponse): NextResponse {
  response.headers.set('X-Frame-Options', 'DENY')
  response.headers.set('X-Content-Type-Options', 'nosniff')
  return response
}
 
function handleOldUrls(request: NextRequest): NextResponse | null {
  const { pathname } = request.nextUrl
  if (pathname.startsWith('/posts/')) {
    return NextResponse.redirect(new URL(pathname.replace('/posts/', '/blog/'), request.url), 301)
  }
  return null
}
 
function checkMaintenance(request: NextRequest): NextResponse | null {
  if (process.env.MAINTENANCE === 'true' && !request.nextUrl.pathname.startsWith('/maintenance')) {
    return NextResponse.rewrite(new URL('/maintenance', request.url))
  }
  return null
}
 
export function middleware(request: NextRequest) {
  // Check each concern in order, return early if handled
  const redirect = handleOldUrls(request)
  if (redirect) return addSecurityHeaders(redirect)
 
  const maintenance = checkMaintenance(request)
  if (maintenance) return addSecurityHeaders(maintenance)
 
  return addSecurityHeaders(NextResponse.next())
}
 
export const config = {
  matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'],
}

Debugging Middleware

Middleware runs on the server — check the terminal, not the browser console:

export function middleware(request: NextRequest) {
  console.log('[middleware]', request.method, request.nextUrl.pathname)
 
  const token = request.cookies.get('auth-token')?.value
  console.log('[middleware] has token:', !!token)
 
  return NextResponse.next()
}

In development, this logs to your terminal. On Vercel, these appear in Function Logs in the dashboard.

Performance Considerations

Middleware adds latency. Keep it fast:

  1. Exit early — check the matcher first, return NextResponse.next() as soon as possible for routes that don't need processing
  2. Avoid heavy computation — no crypto, no large JSON parsing
  3. Minimize external calls — each async call adds latency; batch or cache when possible
  4. Use the matcher — running middleware on static assets is wasted work

Typical middleware overhead on Vercel Edge: 0.5–2ms. With an external Redis call: 5–15ms depending on region.

Common mistakes that cost hours

The middleware file must be at the root of the project, not inside app/. Node.js APIs don't work in Edge Runtime. And a matcher that catches static assets runs middleware on every image and font request — expect slow page loads.

Common Mistakes

Wrong: Putting the middleware file in app/

app/middleware.ts    ← doesn't work
middleware.ts        ← correct (root of project)

Wrong: Using Node.js APIs

import { readFileSync } from 'fs'  // ❌ not available in Edge Runtime
import jwt from 'jsonwebtoken'     // ❌ uses Node.js crypto

Right: Use Web Crypto alternatives

import { jwtVerify } from 'jose'   // ✅ Web Crypto API

Wrong: Blocking matcher catching static assets

// This runs middleware for every PNG, CSS, and font file
matcher: ['/:path*']

Right:

matcher: ['/((?!_next/static|_next/image|favicon.ico|.*\\.png$|.*\\.jpg$).*)']

Quick Reference

Use CaseMechanism
Auth redirectNextResponse.redirect()
Transparent rewriteNextResponse.rewrite()
Block requestnew NextResponse('Forbidden', { status: 403 })
Add/modify headersresponse.headers.set()
Set cookieresponse.cookies.set()
Read cookierequest.cookies.get()
Get IPrequest.headers.get('x-forwarded-for')
Get country (Vercel)request.geo?.country

Conclusion

Middleware sits at the best possible place in the request lifecycle: before everything, fast, and global. Once you internalize what it can and can't do (Edge Runtime, no Node.js APIs, no database), it becomes an essential tool.

The most impactful patterns are auth protection (redirect unauthenticated users), security headers (zero-cost security improvement on every response), and URL redirects (permanent 301s without a full page render).

If you're adding auth to your Next.js app, start with the Clerk authentication guide — it handles the middleware setup for you. If you're building the API layer, the App Router complete guide covers Route Handlers, Server Components, and how middleware fits into the full request lifecycle.

#nextjs#middleware#authentication#edge#typescript
Share:

Enjoyed this article?

Join 2,400+ developers getting weekly insights on Claude Code, React, and AI tools.

No spam. Unsubscribe anytime. By subscribing you agree to our Privacy Policy.