Next.js 15 Middleware: Complete Guide to Auth, Rate Limiting, A/B Testing, and Edge Logic
Developer Guide

Next.js 15 Middleware: Complete Guide to Auth, Rate Limiting, A/B Testing, and Edge Logic

The definitive Next.js 15 middleware guide. Auth, rate limiting, A/B testing, geolocation, bot protection, security headers, and request logging — with complete code for the Vercel Edge runtime.

2026-04-19
35 min read
Next.js 15 Middleware: Complete Guide to Auth, Rate Limiting, A/B Testing, and Edge Logic

Next.js 15 Middleware: The Complete Guide#

Middleware is the most underused primitive in Next.js. Teams write one middleware for auth, ship it, and never touch it again. Meanwhile, they bolt rate limiting into API routes one by one, run A/B tests via client-side flicker, and set security headers in fifteen different places.

Middleware does all of that cleanly in one place. This guide covers every pattern production Next.js 15 apps actually need: authentication, rate limiting, A/B testing, geolocation, bot blocking, CSP headers, and structured request logging.

Every pattern below runs in the Vercel Edge Runtime. If you deploy elsewhere, most of it still works, but some patterns (like geolocation via request headers) are Vercel-specific.

The Mental Model#

Middleware is a function that runs before any route handler. It receives a NextRequest and returns a NextResponse. From that response, you can:

  • Rewrite the URL to a different internal path
  • Redirect the user to a different URL
  • Set or modify cookies
  • Set response headers
  • Short-circuit and return a 401, 403, 429 directly

One file, one export, runs on the Edge. The whole system is:

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

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

export const config = {
  matcher: [
    '/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)',
  ],
};

The matcher pattern is important. Middleware runs on every path that matches it. Exclude static assets and image files unless you specifically need to intercept them.

Pattern 1 — Authentication#

The most common pattern and the one worth getting right first.

// src/lib/middleware/auth.ts
import { createServerClient } from '@supabase/ssr';
import { NextResponse, type NextRequest } from 'next/server';

export async function authMiddleware(request: NextRequest) {
  let response = NextResponse.next({ request });

  const supabase = createServerClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
    {
      cookies: {
        getAll: () => request.cookies.getAll(),
        setAll: (cookiesToSet) => {
          cookiesToSet.forEach(({ name, value }) =>
            request.cookies.set(name, value)
          );
          response = NextResponse.next({ request });
          cookiesToSet.forEach(({ name, value, options }) =>
            response.cookies.set(name, value, options)
          );
        },
      },
    }
  );

  const { data: { user } } = await supabase.auth.getUser();

  const { pathname } = request.nextUrl;
  const isProtected = pathname.startsWith('/dashboard') || pathname.startsWith('/settings');

  if (isProtected && !user) {
    const loginUrl = new URL('/login', request.url);
    loginUrl.searchParams.set('next', pathname);
    return NextResponse.redirect(loginUrl);
  }

  return response;
}

The supabase.auth.getUser() call does two things:

  1. Verifies the JWT in the cookie
  2. Refreshes the access token if it has expired (using the refresh token)

Step 2 is why middleware is non-negotiable for Supabase auth in App Router. Without it, access tokens silently expire and users get booted out after an hour.

Pattern 2 — Rate Limiting#

Rate limiting at the middleware layer catches abuse before it reaches your business logic.

Using Upstash Redis#

Upstash works over HTTP, so it is compatible with the Edge Runtime.

// src/lib/middleware/rate-limit.ts
import { Ratelimit } from '@upstash/ratelimit';
import { Redis } from '@upstash/redis';
import { NextResponse, type NextRequest } from 'next/server';

const ratelimit = new Ratelimit({
  redis: Redis.fromEnv(),
  limiter: Ratelimit.slidingWindow(30, '10 s'),
  analytics: true,
});

