EmpireUI
Get Pro
← Blog7 min read#nextjs#middleware#edge-runtime

Next.js Middleware: Auth, Redirects, A/B Tests at the Edge

Next.js middleware runs at the edge before your page loads. Here's how to wire up auth guards, geo-redirects, and A/B tests without touching your React components.

Server rack with glowing network cables representing edge computing infrastructure

What Next.js Middleware Actually Is

Honestly, most developers treat middleware like a fancy afterthought — something you bolt on when auth gets complicated. That's backwards. Middleware runs at the edge, before your React tree even starts rendering, which means it's one of the few places you can intercept a request with near-zero latency.

In Next.js 13.1+ (and solidly refined through 14.x and 15.x), the middleware.ts file sits at the root of your project and exports a single middleware function. The runtime is a trimmed-down V8 isolate — no Node.js APIs, no fs, no crypto from Node. You get the Web Fetch API, Request, Response, and NextResponse from Next.js itself.

Why does the runtime restriction matter? Because it forces you to write lean, fast logic. Milliseconds count here. If your middleware is doing 400ms database queries, you're defeating the whole point. Think of it as the bouncer at the door, not the maître d' inside.

Setting Up Your middleware.ts File

The file structure is simple. Drop middleware.ts at the project root (same level as app/ or pages/). Export a middleware function and optionally a config object to define which paths it runs on. Without the config matcher, middleware runs on every single request — including _next/static, images, and favicons. You don't want that.

import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'

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

  // Skip API routes and static files
  if (pathname.startsWith('/api/') || pathname.startsWith('/_next/')) {
    return NextResponse.next()
  }

  return NextResponse.next()
}

export const config = {
  matcher: [
    /*
     * Match all request paths EXCEPT:
     * - _next/static (static files)
     * - _next/image (image optimization)
     * - favicon.ico
     * - public folder files
     */
    '/((?!_next/static|_next/image|favicon.ico|.*\.(?:svg|png|jpg|jpeg|gif|webp)$).*)'
  ]
}

The matcher regex looks intimidating but it's just a negative lookahead. You write this once and forget about it. From this baseline you layer in whatever logic you need.

Auth Guards: Protecting Routes Without a Library

Here's the most common use case: redirect unauthenticated users away from protected routes. The pattern is read a session token from cookies, validate it (or at minimum check it exists), and either continue or redirect to /login.

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 PROTECTED_PATHS = ['/dashboard', '/settings', '/billing']

export async function middleware(request: NextRequest) {
  const { pathname } = request.nextUrl
  const isProtected = PROTECTED_PATHS.some(p => pathname.startsWith(p))

  if (!isProtected) return NextResponse.next()

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

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

  try {
    await jwtVerify(token, JWT_SECRET)
    return NextResponse.next()
  } catch {
    // Token expired or tampered with
    const response = NextResponse.redirect(new URL('/login', request.url))
    response.cookies.delete('session')
    return response
  }
}

Notice we're using jose instead of jsonwebtoken. That's intentional — jose is built for Web Crypto APIs and works in the edge runtime. jsonwebtoken uses Node's crypto module and will throw at build time. This is a common stumbling block.

One thing worth noting: this approach validates the JWT signature but doesn't check a revocation list. If you need that, you'll need a fast key-value store like Upstash Redis with an HTTP API — regular TCP connections aren't available in edge functions.

Geo-Based Redirects and Localization

Next.js middleware exposes geo data on the request object when deployed to Vercel (via request.geo). On other platforms you'll parse headers like CF-IPCountry (Cloudflare) or X-Vercel-IP-Country. Either way, the pattern is the same: read a header, decide where to send the user.

export function middleware(request: NextRequest) {
  // Vercel injects this automatically
  const country = request.geo?.country ??
    request.headers.get('CF-IPCountry') ??
    'US'

  const { pathname } = request.nextUrl

  // Only redirect the root path
  if (pathname === '/') {
    if (country === 'FR' || country === 'BE') {
      return NextResponse.redirect(new URL('/fr', request.url))
    }
    if (country === 'DE' || country === 'AT' || country === 'CH') {
      return NextResponse.redirect(new URL('/de', request.url))
    }
  }

  return NextResponse.next()
}

