Next.js Middleware runs at the edge before your page loads. In this guide, learn how to handle authentication, rate limiting, and A/B testing all in a single middleware.ts file — with zero performance penalty.

Next.js Middleware is one of the most underused features in the entire framework. Most teams only use it for one thing — auth redirects. Then they wonder why their app has performance problems, inconsistent A/B test splits, or rate limiting that doesn’t actually work.
The reality: Middleware runs at the edge, before your page even renders, on every request. That makes it the perfect place to handle cross-cutting concerns that would otherwise require multiple roundtrips, server-side logic duplication, or third-party services.
In this guide we’re building a single middleware.ts that handles auth verification, smart rate limiting, and sticky A/B test assignment — all in one place, all at the edge.
What We’re Building

Order matters. Rate limiting first — no point doing auth work on a bot. Auth second — no point assigning A/B tests to unauthenticated users.
The Complete Middleware
// middleware.ts
import { NextRequest, NextResponse } from 'next/server';
import { checkRateLimit } from './lib/middleware/rateLimit';
import { verifyAuth } from './lib/middleware/auth';
import { assignABVariant } from './lib/middleware/abTest';
// Routes that skip auth entirely
const PUBLIC_ROUTES = new Set([
'/login',
'/register',
'/forgot-password',
'/',
'/pricing',
'/blog',
]);
// Routes that skip rate limiting (internal/webhook)
const SKIP_RATE_LIMIT = new Set([
'/api/webhooks',
'/api/health',
]);
// Routes with A/B tests running
const AB_TEST_ROUTES = new Set([
'/pricing',
'/register',
'/dashboard',
]);
export async function middleware(req: NextRequest) {
const { pathname } = req.nextUrl;
const response = NextResponse.next();// ─── 1. RATE LIMITING ───────────────────────────────────
if (!SKIP_RATE_LIMIT.has(pathname)) {
const rateLimitResult = await checkRateLimit(req);
if (!rateLimitResult.allowed) {
return new NextResponse('Too Many Requests', {
status: 429,
headers: {
'Retry-After': rateLimitResult.retryAfter.toString(),
'X-RateLimit-Limit': rateLimitResult.limit.toString(),
'X-RateLimit-Remaining': '0',
'X-RateLimit-Reset': rateLimitResult.resetAt.toString(),
},
});
}
// Add rate limit headers to successful responses too
response.headers.set('X-RateLimit-Remaining', rateLimitResult.remaining.toString());
}
// ─── 2. AUTH ────────────────────────────────────────────
const isPublic = PUBLIC_ROUTES.has(pathname) ||
pathname.startsWith('/blog/') ||
pathname.startsWith('/_next/') ||
pathname.startsWith('/api/auth/');
if (!isPublic) {
const authResult = await verifyAuth(req);
if (!authResult.authenticated) {
const loginUrl = new URL('/login', req.url);
loginUrl.searchParams.set('from', pathname);
return NextResponse.redirect(loginUrl);
}
// Forward user context to pages via headers
response.headers.set('X-User-Id', authResult.userId!);
response.headers.set('X-User-Role', authResult.role!);
}// ─── 3. A/B TESTING ─────────────────────────────────────
if (AB_TEST_ROUTES.has(pathname)) {
const variant = assignABVariant(req, pathname);
response.cookies.set(`ab_${pathname.replace('/', '')}`, variant, {
maxAge: 60 * 60 * 24 * 30, // 30 days sticky
httpOnly: false, // Readable by analytics
sameSite: 'lax',
});
// Tell the app which variant to render
response.headers.set('X-AB-Variant', variant);
}
return response;
}
export const config = {
matcher: [
'/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)',
],
};Building the Rate Limiter

