Next.js Middleware patterns for auth, A/B testing, and geo routing
← Back
April 4, 2026React7 min read

Next.js Middleware patterns for auth, A/B testing, and geo routing

Published April 4, 20267 min read

I used Next.js Middleware only for auth guards for the first year. Then I discovered you could implement A/B tests, feature flags, and geo-based content variations at the edge — before a single line of application code runs. The performance gain and code simplification were significant. Here are the patterns I now use in production.

Auth guard (the classic use case)

typescript
// middleware.ts
import { NextRequest, NextResponse } from 'next/server';
import { verifyToken } from '@/lib/jwt';

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

  // Public routes — no auth needed
  const publicRoutes = ['/', '/login', '/signup', '/api/auth'];
  if (publicRoutes.some((route) => pathname.startsWith(route))) {
    return NextResponse.next();
  }

  // Check token
  const token = request.cookies.get('auth_token')?.value;
  if (!token) {
    const loginUrl = new URL('/login', request.url);
    loginUrl.searchParams.set('redirect', pathname);
    return NextResponse.redirect(loginUrl);
  }

  try {
    const payload = verifyToken(token);
    // Pass user info to route handlers 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);
    return NextResponse.redirect(loginUrl);
  }
}

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

A/B testing at the edge

typescript
import { NextRequest, NextResponse } from 'next/server';

const AB_TESTS = {
  'checkout-flow': {
    variants: ['control', 'simplified'],
    weights: [50, 50], // 50/50 split
  },
  'pricing-page': {
    variants: ['annual-first', 'monthly-first'],
    weights: [30, 70],
  },
};

function assignVariant(testName: string): string {
  const test = AB_TESTS[testName as keyof typeof AB_TESTS];
  if (!test) return 'control';

  const rand = Math.random() * 100;
  let cumulative = 0;
  for (let i = 0; i < test.variants.length; i++) {
    cumulative += test.weights[i];
    if (rand < cumulative) return test.variants[i];
  }
  return test.variants[0];
}

export function middleware(request: NextRequest) {
  const response = NextResponse.next();

  // Assign A/B variants for relevant routes
  if (request.nextUrl.pathname === '/checkout') {
    const cookieName = 'ab_checkout';
    let variant = request.cookies.get(cookieName)?.value;

    if (!variant) {
      variant = assignVariant('checkout-flow');
      response.cookies.set(cookieName, variant, { maxAge: 60 * 60 * 24 * 30 });
    }

    // Rewrite to the variant page
    if (variant === 'simplified') {
      return NextResponse.rewrite(
        new URL('/checkout-simplified', request.url)
      );
    }
  }

  return response;
}

Geo-based routing

typescript
export function middleware(request: NextRequest) {
  const country = request.geo?.country ?? 'US';

  // Route Indian users to Indian pricing
  if (country === 'IN' && request.nextUrl.pathname === '/pricing') {
    return NextResponse.rewrite(new URL('/pricing-india', request.url));
  }

  // GDPR consent required for EU users
  const euCountries = ['DE', 'FR', 'IT', 'ES', 'NL', 'PL', 'SE', 'DK'];
  if (euCountries.includes(country)) {
    const consentCookie = request.cookies.get('gdpr_consent');
    if (!consentCookie && !request.nextUrl.pathname.startsWith('/consent')) {
      // Add GDPR banner flag to headers
      const response = NextResponse.next();
      response.headers.set('x-show-gdpr-banner', 'true');
      return response;
    }
  }

  return NextResponse.next();
}

Feature flags via middleware

typescript
// Check feature flags at the edge — gate entire routes
const FEATURE_FLAGS: Record = {
  'new-dashboard': { enabled: false, allowedEmails: ['beta@example.com'] },
  'ai-assistant': { enabled: true },
};

export function middleware(request: NextRequest) {
  const userId = request.cookies.get('user_id')?.value;
  const userEmail = request.cookies.get('user_email')?.value;

  if (request.nextUrl.pathname.startsWith('/dashboard/new')) {
    const flag = FEATURE_FLAGS['new-dashboard'];
    const allowed =
      flag.enabled ||
      (userEmail && flag.allowedEmails?.includes(userEmail));

    if (!allowed) {
      return NextResponse.redirect(new URL('/dashboard', request.url));
    }
  }

  return NextResponse.next();
}

// Pro tip: fetch flags from your feature flag service (LaunchDarkly, etc.)
// Cache the result in edge storage or use a short-lived cookie to avoid
// adding latency to every request

Chaining middleware behaviors

typescript
export function middleware(request: NextRequest) {
  // Auth check
  const authResult = checkAuth(request);
  if (authResult) return authResult; // Redirect to login

  // A/B assignment
  const response = assignABVariants(request);

  // Geo headers
  const country = request.geo?.country;
  if (country) response.headers.set('x-country', country);

  return response;
}

The key insight about middleware: it runs at the edge (close to the user, before hitting your origin server) and it runs before any React rendering. Auth redirects, A/B tests, and geo routing at the edge mean zero latency for users who are redirected — they never even hit your server.

Share this
← All Posts7 min read