Chapter 14

Middleware: Authentication, Redirects and A/B Testing

What Middleware Is and Where It Runs

Next.js Middleware is code that executes before a request reaches a page or API route. It runs on the Edge Runtime — a lightweight V8 isolate sandbox that is completely isolated from the Node.js process. This means Middleware cannot use fs, child_process, or any npm package that relies on Node.js native APIs.

Why Edge Runtime? The answer is speed. A traditional Node.js server needs hundreds of milliseconds to load its runtime and modules on a cold start, whereas a V8 isolate starts in single-digit milliseconds. Edge networks like Vercel and Cloudflare Workers deploy the Edge Runtime on dozens of nodes worldwide, allowing Middleware to execute on the node physically closest to the user, dramatically reducing Time to First Byte (TTFB).

The Middleware file must live in the project root (alongside app/), named middleware.ts:

// middleware.ts
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'

export function middleware(request: NextRequest) {
  return NextResponse.next()
}

matcher: Precisely Controlling Which Paths Trigger Middleware

By default, Middleware applies to every route, including static assets. This wastes resources unnecessarily. The matcher config lets you specify exactly which paths Middleware should handle:

export const config = {
  matcher: [
    // All paths except static files and Next.js internals
    '/((?!_next/static|_next/image|favicon.ico|public/).*)',
    // Only protect /dashboard and its children
    '/dashboard/:path*',
    // API routes
    '/api/:path*',
  ],
}

The matcher supports regex syntax — quantifiers like ?, *, and + all work. Path parameters use :param notation, and :path* matches zero or more path segments.

Why Careful matcher Design Matters

Running Middleware on every request adds latency even when the function body does nothing — there is always a fixed overhead. More importantly, static assets under _next/static are meant to be served directly from a CDN or the filesystem. Intercepting them with Middleware bypasses that optimization. In production, always explicitly exclude static asset paths.

The Three Modes of NextResponse

Middleware controls request flow by returning different types of NextResponse:

NextResponse.next() — passes the request through for normal processing. You can modify request headers when passing through, forwarding data to downstream Server Components or Route Handlers:

export function middleware(request: NextRequest) {
  const requestHeaders = new Headers(request.headers)
  requestHeaders.set('x-pathname', request.nextUrl.pathname)
  requestHeaders.set('x-user-country', request.geo?.country ?? 'unknown')

  return NextResponse.next({
    request: { headers: requestHeaders },
  })
}

Reading those headers in a Server Component:

// app/dashboard/page.tsx
import { headers } from 'next/headers'

export default async function DashboardPage() {
  const headersList = await headers()
  const country = headersList.get('x-user-country')
  return <div>Your country: {country}</div>
}

NextResponse.redirect() — sends an HTTP redirect to the client (307 Temporary Redirect by default):

return NextResponse.redirect(new URL('/login', request.url))
// Or a permanent redirect
return NextResponse.redirect(new URL('/new-path', request.url), 301)

NextResponse.rewrite() — silently rewrites the request destination on the server side; the URL remains unchanged in the browser. This is the foundation of A/B testing and multi-tenant routing:

return NextResponse.rewrite(new URL('/variant-b/page', request.url))

JWT Authentication Protection: A Complete Implementation

Authentication is the most common use case for Middleware. Below is a production-grade JWT verification implementation using the Edge-compatible jose library (which does not depend on Node.js's crypto module):

// middleware.ts
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!
)

const PUBLIC_ROUTES = ['/login', '/register', '/api/auth']

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 { pathname } = request.nextUrl

  // Let public routes through immediately
  if (PUBLIC_ROUTES.some(route => pathname.startsWith(route))) {
    return NextResponse.next()
  }

  const token = request.cookies.get('auth-token')?.value

  if (!token) {
    const loginUrl = new URL('/login', request.url)
    loginUrl.searchParams.set('callbackUrl', pathname)
    return NextResponse.redirect(loginUrl)
  }

  const payload = await verifyToken(token)

  if (!payload) {
    // Token is invalid or expired — clear the cookie and redirect
    const response = NextResponse.redirect(
      new URL('/login', request.url)
    )
    response.cookies.delete('auth-token')
    return response
  }

  // Forward user identity to downstream handlers
  const requestHeaders = new Headers(request.headers)
  requestHeaders.set('x-user-id', String(payload.sub))
  requestHeaders.set('x-user-role', String(payload.role))

  return NextResponse.next({ request: { headers: requestHeaders } })
}

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

Why jose Instead of jsonwebtoken

jsonwebtoken depends on Node.js's crypto module, which is unavailable in the Edge Runtime. jose uses the Web Crypto API and is the go-to JWT library for Edge and browser environments. The computational overhead of verifying a JWT is negligible — typically under one millisecond — so it will never become a bottleneck.

Working with Cookies in Middleware

Cookies are the primary mechanism for Middleware to interact with client-side state. NextRequest provides a type-safe cookie API:

// Reading cookies
const theme = request.cookies.get('theme')?.value
const allCookies = request.cookies.getAll()

// Setting cookies on the response
const response = NextResponse.next()
response.cookies.set('theme', 'dark', {
  httpOnly: false,          // Readable by client-side JS
  secure: true,             // HTTPS only
  sameSite: 'lax',
  maxAge: 60 * 60 * 24 * 365, // 1 year
  path: '/',
})

