Next.js Edge Middleware: Auth, Redirects, and A/B Testing
What runs in middleware, auth without a database hit, country-based redirects, feature flags, and debugging at the edge.
Next.js middleware runs at the edge — between the CDN and your server — on every request before the response. It's fast (no cold starts on Vercel Edge), but constrained: no Node.js APIs, no database connections, and a 2MB code size limit. Here's what it's actually good for.
What Middleware Can and Can't Do
Can:
- Read and rewrite request headers and cookies
- Redirect and rewrite URLs
- Run lightweight logic (JWT verification, feature flag checks, geolocation)
- Return early with a response (rate limits, auth blocks)
Can't:
- Connect to a database (no TCP)
- Import large Node.js packages
- Access the file system
This means you validate JWT tokens in middleware, not fetch sessions from a database. The session is already in a cookie — verify it cryptographically.
Auth Without a Database Hit
Better-Auth stores session tokens as JWTs. Verify the signature in middleware without a database round-trip:
// middleware.ts
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
import * as jose from "jose";
const SECRET = new TextEncoder().encode(process.env.BETTER_AUTH_SECRET!);
const PROTECTED_ROUTES = ["/dashboard", "/profile", "/watchlist", "/downloads"];
export async function middleware(req: NextRequest) {
const path = req.nextUrl.pathname;
const isProtected = PROTECTED_ROUTES.some((r) => path.startsWith(r));
if (!isProtected) return NextResponse.next();
const token = req.cookies.get("better-auth.session_token")?.value;
if (!token) {
return NextResponse.redirect(new URL(`/login?next=${encodeURIComponent(path)}`, req.url));
}
try {
await jose.jwtVerify(token, SECRET);
return NextResponse.next();
} catch {
// Token expired or invalid
const response = NextResponse.redirect(new URL("/login", req.url));
response.cookies.delete("better-auth.session_token");
return response;
}
}
export const config = {
matcher: ["/((?!_next/static|_next/image|favicon.ico|api/auth).*)"],
};This is ~2ms overhead versus ~20ms for a database session lookup.
Geolocation-Based Redirects
Cloudflare and Vercel inject geo headers. Use them for region-specific routing:
export function middleware(req: NextRequest) {
const country = req.headers.get("cf-ipcountry") // Cloudflare
?? req.geo?.country; // Vercel
// Redirect to regional pricing page for certain countries
const REGIONAL_PRICING = ["IN", "BR", "NG", "PK"];
if (country && REGIONAL_PRICING.includes(country) && req.nextUrl.pathname === "/pricing") {
return NextResponse.redirect(new URL("/pricing/regional", req.url));
}
return NextResponse.next();
}Never block content based on geolocation without clear user communication — but pricing and language redirects are expected behavior.
Feature Flags at the Edge
Assign users to A/B test cohorts in middleware, persist in a cookie:
export function middleware(req: NextRequest) {
const existing = req.cookies.get("ab-new-layout");
if (!existing) {
// Assign 20% of users to the new layout
const inTest = Math.random() < 0.2;
const response = NextResponse.next();
response.cookies.set("ab-new-layout", inTest ? "1" : "0", {
maxAge: 60 * 60 * 24 * 30, // 30 days
httpOnly: true,
});
return response;
}
return NextResponse.next();
}In your layout, read the cookie:
import { cookies } from "next/headers";
export default async function Layout({ children }) {
const cookieStore = await cookies();
const useNewLayout = cookieStore.get("ab-new-layout")?.value === "1";
return useNewLayout ? <NewLayout>{children}</NewLayout> : <OldLayout>{children}</OldLayout>;
}URL Rewrites
Rewrites change what page is served without changing the URL the user sees:
export function middleware(req: NextRequest) {
// Serve /movies as the homepage for certain traffic sources
const utm = req.nextUrl.searchParams.get("utm_source");
if (utm === "youtube" && req.nextUrl.pathname === "/") {
return NextResponse.rewrite(new URL("/landing/youtube", req.url));
}
return NextResponse.next();
}Different from redirects in next.config.ts — rewrites don't change the browser URL.
Header Manipulation
Forward auth context to API routes without a session fetch:
export async function middleware(req: NextRequest) {
const token = req.cookies.get("better-auth.session_token")?.value;
if (token && req.nextUrl.pathname.startsWith("/api/")) {
const requestHeaders = new Headers(req.headers);
requestHeaders.set("x-session-token", token);
return NextResponse.next({ request: { headers: requestHeaders } });
}
return NextResponse.next();
}In your API route, read x-session-token from headers instead of cookies — useful in environments where cookies aren't forwarded correctly.
Debugging
Middleware errors are silent by default. Add explicit logging:
export async function middleware(req: NextRequest) {
try {
// ... your logic
} catch (error) {
console.error("Middleware error:", error);
// Fail open — don't block the user if middleware errors
return NextResponse.next();
}
}In Cloudflare, use wrangler tail to stream logs from your middleware in real time.
The rule: if it can be done in middleware, it should be — the latency savings compound across every request. But keep it lightweight. The moment you reach for a database client, move the logic to the route handler.