export async function rateLimitMiddleware(request: NextRequest) {
  const ip = request.headers.get('x-forwarded-for')?.split(',')[0] ?? 'anonymous';
  const { success, limit, remaining, reset } = await ratelimit.limit(ip);

  if (!success) {
    return new NextResponse('Too Many Requests', {
      status: 429,
      headers: {
        'X-RateLimit-Limit': String(limit),
        'X-RateLimit-Remaining': String(remaining),
        'X-RateLimit-Reset': String(reset),
        'Retry-After': String(Math.ceil((reset - Date.now()) / 1000)),
      },
    });
  }

  return null; // signal: no action needed, continue
}

Tiered Rate Limiting by Path#

Different endpoints warrant different limits:

const publicLimiter = new Ratelimit({
  redis: Redis.fromEnv(),
  limiter: Ratelimit.slidingWindow(100, '60 s'),
});

const authLimiter = new Ratelimit({
  redis: Redis.fromEnv(),
  limiter: Ratelimit.slidingWindow(5, '60 s'),
});

export async function tieredRateLimit(request: NextRequest) {
  const ip = request.headers.get('x-forwarded-for')?.split(',')[0] ?? 'anonymous';
  const { pathname } = request.nextUrl;

  const isAuthEndpoint = pathname.startsWith('/api/auth') ||
                         pathname === '/login' ||
                         pathname === '/signup';

  const limiter = isAuthEndpoint ? authLimiter : publicLimiter;
  const { success } = await limiter.limit(`${pathname}:${ip}`);

  if (!success) {
    return new NextResponse('Too Many Requests', { status: 429 });
  }

  return null;
}

Five per minute on auth endpoints stops password-spraying attacks cold. A hundred per minute on public endpoints lets real users browse freely.

Rate Limiting by Authenticated User#

After auth, rate-limit by user ID instead of IP:

const userLimiter = new Ratelimit({
  redis: Redis.fromEnv(),
  limiter: Ratelimit.slidingWindow(1000, '60 s'),
});

// inside middleware, after auth check
if (user) {
  const { success } = await userLimiter.limit(`user:${user.id}`);
  if (!success) return new NextResponse('Too Many Requests', { status: 429 });
}

This prevents a single malicious account from saturating your infrastructure.

Pattern 3 — A/B Testing#

A/B testing in middleware runs server-side with zero client flicker.

// src/lib/middleware/ab-test.ts
import { NextResponse, type NextRequest } from 'next/server';

type Experiment = {
  name: string;
  path: string;
  variants: { name: string; weight: number; rewrite: string }[];
};

const experiments: Experiment[] = [
  {
    name: 'pricing-v2',
    path: '/pricing',
    variants: [
      { name: 'control', weight: 50, rewrite: '/pricing' },
      { name: 'variant-b', weight: 50, rewrite: '/pricing-v2' },
    ],
  },
];

function pickVariant(experiment: Experiment) {
  const roll = Math.random() * 100;
  let cumulative = 0;
  for (const variant of experiment.variants) {
    cumulative += variant.weight;
    if (roll < cumulative) return variant;
  }
  return experiment.variants[0];
}

export function abTestMiddleware(request: NextRequest) {
  const { pathname } = request.nextUrl;
  const experiment = experiments.find((e) => e.path === pathname);
  if (!experiment) return null;

  const cookieName = `exp-${experiment.name}`;
  let variantName = request.cookies.get(cookieName)?.value;

  if (!variantName) {
    const variant = pickVariant(experiment);
    variantName = variant.name;
  }

  const variant = experiment.variants.find((v) => v.name === variantName) ??
                  experiment.variants[0];

  const url = request.nextUrl.clone();
  url.pathname = variant.rewrite;

  const response = NextResponse.rewrite(url);
  response.cookies.set(cookieName, variantName, {
    maxAge: 60 * 60 * 24 * 30, // 30 days
    path: '/',
  });

  return response;
}