// Deleting a cookie
response.cookies.delete('old-session')

The core requirement of A/B testing is that the same user always sees the same variant (stickiness), while different users are randomly assigned to groups. Middleware is a natural fit for this:

// middleware.ts
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'

type Variant = 'control' | 'treatment'

function assignVariant(): Variant {
  return Math.random() < 0.5 ? 'control' : 'treatment'
}

export function middleware(request: NextRequest) {
  const { pathname } = request.nextUrl

  // Only A/B test the homepage
  if (pathname !== '/') {
    return NextResponse.next()
  }

  let variant = request.cookies.get('ab-variant')?.value as Variant | undefined

  // New user: assign a variant and persist it
  if (!variant || !['control', 'treatment'].includes(variant)) {
    variant = assignVariant()
  }

  // Rewrite traffic to the corresponding variant page
  // /app/ab/control/page.tsx and /app/ab/treatment/page.tsx
  const url = request.nextUrl.clone()
  url.pathname = `/ab/${variant}`

  const response = NextResponse.rewrite(url)

  // Persist the assignment for 30 days
  response.cookies.set('ab-variant', variant, {
    maxAge: 60 * 60 * 24 * 30,
    httpOnly: true,
    sameSite: 'lax',
  })

  return response
}

The file structure for variant pages:

app/
  ab/
    control/
      page.tsx   ← Original version
    treatment/
      page.tsx   ← Experimental version
  page.tsx       ← Never actually reached (intercepted by rewrite)

Collecting Experiment Data

Record exposure events in variant pages and send them to an analytics service:

// app/ab/treatment/page.tsx
import { headers } from 'next/headers'
import { track } from '@/lib/analytics'

export default async function TreatmentPage() {
  // Record exposure server-side (Server Component)
  await track('experiment_exposure', {
    experiment: 'homepage-redesign',
    variant: 'treatment',
  })

  return <NewHomepage />
}

Geolocation-Based Redirects

Platforms like Vercel expose geolocation data through request headers in the Edge Runtime, and NextRequest wraps this in a request.geo object:

export function middleware(request: NextRequest) {
  const country = request.geo?.country
  const { pathname } = request.nextUrl

  // Already on a language-specific path — do nothing
  if (pathname.startsWith('/zh') || pathname.startsWith('/en')) {
    return NextResponse.next()
  }

  if (country === 'CN') {
    return NextResponse.redirect(
      new URL(`/zh${pathname}`, request.url)
    )
  }

  // Default to English
  return NextResponse.redirect(
    new URL(`/en${pathname}`, request.url)
  )
}

Note that request.geo is only populated when deployed on a platform that provides geolocation data (such as Vercel). During local development it is undefined, so always handle that gracefully.

Edge Runtime Limitations and Workarounds

The Edge Runtime's constraints stem from its secure sandbox design. Here are the common issues and solutions:

Limitation Reason Solution
No fs module V8 isolate has no filesystem access Compile file contents into the bundle or fetch via API
No Node.js crypto Use Web Crypto API instead Switch to jose, @noble/ crypto libraries
No child_process Sandbox restriction Not suitable for Middleware execution
128 MB memory limit Multi-tenant shared environment Avoid loading large data structures in Middleware
No persistent connections Stateless execution Do database queries in Server Components

One commonly overlooked trap: connecting to a database inside Middleware to verify a user. Creating a database connection on every single request is extremely expensive. Instead, verify a JWT (no database query needed) or call a lightweight KV store like Upstash Redis.

Composing Multiple Middleware Functions

As business complexity grows, a single Middleware function can become bloated. The recommended pattern is to split responsibilities into independent functions and compose them in sequence in the main Middleware entry point:

// middleware.ts
import type { NextRequest } from 'next/server'
import { NextResponse } from 'next/server'
import { withAuth } from './middlewares/auth'
import { withI18n } from './middlewares/i18n'
import { withRateLimit } from './middlewares/rateLimit'

type MiddlewareFn = (
  request: NextRequest,
  next: () => Promise<NextResponse>
) => Promise<NextResponse>

function chain(middlewares: MiddlewareFn[]) {
  return async (request: NextRequest): Promise<NextResponse> => {
    let index = 0

    async function next(): Promise<NextResponse> {
      if (index >= middlewares.length) {
        return NextResponse.next()
      }
      const current = middlewares[index++]
      return current(request, next)
    }

    return next()
  }
}

export const middleware = chain([
  withRateLimit,
  withAuth,
  withI18n,
])

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

This chain pattern keeps each middleware focused on a single responsibility, making it easy to test and maintain independently. Each middleware can either return early (short-circuit) or call next() to pass control to the next function in the chain.

Summary

Middleware is the frontmost guard in the Next.js request pipeline, executing on the Edge Runtime with minimal latency. It is the right tool for authentication checks, redirects, A/B traffic splitting, and geolocation routing — any logic that requires a fast decision on every request. The key constraint is the Edge Runtime's sandbox: no Node.js APIs, no direct database connections. These constraints push us toward lighter, stateless implementations that actually improve scalability. Mastering precise matcher configuration and understanding when to use each of the three NextResponse modes is the foundation of using Middleware effectively.

Rate this chapter
4.6  / 5  (20 ratings)

💬 Comments