DEV Community

Cover image for Next.js Middleware — Request Interception, Authentication Guards, and A/B Testing
Aon infotech
Aon infotech

Posted on

Next.js Middleware — Request Interception, Authentication Guards, and A/B Testing

Middleware in Next.js runs before a request is processed — before the cache is checked, before the route renders, before any server code executes. This makes it the right place for authentication checks, redirects, request modification, and A/B testing logic that needs to apply across multiple routes without being duplicated in each one.

Here's the complete middleware pattern, including auth guards, geographic redirects, A/B testing with cookies, and the performance considerations that determine when middleware is and isn't the right tool — including patterns used in the infrastructure behind ai caricature generation workflows.

Creating Middleware

Create middleware.ts at the project root (same level as app/):

// middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';

export function middleware(request: NextRequest) {
  return NextResponse.next();
}

// Configure which paths middleware runs on
export const config = {
  matcher: [
    '/((?!_next/static|_next/image|favicon.ico).*)',
  ],
};
Enter fullscreen mode Exit fullscreen mode

The matcher is critical — middleware that runs on static assets and image optimization requests adds latency for no reason.

Authentication Guard

The most common middleware use: blocking unauthenticated access to protected routes.

// middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';

const protectedPaths = ['/dashboard', '/account', '/settings'];
const authPath = '/login';

export function middleware(request: NextRequest) {
  const { pathname } = request.nextUrl;

  const isProtected = protectedPaths.some(path =>
    pathname.startsWith(path)
  );

  if (!isProtected) {
    return NextResponse.next();
  }

  const token = request.cookies.get('auth-token')?.value;

  if (!token) {
    const loginUrl = new URL(authPath, request.url);
    loginUrl.searchParams.set('from', pathname);
    return NextResponse.redirect(loginUrl);
  }

  // Optionally validate token structure here
  // For full JWT validation, use an Edge-compatible library
  return NextResponse.next();
}

export const config = {
  matcher: ['/((?!_next/static|_next/image|favicon.ico|api/auth).*)'],
};
Enter fullscreen mode Exit fullscreen mode

The from parameter preserves the original destination — after login, the user can be redirected back to where they were going.

JWT Validation in Middleware

For proper JWT validation, use an Edge-compatible library (standard Node.js crypto isn't available in middleware's Edge runtime):

// middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
import { jwtVerify } from 'jose'; // Edge-compatible JWT library

const JWT_SECRET = new TextEncoder().encode(
  process.env.JWT_SECRET!
);

async function validateToken(token: string): Promise<boolean> {
  try {
    await jwtVerify(token, JWT_SECRET);
    return true;
  } catch {
    return false;
  }
}

export async function middleware(request: NextRequest) {
  const { pathname } = request.nextUrl;

  if (!pathname.startsWith('/dashboard')) {
    return NextResponse.next();
  }

  const token = request.cookies.get('auth-token')?.value;

  if (!token || !(await validateToken(token))) {
    return NextResponse.redirect(new URL('/login', request.url));
  }

  return NextResponse.next();
}
Enter fullscreen mode Exit fullscreen mode

jose is Edge-compatible unlike jsonwebtoken, which requires Node.js crypto.

Geographic Redirects

Middleware can read the geo data that Vercel and other platforms inject into requests:

export function middleware(request: NextRequest) {
  const country = request.geo?.country;

  if (country === 'DE') {
    return NextResponse.redirect(new URL('/de', request.url));
  }

  if (country === 'JP') {
    return NextResponse.redirect(new URL('/jp', request.url));
  }

  return NextResponse.next();
}
Enter fullscreen mode Exit fullscreen mode

request.geo is populated by Vercel's edge network. On other platforms, use the CF-IPCountry header (Cloudflare) or similar. In development, request.geo is undefined.

A/B Testing With Cookies

Middleware is ideal for A/B testing because it assigns the variant before any rendering occurs, ensuring the user consistently sees the same variant across page loads:

import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';

const AB_COOKIE = 'ab-variant';
const VARIANTS = ['control', 'treatment'] as const;
type Variant = typeof VARIANTS[number];