Three things this does well:

  1. Sticky assignment: once a user gets a variant, the cookie keeps them there. No flicker, no inconsistency across page loads.
  2. Server-side rewrite: the user sees the experimental page from the first byte. No client-side JavaScript needed.
  3. Weighted selection: you can run 90/10 or 33/33/33 splits by adjusting weights.

To integrate with your analytics, add a Vary: Cookie header and log the variant name in your structured request log.

Feature Flags#

Same mechanism, simpler logic:

const FEATURES = {
  newDashboard: { enabled: true, rollout: 0.1 }, // 10% of users
};

export function featureFlagMiddleware(request: NextRequest, user?: { id: string }) {
  const response = NextResponse.next();
  if (!user) return response;

  for (const [name, config] of Object.entries(FEATURES)) {
    const hash = parseInt(user.id.slice(0, 8), 16) / 0xffffffff;
    const enabled = config.enabled && hash < config.rollout;
    response.headers.set(`X-Feature-${name}`, enabled ? '1' : '0');
  }

  return response;
}

Your server components read the header via headers() and render conditionally.

Pattern 4 — Geolocation-Based Routing#

Vercel ships request geolocation in the x-vercel-ip-country and x-vercel-ip-city headers.

Country-Specific Landing Pages#

export function geoMiddleware(request: NextRequest) {
  const country = request.headers.get('x-vercel-ip-country') ?? 'US';
  const { pathname } = request.nextUrl;

  if (pathname !== '/') return null;

  const regionalPages: Record<string, string> = {
    GB: '/uk',
    DE: '/de',
    FR: '/fr',
    JP: '/jp',
  };

  const rewrite = regionalPages[country];
  if (rewrite) {
    const url = request.nextUrl.clone();
    url.pathname = rewrite;
    return NextResponse.rewrite(url);
  }

  return null;
}

Users see the same URL in their address bar but get locale-appropriate content.

export function gdprMiddleware(request: NextRequest) {
  const country = request.headers.get('x-vercel-ip-country') ?? 'US';
  const gdprCountries = ['AT', 'BE', 'BG', 'HR', 'CY', 'CZ', 'DK', 'EE', 'FI', 'FR', 'DE', 'GR', 'HU', 'IE', 'IT', 'LV', 'LT', 'LU', 'MT', 'NL', 'PL', 'PT', 'RO', 'SK', 'SI', 'ES', 'SE', 'GB', 'IS', 'LI', 'NO'];

  const response = NextResponse.next();
  response.headers.set('X-Requires-GDPR-Consent', gdprCountries.includes(country) ? '1' : '0');
  return response;
}

The server component reads the header and decides whether to render the cookie banner. No client-side geolocation API call required.

Blocking Sanctioned Regions#

const BLOCKED_COUNTRIES = ['XX', 'YY']; // two-letter codes per your legal requirements

export function sanctionsMiddleware(request: NextRequest) {
  const country = request.headers.get('x-vercel-ip-country');
  if (country && BLOCKED_COUNTRIES.includes(country)) {
    return new NextResponse('Service unavailable in your region', { status: 451 });
  }
  return null;
}

HTTP 451 is the specific status code for legally-blocked content. Use it.

Pattern 5 — Bot and Abuse Protection#

Most bot traffic is harmless — search crawlers, uptime monitors, link-preview generators. Some is not.

Blocking Known-Bad User Agents#

const BLOCKED_USER_AGENTS = [
  /AhrefsBot/i,
  /SemrushBot/i,
  /MJ12bot/i,
  // ... add as you encounter noisy scrapers
];

export function botProtection(request: NextRequest) {
  const ua = request.headers.get('user-agent') ?? '';
  if (BLOCKED_USER_AGENTS.some((pattern) => pattern.test(ua))) {
    return new NextResponse('Forbidden', { status: 403 });
  }
  return null;
}

Do not block Googlebot, Bingbot, or YandexBot. Your SEO depends on them.

