Back to Blog
Jul 7, 20264 min readOnuzulike Anthony

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.

Full-StackNext.jsEdgeMiddleware

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:

ts
// 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:

ts
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:

ts
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:

ts
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:

ts
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:

ts
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:

ts
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.