Keep redirect chains short. Redirecting //fr/fr/home adds two round trips. If you're building a heavily localized app, pair this with Next.js's built-in i18n routing rather than rolling everything yourself. Middleware is good for the initial country detection redirect; next.config.js i18n handles the rest.

You can also use this for feature flags scoped by region — for example, showing a new checkout flow only to US users while the EU version stays stable during an experiment.

A/B Testing at the Edge: The Right Way

A/B testing in middleware works by assigning users a variant via a cookie, then rewriting the URL to a different page path — all without a redirect. The user's URL stays the same. The served content is different. That's the magic of NextResponse.rewrite().

import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'

const EXPERIMENT_COOKIE = 'ab_pricing_v2'
const VARIANTS = ['control', 'treatment'] as const
type Variant = typeof VARIANTS[number]

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

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

  if (pathname !== '/pricing') return NextResponse.next()

  let variant = request.cookies.get(EXPERIMENT_COOKIE)?.value as Variant | undefined

  if (!variant || !VARIANTS.includes(variant)) {
    variant = assignVariant()
  }

  const rewriteUrl = new URL(
    variant === 'treatment' ? '/pricing-v2' : '/pricing',
    request.url
  )

  const response = NextResponse.rewrite(rewriteUrl)

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

  return response
}

Why do this at the edge instead of in a React component? Two reasons. First, there's no layout shift — the user gets the right page immediately, not after a client-side hydration check. Second, search engines see a consistent version of your page since the rewrite is server-side. If you toggle variants in React, you risk Google indexing the wrong content.

The tradeoff is that you need actual separate page files (/pricing and /pricing-v2). You can't conditionally render components from middleware. Plan your file structure before you start. Also worth reading our React performance guide if you want to understand what happens after the rewrite lands — hydration and bundle size still matter.

Chaining Multiple Middleware Concerns

Real apps need auth AND geo redirects AND A/B tests. Next.js only supports one middleware file, so you compose everything inside it. The order matters. Auth should run first — no point geo-redirecting an unauthenticated user to the French version of a dashboard they can't access anyway.

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

  // 1. Skip static assets early
  if (pathname.match(/\.(ico|png|jpg|svg|css|js)$/)) {
    return NextResponse.next()
  }

  // 2. Auth check on protected paths
  const authResult = await checkAuth(request)
  if (authResult) return authResult // returns redirect if unauthed

  // 3. Geo redirect (root only)
  const geoResult = handleGeoRedirect(request)
  if (geoResult) return geoResult

  // 4. A/B test rewrites
  const abResult = handleABTest(request)
  if (abResult) return abResult

  return NextResponse.next()
}

Break each concern into its own function that returns NextResponse | null. If it returns null, move to the next concern. This keeps your middleware readable as it grows. If you're into typed patterns, you'll appreciate how this pairs with TypeScript utility types to make each handler's return type explicit.

Watch your total middleware execution time. Vercel's edge functions have a 1500ms wall clock limit. If you're doing multiple async operations (like two separate token validations), run them in parallel with Promise.all when they're independent.

Headers, CSP, and Response Manipulation

Middleware can modify both request and response headers. This makes it a natural place to inject Content Security Policy headers, set CORS headers for API routes, or pass request metadata to your pages via custom headers.

export function middleware(request: NextRequest) {
  const requestHeaders = new Headers(request.headers)

  // Pass the pathname to server components via header
  requestHeaders.set('x-pathname', request.nextUrl.pathname)

  const response = NextResponse.next({
    request: { headers: requestHeaders }
  })

  // Content Security Policy
  response.headers.set(
    'Content-Security-Policy',
    [
      "default-src 'self'",
      "script-src 'self' 'unsafe-inline' https://cdn.example.com",
      "style-src 'self' 'unsafe-inline'",
      "img-src 'self' data: https://images.unsplash.com",
      "connect-src 'self' https://api.example.com"
    ].join('; ')
  )

  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')

  return response
}