Challenging Suspicious Requests#

For borderline cases, challenge instead of block:

export function suspiciousRequestCheck(request: NextRequest) {
  const ua = request.headers.get('user-agent') ?? '';
  const hasReferrer = !!request.headers.get('referer');
  const hasAcceptLang = !!request.headers.get('accept-language');

  const suspicious = !ua ||
                     !hasReferrer && request.nextUrl.pathname !== '/' ||
                     !hasAcceptLang;

  if (suspicious) {
    const response = NextResponse.next();
    response.headers.set('X-Challenge-Required', '1');
    return response;
  }

  return null;
}

Your page reads the X-Challenge-Required header and shows a Turnstile or hCaptcha widget. Real users breeze through; bots fail the challenge and never reach the signup form.

Pattern 6 — Security Headers#

Security headers belong in middleware, not in every individual route.

export function securityHeaders(response: NextResponse) {
  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('Permissions-Policy', 'camera=(), microphone=(), geolocation=()');
  response.headers.set('Strict-Transport-Security', 'max-age=31536000; includeSubDomains; preload');

  const nonce = crypto.randomUUID();
  response.headers.set('X-Nonce', nonce);
  response.headers.set(
    'Content-Security-Policy',
    `default-src 'self'; script-src 'self' 'nonce-${nonce}' 'strict-dynamic'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; connect-src 'self' https://*.supabase.co https://vitals.vercel-insights.com; frame-ancestors 'none';`
  );

  return response;
}

Your inline scripts read the nonce via headers() and set nonce={nonce} on the <Script> tags. Browsers reject any script without the correct nonce, which kills a large class of XSS attacks.

Pattern 7 — Structured Request Logging#

Every production app needs structured logs with a request ID you can trace.

export function requestLogger(request: NextRequest) {
  const requestId = crypto.randomUUID();

  const logPayload = {
    requestId,
    method: request.method,
    path: request.nextUrl.pathname,
    ip: request.headers.get('x-forwarded-for')?.split(',')[0],
    ua: request.headers.get('user-agent'),
    country: request.headers.get('x-vercel-ip-country'),
    timestamp: new Date().toISOString(),
  };

  console.log(JSON.stringify(logPayload));

  const response = NextResponse.next();
  response.headers.set('X-Request-ID', requestId);
  return response;
}

Two benefits:

  1. Every log line from this request (middleware, server components, server actions) can include the X-Request-ID for correlation
  2. When a user reports a bug, they can share their request ID and you can find their exact trace in seconds

Pipe the structured log to Datadog, Logtail, or Axiom for search and alerting.

Composing Multiple Patterns in One File#

You only get one middleware.ts. Compose your helpers in order of cheapest to most expensive:

// middleware.ts
import { NextResponse, type NextRequest } from 'next/server';
import { securityHeaders } from '@/lib/middleware/security';
import { sanctionsMiddleware } from '@/lib/middleware/sanctions';
import { botProtection } from '@/lib/middleware/bots';
import { rateLimitMiddleware } from '@/lib/middleware/rate-limit';
import { authMiddleware } from '@/lib/middleware/auth';
import { abTestMiddleware } from '@/lib/middleware/ab-test';
import { requestLogger } from '@/lib/middleware/logger';

export async function middleware(request: NextRequest) {
  // 1. Cheap, header-only checks first — reject fast
  const sanctionsCheck = sanctionsMiddleware(request);
  if (sanctionsCheck) return sanctionsCheck;

  const botCheck = botProtection(request);
  if (botCheck) return botCheck;

  // 2. External service call — rate limit
  const rateCheck = await rateLimitMiddleware(request);
  if (rateCheck) return rateCheck;

  // 3. Auth (expensive, involves Supabase call)
  const authResult = await authMiddleware(request);
  if (authResult.status === 307 || authResult.status === 308) return authResult;

  // 4. A/B test rewrite
  const abResult = abTestMiddleware(request);
  let response = abResult ?? authResult;

  // 5. Always apply
  response = securityHeaders(response);
  requestLogger(request);

  return response;
}