The challenge with edge rate limiting: you need persistent state across edge nodes. We use Upstash Redis — serverless, globally distributed, edge-compatible.
// lib/middleware/rateLimit.ts
import { Ratelimit } from '@upstash/ratelimit';
import { Redis } from '@upstash/redis';
import { NextRequest } from 'next/server';
const redis = new Redis({
url: process.env.UPSTASH_REDIS_URL!,
token: process.env.UPSTASH_REDIS_TOKEN!,
});// Different limits for different route types
const limiters = {
// API routes — stricter
api: new Ratelimit({
redis,
limiter: Ratelimit.slidingWindow(60, '1 m'), // 60 req/min
analytics: true,
}),
// Auth routes — very strict (prevent brute force)
auth: new Ratelimit({
redis,
limiter: Ratelimit.slidingWindow(5, '1 m'), // 5 req/min
analytics: true,
}),
// General pages
default: new Ratelimit({
redis,
limiter: Ratelimit.slidingWindow(200, '1 m'), // 200 req/min
analytics: true,
}),
};
function getLimiter(pathname: string) {
if (pathname.startsWith('/api/auth')) return limiters.auth;
if (pathname.startsWith('/api')) return limiters.api;
return limiters.default;
}function getIdentifier(req: NextRequest): string {
// Use real IP, fall through to forwarded IP
return (
req.ip ||
req.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ||
req.headers.get('x-real-ip') ||
'anonymous'
);
}export interface RateLimitResult {
allowed: boolean;
remaining: number;
limit: number;
retryAfter: number;
resetAt: number;
}export async function checkRateLimit(req: NextRequest): Promise<RateLimitResult> {
const identifier = getIdentifier(req);
const limiter = getLimiter(req.nextUrl.pathname); const { success, limit, remaining, reset } = await limiter.limit(identifier);
const now = Date.now(); return {
allowed: success,
remaining,
limit,
retryAfter: Math.ceil((reset - now) / 1000),
resetAt: reset,
};
}Building the Auth Verifier
Edge-compatible JWT verification using jose (no Node.js crypto module):
// lib/middleware/auth.ts
import { jwtVerify } from 'jose';
import { NextRequest } from 'next/server';
const ACCESS_SECRET = new TextEncoder().encode(
process.env.ACCESS_TOKEN_SECRET!
);
export interface AuthResult {
authenticated: boolean;
userId?: string;
email?: string;
role?: string;
}export async function verifyAuth(req: NextRequest): Promise<AuthResult> {
const token = req.cookies.get('access_token')?.value; if (!token) return { authenticated: false }; try {
const { payload } = await jwtVerify(token, ACCESS_SECRET); return {
authenticated: true,
userId: payload.userId as string,
email: payload.email as string,
role: payload.role as string,
};
} catch {
// Token expired or invalid
return { authenticated: false };
}
}Why not call your database here? Because middleware runs on every single request — even static assets. Keeping it to JWT verification (CPU only, no I/O) keeps it sub-millisecond.
Building the A/B Test Assigner
The key to good A/B testing is stickiness — once a user is in variant A, they stay in variant A across sessions and devices.
// lib/middleware/abTest.ts
import { NextRequest } from 'next/server';
import { createHash } from 'crypto';
interface ABTest {
variants: string[];
weights: number[]; // Must sum to 1
}// Define your active tests here
const ACTIVE_TESTS: Record<string, ABTest> = {
pricing: {
variants: ['control', 'annual-highlight', 'per-seat'],
weights: [0.34, 0.33, 0.33],
},
register: {
variants: ['control', 'social-proof'],
weights: [0.5, 0.5],
},
dashboard: {
variants: ['control', 'new-nav'],
weights: [0.8, 0.2], // 20% rollout
},
};
function deterministicVariant(userId: string, testName: string, weights: number[]): number {
// Hash user+test for consistent assignment
const hash = createHash('md5')
.update(`${userId}:${testName}`)
.digest('hex');
const bucket = parseInt(hash.slice(0, 8), 16) / 0xFFFFFFFF; // 0–1
let cumulative = 0;
for (let i = 0; i < weights.length; i++) {
cumulative += weights[i];
if (bucket < cumulative) return i;
}
return weights.length - 1;
}export function assignABVariant(req: NextRequest, pathname: string): string {
const testName = pathname.replace('/', '') || 'home';
const test = ACTIVE_TESTS[testName];
if (!test) return 'control';
// Check if user already has a variant assigned (sticky)
const existingVariant = req.cookies.get(`ab_${testName}`)?.value;
if (existingVariant && test.variants.includes(existingVariant)) {
return existingVariant;
}
// Use user ID if authenticated, otherwise use a stable anonymous ID
const userId =
req.cookies.get('user_id')?.value ||
req.headers.get('x-forwarded-for') ||
req.ip ||
Math.random().toString();
const variantIndex = deterministicVariant(userId, testName, test.weights);
return test.variants[variantIndex];
}The deterministic hash ensures that the same user always gets the same variant — even before they log in, even across devices on the same IP.
Reading Variant in Your Pages
// app/pricing/page.tsx
import { headers, cookies } from 'next/headers';
export default async function PricingPage() {
// Read the variant set by middleware
const variant = (await cookies()).get('ab_pricing')?.value || 'control';return (
<main>
{variant === 'annual-highlight' && <AnnualHighlightPricing />}
{variant === 'per-seat' && <PerSeatPricing />}
{variant === 'control' && <DefaultPricing />}
</main>
);
}
Tracking A/B Results
// lib/analytics/abTracking.ts
export async function trackVariantView(
testName: string,
variant: string,
userId: string
) {
// Send to your analytics (PostHog, Amplitude, etc.)
await fetch('/api/analytics/ab', {
method: 'POST',
body: JSON.stringify({ testName, variant, userId, event: 'view' }),
});
}
export async function trackConversion(
testName: string,
variant: string,
userId: string
) {
await fetch('/api/analytics/ab', {
method: 'POST',
body: JSON.stringify({ testName, variant, userId, event: 'conversion' }),
});
}
Performance Impact

Middleware runs at the edge — typically Vercel Edge Network or Cloudflare Workers. Here’s what to expect:
OperationTypical LatencyJWT verification (CPU only)0.5–2msRedis rate limit check2–8msA/B assignment (CPU only)<1msTotal middleware overhead~5–12ms
Compare this to the alternative: separate auth service call (50–200ms), separate rate limit service (20–50ms), client-side A/B with flash of wrong content (visible to user). Middleware wins by a lot.
Wrapping Up
One middleware.ts file, three problems solved at the edge before your app code runs:
- Rate limiting protects your API and auth routes from abuse
- Auth verification keeps unauthenticated users out without database roundtrips
- A/B test assignment is sticky, deterministic, and flash-free
The edge is where this logic belongs. Stop duplicating it across every page and API route.
What else do you use Next.js middleware for? I’d love to hear unconventional use cases in the comments.
I’m a Full-Stack Developer specializing in React, Next.js, and Node.js.
👉 Connect with me: https://www.linkedin.com/in/chiragmehta900/