The x-pathname trick is genuinely useful. In server components you can read headers().get('x-pathname') to know the current route without parsing the URL again. It's a clean way to pass edge-computed data downstream without prop drilling or cookies.

For CSP specifically, be careful with 'unsafe-inline' for scripts — it weakens your protection against XSS. The proper solution is nonce-based CSP where you generate a random nonce in middleware, set it in the header, and thread it through your <Script> tags. That's a longer topic, but it's worth doing if you're handling sensitive user data. Also check out how glassmorphism UI patterns and toast notifications handle their inline styles — you'll need to account for those in your CSP allowlist.

Testing and Debugging Middleware Locally

Middleware is notoriously hard to test because the edge runtime isn't Node.js. You can't just import your middleware file into Jest and call it. The best approach is to use Next.js's built-in dev server and write integration tests with Playwright or a lightweight HTTP client that hits localhost:3000.

For unit testing individual helper functions (like assignVariant() or checkAuth()), extract them into separate files that don't import any Next.js-specific APIs. Those files are perfectly testable with Vitest. The middleware file itself just orchestrates calls to those pure functions.

Locally, console.log in middleware prints to your terminal (the Next.js dev server process), not the browser. When deployed on Vercel, you'll find edge function logs in the Functions tab of your project dashboard. Add structured logging with a requestId header — it makes correlating logs across distributed edge nodes much easier when something goes wrong.

One more gotcha: environment variables in middleware must be defined in next.config.js under env or prefixed with NEXT_PUBLIC_. Server-only variables accessed via process.env work during local dev but may behave differently across edge runtimes. Test with next build && next start before assuming your prod behaviour matches dev.

FAQ

Can I use Prisma or a database client directly in Next.js middleware?

No. The edge runtime doesn't support TCP connections, which most database clients require. You'd need an HTTP-based database API (like PlanetScale's HTTP driver, Supabase's REST API, or Upstash Redis) to fetch data from middleware. For anything heavier, do it in a server action or API route instead.

Does middleware run on every request including image optimization and static files?

By default, yes — unless you define a matcher in your config export. Always add a matcher that excludes _next/static, _next/image, and common static file extensions. Running middleware on every image request adds unnecessary latency and can exhaust your edge function invocation limits.

What's the difference between NextResponse.redirect() and NextResponse.rewrite()?

redirect() sends a 307 or 308 HTTP response to the browser, changing the URL in the address bar. rewrite() silently serves a different page without the URL changing — the browser never knows a rewrite happened. Use redirect for auth flows and canonical URLs; use rewrite for A/B tests and internationalisation.

How do I share session data between middleware and server components without hitting a database twice?

Set a custom request header in middleware with the verified session data (e.g., user ID, role), then read it in your server component via headers().get('x-user-id'). Avoid putting sensitive data in headers though — headers can be logged. For sensitive fields, keep them in a cookie that the server component reads directly.

Why is my middleware causing a redirect loop?

Usually because your auth redirect points to /login, but /login also matches your auth guard. Fix this by explicitly excluding auth-related paths from your protected paths list, or by adding /login and /register to the beginning of your middleware's early-return conditions.

Can middleware run on Netlify or AWS Lambda instead of Vercel?

Yes, but with caveats. Netlify Edge Functions support the same Web API standard and Next.js middleware works there. On traditional Lambda (not Lambda@Edge), requests run in a Node.js environment and some edge-specific features like request.geo won't be available. Check your hosting provider's Next.js compatibility docs before relying on edge-specific APIs.

Free components in 40 styles
React & Tailwind, copy-paste ready.
Browse →

Read next

Next.js Edge Runtime: When to Use It and Its LimitationsNext.js Analytics: Measuring Real User Web VitalsLighthouse CI: Automated Performance Checks in GitHub ActionsEdge Runtime in Next.js: Middleware, Edge API Routes and Limits