Next.js Middleware: Edge Auth Guards, A/B Testing, Geolocation Rewrites, and Request Inspection
Build production Next.js middleware: JWT auth guards at the edge, A/B testing with cookie assignment, geolocation-based content rewrites, bot detection, and rate limiting without hitting your origin server.
Next.js middleware runs at the edge โ before your page renders, before your API route executes, before the request even reaches your origin server. That makes it the right place for auth checks, geolocation rewrites, A/B test assignment, bot filtering, and rate limiting: work that applies to every request and needs to be fast. A middleware auth check adds ~2ms at the edge instead of a round-trip to your database.
This post covers production middleware patterns: JWT verification without a DB call, sticky A/B test assignment via cookies, geolocation-based content routing, bot detection heuristics, and composing multiple middleware concerns cleanly.
How Middleware Works
Browser request โ Cloudflare/Vercel Edge Network
โ
โผ
middleware.ts (runs at edge, ~1โ5ms)
โโโ redirect / rewrite / next()
โโโ modify request headers โ origin
โ
โผ
App Router page / API route
Middleware runs before the cache โ so returned responses from middleware are not cached by default. Use next() to let the cache serve cached pages normally.
1. File and Matcher Configuration
// middleware.ts (must be at project root, not inside /app or /src/app)
import { NextRequest, NextResponse } from 'next/server';
export const config = {
matcher: [
// Match everything except static files and Next.js internals
'/((?!_next/static|_next/image|favicon.ico|images/|fonts/).*)',
// Or be explicit about what you DO match:
// '/dashboard/:path*',
// '/api/:path*',
// '/(en|de|fr)/:path*',
],
};
export async function middleware(req: NextRequest): Promise<NextResponse> {
// Compose multiple middleware concerns
return compose(req, [
authMiddleware,
abTestMiddleware,
geolocationMiddleware,
botDetectionMiddleware,
]);
}
type MiddlewareFn = (req: NextRequest) => NextResponse | null;
function compose(req: NextRequest, fns: MiddlewareFn[]): NextResponse {
for (const fn of fns) {
const result = fn(req);
if (result) return result; // Short-circuit on redirect/block
}
return NextResponse.next();
}
๐ Looking for a Dev Team That Actually Delivers?
Most agencies sell you a project manager and assign juniors. Viprasol is different โ senior engineers only, direct Slack access, and a 5.0โ Upwork record across 100+ projects.
- React, Next.js, Node.js, TypeScript โ production-grade stack
- Fixed-price contracts โ no surprise invoices
- Full source code ownership from day one
- 90-day post-launch support included
2. JWT Auth Guard
// src/middleware/auth.ts
import { NextRequest, NextResponse } from 'next/server';
import { jwtVerify, type JWTPayload } from 'jose';
// Public paths that don't require auth
const PUBLIC_PATHS = new Set([
'/',
'/login',
'/register',
'/forgot-password',
'/blog',
'/pricing',
'/about',
]);
const PUBLIC_PREFIXES = ['/blog/', '/api/auth/', '/api/webhooks/'];
export function authMiddleware(req: NextRequest): NextResponse | null {
const { pathname } = req.nextUrl;
// Skip auth for public paths
if (PUBLIC_PATHS.has(pathname)) return null;
if (PUBLIC_PREFIXES.some((p) => pathname.startsWith(p))) return null;
const token = req.cookies.get('access_token')?.value
?? req.headers.get('authorization')?.replace('Bearer ', '');
if (!token) {
// API routes: return 401
if (pathname.startsWith('/api/')) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
// Pages: redirect to login
const loginUrl = new URL('/login', req.url);
loginUrl.searchParams.set('callbackUrl', pathname);
return NextResponse.redirect(loginUrl);
}
// Verify JWT at the edge (no DB call โ stateless)
const payload = verifyTokenSync(token);
if (!payload) {
const loginUrl = new URL('/login', req.url);
return NextResponse.redirect(loginUrl);
}
// Pass user identity to the origin via headers
const response = NextResponse.next();
response.headers.set('x-user-id', payload.sub ?? '');
response.headers.set('x-tenant-id', payload.tenantId as string ?? '');
response.headers.set('x-user-roles', (payload.roles as string[])?.join(',') ?? '');
return response;
}
// Edge-compatible JWT verification (no Node.js crypto โ uses Web Crypto API)
function verifyTokenSync(token: string): JWTPayload | null {
// Note: jwtVerify is async โ in real middleware, use a cached public key
// and verify synchronously, or use a lightweight edge-compatible library
try {
// For HS256: decode and verify HMAC signature
const [headerB64, payloadB64, sigB64] = token.split('.');
if (!headerB64 || !payloadB64 || !sigB64) return null;
const payload = JSON.parse(atob(payloadB64.replace(/-/g, '+').replace(/_/g, '/')));
// Check expiry
if (payload.exp && payload.exp < Math.floor(Date.now() / 1000)) return null;
// In production: verify HMAC signature using Web Crypto API (async)
// For now return decoded payload โ signature verified in API routes
return payload as JWTPayload;
} catch {
return null;
}
}
Async JWT Verification with jose
// For RS256 (asymmetric) โ fetch public key once and cache
import { jwtVerify, createRemoteJWKSet } from 'jose';
const JWKS = createRemoteJWKSet(
new URL(`${process.env.NEXT_PUBLIC_API_URL}/api/auth/jwks`)
);
export async function verifyJWT(token: string): Promise<JWTPayload | null> {
try {
const { payload } = await jwtVerify(token, JWKS, {
issuer: 'https://viprasol.com',
audience: 'viprasol-app',
});
return payload;
} catch {
return null;
}
}
3. A/B Testing with Sticky Assignment
// src/middleware/ab-test.ts
import { NextRequest, NextResponse } from 'next/server';
interface ABTest {
name: string;
variants: string[];
weight: number[]; // Must sum to 1.0
paths: string[]; // Paths where this test applies
}
const ACTIVE_TESTS: ABTest[] = [
{
name: 'pricing-layout',
variants: ['control', 'variant-a', 'variant-b'],
weight: [0.5, 0.25, 0.25],
paths: ['/pricing'],
},
{
name: 'cta-copy',
variants: ['control', 'variant-a'],
weight: [0.5, 0.5],
paths: ['/'],
},
];
export function abTestMiddleware(req: NextRequest): NextResponse | null {
const { pathname } = req.nextUrl;
const response = NextResponse.next();
for (const test of ACTIVE_TESTS) {
if (!test.paths.some((p) => pathname === p || pathname.startsWith(p + '/'))) {
continue;
}
const cookieName = `ab_${test.name}`;
let variant = req.cookies.get(cookieName)?.value;
// Assign variant if not already assigned
if (!variant || !test.variants.includes(variant)) {
variant = assignVariant(test.weight, test.variants);
// Sticky: set cookie for 30 days
response.cookies.set(cookieName, variant, {
maxAge: 60 * 60 * 24 * 30,
httpOnly: false, // Readable by client-side analytics
sameSite: 'lax',
secure: process.env.NODE_ENV === 'production',
});
}
// Tell the origin which variant this user is in
response.headers.set(`x-ab-${test.name}`, variant);
// Rewrite to variant-specific page if it exists
// e.g., /pricing โ /_ab/pricing/variant-a (internal route)
if (variant !== 'control') {
const variantUrl = req.nextUrl.clone();
variantUrl.pathname = `/_ab${pathname}/${variant}`;
// Only rewrite if variant page exists โ check via headers or static manifest
}
}
return null; // Let compose() call NextResponse.next() with modified headers
}
function assignVariant(weights: number[], variants: string[]): string {
const rand = Math.random();
let cumulative = 0;
for (let i = 0; i < weights.length; i++) {
cumulative += weights[i];
if (rand < cumulative) return variants[i];
}
return variants[variants.length - 1];
}
๐ Senior Engineers. No Junior Handoffs. Ever.
You get the senior developer, not a project manager who relays your requirements to someone you never meet. Every Viprasol project has a senior lead from kickoff to launch.
- MVPs in 4โ8 weeks, full platforms in 3โ5 months
- Lighthouse 90+ performance scores standard
- Works across US, UK, AU timezones
- Free 30-min architecture review, no commitment
4. Geolocation-Based Routing
// src/middleware/geolocation.ts
import { NextRequest, NextResponse } from 'next/server';
// Vercel provides geo data; on other platforms use Cloudflare headers
function getCountry(req: NextRequest): string | undefined {
return (
req.headers.get('x-vercel-ip-country') ?? // Vercel
req.headers.get('cf-ipcountry') ?? // Cloudflare
req.geo?.country // Vercel geo object
);
}
const EU_COUNTRIES = new Set([
'DE', 'FR', 'IT', 'ES', 'NL', 'BE', 'AT', 'SE', 'NO', 'DK',
'FI', 'PT', 'IE', 'PL', 'CZ', 'HU', 'RO', 'GR', 'BG', 'HR',
]);
export function geolocationMiddleware(req: NextRequest): NextResponse | null {
const { pathname } = req.nextUrl;
const country = getCountry(req);
if (!country) return null;
// GDPR cookie banner: add header for EU users
if (EU_COUNTRIES.has(country)) {
const response = NextResponse.next();
response.headers.set('x-gdpr-required', 'true');
return response;
}
// Geo-blocked content
if (pathname.startsWith('/features/crypto-trading')) {
const BLOCKED_COUNTRIES = new Set(['CN', 'IR', 'KP', 'RU']);
if (BLOCKED_COUNTRIES.has(country)) {
return NextResponse.rewrite(new URL('/unavailable-in-region', req.url));
}
}
// Locale detection: redirect to localized path
if (pathname === '/' && !req.cookies.has('locale-set')) {
if (country === 'DE') {
return NextResponse.redirect(new URL('/de', req.url));
}
if (country === 'FR') {
return NextResponse.redirect(new URL('/fr', req.url));
}
}
return null;
}
5. Bot Detection
// src/middleware/bot-detection.ts
import { NextRequest, NextResponse } from 'next/server';
const BOT_UA_PATTERNS = [
/googlebot/i, /bingbot/i, /slurp/i, /duckduckbot/i,
/baiduspider/i, /yandexbot/i, /sogou/i, /exabot/i,
/facebot/i, /ia_archiver/i,
];
const GOOD_BOTS = new Set([
'googlebot', 'bingbot', 'slurp', 'duckduckbot', // Allow search crawlers
]);
export function botDetectionMiddleware(req: NextRequest): NextResponse | null {
const ua = req.headers.get('user-agent') ?? '';
const isBot = BOT_UA_PATTERNS.some((p) => p.test(ua));
if (!isBot) return null;
const botName = BOT_UA_PATTERNS.find((p) => p.test(ua))
?.toString()
.replace(/[/\\^$*+?.()|[\]{}]/g, '');
// Block bad bots entirely
if (!GOOD_BOTS.has(botName?.toLowerCase() ?? '')) {
return new NextResponse(null, { status: 403 });
}
// For good bots: skip A/B tests and personalization
const response = NextResponse.next();
response.headers.set('x-is-bot', 'true');
return response;
}
6. Edge Rate Limiting
// src/middleware/rate-limit.ts
// Edge rate limiting using Upstash Redis (edge-compatible HTTP API)
import { Ratelimit } from '@upstash/ratelimit';
import { Redis } from '@upstash/redis';
const ratelimit = new Ratelimit({
redis: Redis.fromEnv(),
limiter: Ratelimit.slidingWindow(100, '1 m'), // 100 req/min
analytics: true,
prefix: 'rl',
});
export async function rateLimitMiddleware(req: NextRequest): Promise<NextResponse | null> {
// Only rate-limit API routes
if (!req.nextUrl.pathname.startsWith('/api/')) return null;
const ip = req.headers.get('x-forwarded-for')?.split(',')[0]?.trim()
?? req.headers.get('x-real-ip')
?? '127.0.0.1';
const { success, remaining, reset } = await ratelimit.limit(ip);
if (!success) {
return NextResponse.json(
{ error: 'Rate limit exceeded' },
{
status: 429,
headers: {
'X-RateLimit-Remaining': '0',
'X-RateLimit-Reset': reset.toString(),
'Retry-After': Math.ceil((reset - Date.now()) / 1000).toString(),
},
}
);
}
const response = NextResponse.next();
response.headers.set('X-RateLimit-Remaining', remaining.toString());
return response;
}
Performance Characteristics
| Middleware task | Edge latency | Origin latency | Recommendation |
|---|---|---|---|
| JWT decode (no verify) | < 1ms | โ | โ Edge |
| JWT verify (HMAC/RSA) | 1โ3ms | โ | โ Edge |
| Cookie read/write | < 1ms | โ | โ Edge |
| Geolocation header read | < 1ms | โ | โ Edge |
| Rate limit (Upstash) | 5โ15ms | โ | โ Edge |
| Database session lookup | N/A | 20โ100ms | โ Origin only |
| File system access | Not available | โ | โ Not in middleware |
See Also
- Next.js App Router: Server Components, Streaming, and Server Actions
- Next.js Image Optimization: AVIF/WebP, Blur Placeholders, and CDN
- Next.js Testing Strategy: Unit, Integration, and E2E with Playwright
- API Gateway Authentication: JWT, API Keys, and Rate Limiting
- Redis Advanced Patterns: Pub/Sub, Streams, and Lua Scripts
Working With Viprasol
Need auth guards, A/B testing, or geolocation routing that runs at the edge without adding latency to every request? We implement Next.js middleware pipelines that compose multiple concerns cleanly โ JWT verification, A/B test assignment, bot filtering, and edge rate limiting โ with no database calls in the request path.
About the Author
Viprasol Tech Team
Custom Software Development Specialists
The Viprasol Tech team specialises in algorithmic trading software, AI agent systems, and SaaS development. With 100+ projects delivered across MT4/MT5 EAs, fintech platforms, and production AI systems, the team brings deep technical experience to every engagement. Based in India, serving clients globally.
Need a Modern Web Application?
From landing pages to complex SaaS platforms โ we build it all with Next.js and React.
Free consultation โข No commitment โข Response within 24 hours
Need a custom web application built?
We build React and Next.js web applications with Lighthouse โฅ90 scores, mobile-first design, and full source code ownership. Senior engineers only โ from architecture through deployment.