function getVariant(request: NextRequest): Variant {
  const existing = request.cookies.get(AB_COOKIE)?.value;
  if (existing === 'control' || existing === 'treatment') {
    return existing;
  }
  return Math.random() < 0.5 ? 'control' : 'treatment';
}

export function middleware(request: NextRequest) {
  const { pathname } = request.nextUrl;

  if (!pathname.startsWith('/pricing')) {
    return NextResponse.next();
  }

  const variant = getVariant(request);
  const response = NextResponse.next();

  // Persist the variant
  response.cookies.set(AB_COOKIE, variant, {
    maxAge: 60 * 60 * 24 * 30, // 30 days
    httpOnly: true,
    sameSite: 'lax',
  });

  // Pass variant to the page via header
  response.headers.set('x-ab-variant', variant);

  return response;
}
Enter fullscreen mode Exit fullscreen mode

In the page component, read the header to determine which variant to render:

// app/pricing/page.tsx
import { headers } from 'next/headers';

export default function PricingPage() {
  const headersList = headers();
  const variant = headersList.get('x-ab-variant') ?? 'control';

  if (variant === 'treatment') {
    return <PricingV2 />;
  }

  return <PricingV1 />;
}
Enter fullscreen mode Exit fullscreen mode

Request Modification and Header Injection

Middleware can modify requests before they reach route handlers — useful for injecting context that components or API routes need:

export function middleware(request: NextRequest) {
  const requestHeaders = new Headers(request.headers);

  // Add the request URL for server components that need it
  requestHeaders.set('x-pathname', request.nextUrl.pathname);

  return NextResponse.next({
    request: { headers: requestHeaders },
  });
}
Enter fullscreen mode Exit fullscreen mode

The Matcher — Getting It Right

The matcher config determines which requests trigger middleware:

export const config = {
  matcher: [
    // Match all paths except static files and Next.js internals
    '/((?!_next/static|_next/image|favicon.ico).*)',

    // Or: match specific paths only
    '/dashboard/:path*',
    '/account/:path*',

    // Or: match API routes
    '/api/:path*',
  ],
};
Enter fullscreen mode Exit fullscreen mode

Incorrect matchers are the most common middleware bug — middleware running on _next/static paths adds latency to every static asset request.

Performance Considerations

Middleware runs on the Edge runtime, which has constraints:

  • No Node.js APIs (no fs, no native crypto)
  • No npm packages that use Node.js internals
  • Execution time limits (typically under 50ms)

For heavy operations — database queries, complex business logic — don't put them in middleware. Middleware should handle fast operations: cookie reads, header inspection, simple redirects. Anything that requires database access belongs in the route handler or Server Component.

When NOT to Use Middleware

Middleware is not the right place for:

Database queries. Every request that triggers middleware will wait for the database call. Use middleware for fast cookie/header checks; move database calls to Server Components or route handlers where caching applies.

Heavy business logic. Complex calculations or multi-step operations belong in server-side code where they can be cached and where the full Node.js environment is available.

Per-request secret handling. Don't access environment variables containing secrets in middleware for operations that could be handled with a simple cookie check. Keep middleware logic minimal and fast.

Image optimization. The _next/image path should be excluded from middleware matchers — running middleware on image optimization requests adds latency to every image load.

The mental model: middleware handles routing decisions. Route handlers and Server Components handle business logic and data fetching.

Summary

Middleware intercepts requests before rendering — right for auth checks, redirects, A/B variant assignment, and request header injection. Create at project root as middleware.ts. Use the Edge-compatible jose for JWT validation. Always configure matcher to exclude static assets. Keep logic fast — no database calls, no heavy computation.

Testing Middleware Locally

Middleware runs in the Edge runtime even during local development with next dev. Test by:

# Check which paths trigger middleware with a simple log
export function middleware(request: NextRequest) {
  console.log('[Middleware]', request.method, request.nextUrl.pathname);
  return NextResponse.next();
}
Enter fullscreen mode Exit fullscreen mode

For auth middleware specifically: test with an expired/invalid token, a missing token, and a valid token to cover the three cases that matter. Also test that the from redirect parameter round-trips correctly through the login flow.

Top comments (0)