export const config = {
  matcher: [
    '/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)',
  ],
};

Order matters for performance. Reject sanctioned traffic and bots before you pay for Redis calls. Check rate limits before Supabase. Run the A/B test last, because it only applies to specific paths.

Performance Targets#

Middleware runs on every request. Slowness compounds. Targets:

  • p50 < 20ms
  • p95 < 50ms
  • p99 < 100ms

Measure with Vercel's observability dashboard or by logging elapsed time at the end of middleware:

const start = Date.now();
// ... middleware logic
console.log(JSON.stringify({ requestId, elapsedMs: Date.now() - start }));

If you exceed the p95 target, the usual culprits are:

  • Sequential Redis calls (parallelize with Promise.all)
  • Supabase auth round trip (unavoidable but cached — ensure you are using getUser() which uses JWT verification when possible)
  • Too many regex checks on user agents (precompile and limit the list)

Edge Runtime Limitations#

Middleware runs on the Edge Runtime, a subset of Node.js. Things that do not work:

  • Node.js built-in modules (fs, net, child_process) — none available
  • Native database drivers (pg, mysql2) — they need TCP sockets
  • File I/O — no filesystem access
  • Large dependencies — middleware bundles are size-limited

Things that do work:

  • fetch, Request, Response, URL — standard Web APIs
  • crypto.subtle for hashing and signing
  • Most @supabase/* packages (use HTTP, not TCP)
  • @upstash/* packages (HTTP-based)
  • jose for JWT signing and verification

If you need Node.js APIs, the logic belongs in a route handler or server action — not middleware.

Debugging Middleware#

Middleware runs server-side, so console.log goes to the Vercel function logs. Find them at:

Vercel Dashboard → Project → Logs → Filter: middleware

For local debugging, console.log shows up in the terminal running npm run dev. You can also set breakpoints with VS Code's Node.js debugger if you run Next.js with --inspect.

A common bug: "middleware not running." Check:

  1. The file is at the project root (not in src/)
  2. The matcher in config matches your path
  3. You are not in a Next.js version that had a middleware regression (rare, but 13.5.x had issues)

Testing Middleware#

Write unit tests against the middleware function directly:

// middleware.test.ts
import { middleware } from './middleware';

describe('middleware', () => {
  it('redirects unauthenticated users from /dashboard', async () => {
    const request = new Request('https://example.com/dashboard') as any;
    request.cookies = { getAll: () => [], get: () => null };
    request.nextUrl = new URL('https://example.com/dashboard');

    const response = await middleware(request);
    expect(response.status).toBe(307);
    expect(response.headers.get('location')).toContain('/login');
  });
});

Mock external services (Supabase, Redis) at the module level. You are testing routing logic, not the services themselves.

When Not to Use Middleware#

Middleware is the hammer. Not everything is a nail.

Do not put in middleware:

  • Complex business logic that needs a database (use server actions or route handlers)
  • Anything over 100ms (move to a dedicated API route with caching)
  • Logic that only applies to a handful of pages (inline it in those server components instead)

Do put in middleware:

  • Cross-cutting concerns: auth, rate limiting, security headers, logging
  • Request rewrites and redirects
  • A/B test assignment and feature flag evaluation
  • Cheap header-based checks that reject abuse early

Conclusion#

Middleware is the unsung workhorse of a production Next.js app. Auth, rate limiting, A/B testing, security headers, geolocation, observability — they all belong here, composed into one file, running on the Edge.

Build each helper once, wire them into a single middleware.ts, and every new route in your app inherits the whole set automatically. That is the compounding value of a well-designed middleware layer: it protects code you have not written yet.

Frequently Asked Questions

|

Have more questions? Contact us