Next.js Middleware Patterns: Auth, Redirects, A/B Testing, and Geo Routing at the Edge
Master Next.js Edge Middleware: implement authentication guards without full server roundtrips, configure redirect rules, run A/B tests at the edge with cookie-based assignment, and geo-route users to regional content — all with sub-millisecond latency.
Next.js Middleware runs at the Edge — before the request reaches your server, on Vercel's CDN nodes or your own edge infrastructure. This makes it ideal for fast operations that must happen on every request: auth checks, redirects, bot filtering, A/B test assignment, and geo-based routing.
Edge Middleware runs in a restricted runtime (no Node.js fs, child_process, or most npm packages) but has access to request headers, cookies, URL, and geolocation data from the CDN.
Middleware Structure
// middleware.ts — at the root of your project (not src/)
import { NextRequest, NextResponse } from "next/server";
export function middleware(request: NextRequest): NextResponse | Response {
const { pathname } = request.nextUrl;
// Auth check
const authResponse = checkAuth(request);
if (authResponse) return authResponse;
// A/B test assignment
const abResponse = assignABTest(request);
if (abResponse) return abResponse;
// Geo routing
return handleGeoRouting(request);
}
// Matcher: which paths middleware runs on
// Be specific — running on every request adds latency
export const config = {
matcher: [
// Run on all paths except static files and API routes that handle their own auth
"/((?!_next/static|_next/image|favicon.ico|public/).*)",
],
};
Authentication Middleware
// src/middleware/auth.ts
import { NextRequest, NextResponse } from "next/server";
import { jwtVerify } from "jose"; // jose works in Edge Runtime; jsonwebtoken does not
const JWT_SECRET = new TextEncoder().encode(process.env.JWT_SECRET!);
// Routes that don't require authentication
const PUBLIC_PATHS = [
"/",
"/login",
"/signup",
"/forgot-password",
"/api/auth", // Auth API routes handle their own logic
"/blog", // Public blog
];
const isPublicPath = (pathname: string): boolean => {
return PUBLIC_PATHS.some(
(path) => pathname === path || pathname.startsWith(`${path}/`)
);
};
export async function checkAuth(
request: NextRequest
): Promise<NextResponse | null> {
const { pathname } = request.nextUrl;
if (isPublicPath(pathname)) return null; // No auth required
const token =
request.cookies.get("access_token")?.value ||
request.headers.get("authorization")?.replace("Bearer ", "");
if (!token) {
// Redirect to login, preserving the intended destination
const loginUrl = new URL("/login", request.url);
loginUrl.searchParams.set("returnTo", pathname);
return NextResponse.redirect(loginUrl);
}
try {
const { payload } = await jwtVerify(token, JWT_SECRET);
// Forward user context to route handlers via request headers
// (you can't pass data directly — use headers instead)
const requestHeaders = new Headers(request.headers);
requestHeaders.set("x-user-id", payload.sub as string);
requestHeaders.set("x-tenant-id", payload.tenantId as string);
requestHeaders.set("x-user-roles", JSON.stringify(payload.roles));
return NextResponse.next({
request: { headers: requestHeaders },
});
} catch {
// Token invalid or expired — clear cookie and redirect to login
const loginUrl = new URL("/login", request.url);
loginUrl.searchParams.set("returnTo", pathname);
loginUrl.searchParams.set("reason", "session_expired");
const response = NextResponse.redirect(loginUrl);
response.cookies.delete("access_token");
return response;
}
}
🌐 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
Redirect Rules
// src/middleware/redirects.ts
// Handle redirects at the edge — faster than server-side redirects
interface RedirectRule {
source: string | RegExp;
destination: string;
permanent: boolean;
}
const REDIRECT_RULES: RedirectRule[] = [
// Legacy URL cleanup (match GSC 404s)
{ source: "/blog/old-slug", destination: "/blog/new-slug", permanent: true },
{ source: "/services/devops", destination: "/services/cloud-solutions", permanent: true },
// Trailing slash normalization
// (or configure this in next.config.ts with trailingSlash)
// Campaign redirects
{ source: "/go/product-hunt", destination: "/?utm_source=producthunt&utm_medium=launch", permanent: false },
];
export function handleRedirects(request: NextRequest): NextResponse | null {
const { pathname } = request.nextUrl;
for (const rule of REDIRECT_RULES) {
const matches =
typeof rule.source === "string"
? pathname === rule.source
: rule.source.test(pathname);
if (matches) {
const destination = new URL(rule.destination, request.url);
return NextResponse.redirect(destination, {
status: rule.permanent ? 308 : 307,
});
}
}
return null;
}
A/B Testing at the Edge
// src/middleware/ab-testing.ts
// Assign users to test variants at the edge — no flicker, no layout shift
interface ABTest {
id: string;
variants: string[];
weights: number[]; // Must sum to 1
// Paths this test applies to
paths: string[];
}
const ACTIVE_TESTS: ABTest[] = [
{
id: "pricing-page-cta",
variants: ["control", "variant-a", "variant-b"],
weights: [0.34, 0.33, 0.33],
paths: ["/pricing"],
},
{
id: "signup-flow",
variants: ["control", "simplified"],
weights: [0.5, 0.5],
paths: ["/signup"],
},
];
function assignVariant(test: ABTest): string {
const rand = Math.random();
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]; // Fallback
}
export function assignABTest(request: NextRequest): NextResponse | null {
const { pathname } = request.nextUrl;
let response: NextResponse | null = null;
for (const test of ACTIVE_TESTS) {
if (!test.paths.some((p) => pathname.startsWith(p))) continue;
const cookieName = `ab_${test.id}`;
const existingVariant = request.cookies.get(cookieName)?.value;
if (existingVariant && test.variants.includes(existingVariant)) {
// Already assigned — forward variant to page via header
if (!response) {
response = NextResponse.next();
}
response.headers.set(`x-ab-${test.id}`, existingVariant);
continue;
}
// New visitor — assign variant
const variant = assignVariant(test);
if (!response) {
response = NextResponse.next();
}
// Set cookie (persists assignment across sessions)
response.cookies.set(cookieName, variant, {
maxAge: 30 * 24 * 60 * 60, // 30 days
httpOnly: false, // Readable by analytics scripts
sameSite: "lax",
});
response.headers.set(`x-ab-${test.id}`, variant);
}
return response;
}
// Reading the A/B variant in a page
// src/app/pricing/page.tsx
import { headers } from "next/headers";
export default async function PricingPage() {
const headersList = headers();
const variant = headersList.get("x-ab-pricing-page-cta") ?? "control";
return (
<main>
{variant === "variant-a" ? (
<button className="bg-green-600 text-white px-8 py-4 text-lg rounded-xl">
Start Building Free →
</button>
) : variant === "variant-b" ? (
<button className="bg-blue-600 text-white px-8 py-4 text-lg rounded-xl">
Get Started in 2 Minutes
</button>
) : (
<button className="bg-blue-600 text-white px-6 py-3 rounded-lg">
Start Free Trial
</button>
)}
</main>
);
}
🚀 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
Geo Routing
// src/middleware/geo-routing.ts
// Route users to region-specific content or servers
export function handleGeoRouting(request: NextRequest): NextResponse {
const country = request.geo?.country ?? "US";
const { pathname } = request.nextUrl;
// Route to regional subpages for compliance (GDPR notice for EU)
if (pathname === "/" && isEUCountry(country)) {
// Rewrite (not redirect) — URL stays as "/" but serves EU content
const url = request.nextUrl.clone();
url.pathname = "/eu";
return NextResponse.rewrite(url);
}
// Route to language-appropriate content
const preferredLocale = getPreferredLocale(request, country);
if (preferredLocale !== "en" && !pathname.startsWith(`/${preferredLocale}`)) {
const url = request.nextUrl.clone();
url.pathname = `/${preferredLocale}${pathname}`;
return NextResponse.redirect(url, { status: 307 });
}
// Set country header for server components
const response = NextResponse.next();
response.headers.set("x-user-country", country);
response.headers.set("x-user-region", request.geo?.region ?? "");
return response;
}
const EU_COUNTRIES = new Set([
"AT", "BE", "BG", "CY", "CZ", "DE", "DK", "EE", "ES", "FI",
"FR", "GR", "HR", "HU", "IE", "IT", "LT", "LU", "LV", "MT",
"NL", "PL", "PT", "RO", "SE", "SI", "SK",
]);
function isEUCountry(country: string): boolean {
return EU_COUNTRIES.has(country);
}
function getPreferredLocale(request: NextRequest, country: string): string {
// Check explicit cookie preference first
const cookieLocale = request.cookies.get("locale")?.value;
if (cookieLocale) return cookieLocale;
// Infer from country
const countryToLocale: Record<string, string> = {
DE: "de", FR: "fr", ES: "es", JP: "ja", BR: "pt",
};
return countryToLocale[country] ?? "en";
}
Bot Detection and Rate Limiting
// src/middleware/bot-filter.ts
// Simple bot detection at the edge — before traffic hits your server
const BOT_USER_AGENTS = [
/Googlebot/i, /bingbot/i, /Slurp/i, /DuckDuckBot/i,
/Baiduspider/i, /YandexBot/i, /Sogou/i,
// Add scrapers you want to block
/python-requests/i, /Go-http-client/i,
];
const ALLOWED_CRAWLERS = new Set(["Googlebot", "bingbot"]);
export function filterBots(request: NextRequest): NextResponse | null {
const ua = request.headers.get("user-agent") ?? "";
// Allow known search engine crawlers
if (ALLOWED_CRAWLERS.has(ua.split("/")[0])) return null;
// Block scraper patterns on sensitive endpoints
const sensitivePattern = /^\/api\/(?!auth)/;
if (sensitivePattern.test(request.nextUrl.pathname)) {
for (const pattern of BOT_USER_AGENTS) {
if (pattern.test(ua)) {
return new NextResponse("Too Many Requests", { status: 429 });
}
}
}
return null;
}
See Also
- Next.js App Router Patterns — App Router architecture
- Next.js Caching Strategies — caching at the edge
- API Security Best Practices — auth and rate limiting
- Web Performance: Core Web Vitals — edge performance
Working With Viprasol
Edge Middleware enables authentication, personalization, A/B testing, and routing decisions with minimal latency overhead. We implement middleware patterns for Next.js applications — from session-based auth guards to multi-region geo routing — with proper matcher configuration that avoids running middleware on every static asset request.
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.