DEV Community

Cover image for Next.js Middleware — Redirects, Auth Guards, and A/B Testing at the Edge
Aon infotech
Aon infotech

Posted on

Next.js Middleware — Redirects, Auth Guards, and A/B Testing at the Edge

Middleware in Next.js runs before a request is completed — at the edge, before the page renders, before your API route executes. This makes it the right place for logic that needs to run on every request: authentication checks, redirects, geo-based routing, and A/B testing.

Here's the complete picture of what middleware is actually good for, with patterns from the request flow at free AI product image generation.

How Middleware Works

Create middleware.ts at the root of your project (same level as app/). Next.js automatically runs it before matched routes.

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

export function middleware(request: NextRequest) {
  // Runs before every matched request
  return NextResponse.next(); // Continue to the route
}

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

The config.matcher controls which paths trigger middleware. The pattern above runs on all paths except Next.js internals. Be specific here — middleware runs at the edge on every request, so unintentionally broad matchers create unnecessary latency.

Auth Guards — Protecting Routes

The most common middleware use case: redirect unauthenticated users away from protected routes.

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

const PROTECTED_ROUTES = ['/dashboard', '/account', '/settings'];
const LOGIN_PAGE = '/login';

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

  const isProtectedRoute = PROTECTED_ROUTES.some(route => 
    pathname.startsWith(route)
  );

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

  // Check for auth token in cookies
  const token = request.cookies.get('auth-token')?.value;

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

  return NextResponse.next();
}

export const config = {
  matcher: ['/dashboard/:path*', '/account/:path*', '/settings/:path*'],
};
Enter fullscreen mode Exit fullscreen mode

The matcher config here is more specific — only runs on the protected route groups rather than every request. Faster and cleaner.

For JWT validation in middleware:

import { jwtVerify } from 'jose';

async function verifyToken(token: string): Promise<boolean> {
  try {
    const secret = new TextEncoder().encode(process.env.JWT_SECRET!);
    await jwtVerify(token, secret);
    return true;
  } catch {
    return false;
  }
}

export async function middleware(request: NextRequest) {
  const token = request.cookies.get('auth-token')?.value;

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

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

Note: keep token verification lightweight in middleware. Heavy database lookups don't belong here — middleware runs on every matched request and latency adds up.

Redirects at Scale

For simple redirects, next.config.js redirects are better. Middleware redirects make sense when the redirect logic is conditional or dynamic.

// middleware.ts — conditional redirect based on request properties
export function middleware(request: NextRequest) {
  const { pathname, searchParams } = request.nextUrl;

  // Redirect old URL patterns to new ones
  if (pathname.startsWith('/blog/post/')) {
    const slug = pathname.replace('/blog/post/', '');
    return NextResponse.redirect(
      new URL(`/blog/${slug}`, request.url),
      { status: 301 }
    );
  }

  // Redirect based on query param
  if (searchParams.get('ref') === 'old-campaign') {
    const url = request.nextUrl.clone();
    url.searchParams.delete('ref');
    url.searchParams.set('utm_source', 'old-campaign');
    return NextResponse.redirect(url);
  }

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

Geo-Based Routing

Middleware receives geo data from the edge (Vercel populates this automatically):

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

  // Redirect to localized version
  if (country === 'DE' && !request.nextUrl.pathname.startsWith('/de')) {
    return NextResponse.redirect(
      new URL(`/de${request.nextUrl.pathname}`, request.url)
    );
  }

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

A/B Testing With Middleware

A/B testing at the edge — assigning users to buckets and serving different content — works well with middleware because it runs before the page renders.

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

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

function getVariant(): Variant {
  return Math.random() < 0.5 ? 'control' : 'variant-a';
}

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

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

  // Get or assign variant
  let variant = request.cookies.get(AB_COOKIE)?.value as Variant | undefined;

  if (!variant || !VARIANTS.includes(variant)) {
    variant = getVariant();
  }

  // Rewrite to variant page (URL stays the same for user)
  const response = NextResponse.rewrite(
    new URL(`/ab-variants/${variant}`, request.url)
  );

  // Set cookie so user stays in their bucket
  response.cookies.set(AB_COOKIE, variant, {
    maxAge: 60 * 60 * 24 * 30, // 30 days
    httpOnly: true,
    sameSite: 'lax',
  });

  return response;
}

export const config = {
  matcher: ['/'],
};
Enter fullscreen mode Exit fullscreen mode

NextResponse.rewrite shows different content at the same URL — the user sees / but Next.js serves /ab-variants/variant-a. The cookie persists the assignment across sessions.

Adding Headers to Requests

Middleware can add headers that downstream components can read:

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

  // Add pathname as header for Server Components to read
  requestHeaders.set('x-pathname', request.nextUrl.pathname);

  // Add user segment based on cookie
  const segment = request.cookies.get('user-segment')?.value || 'unknown';
  requestHeaders.set('x-user-segment', segment);

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

In a Server Component:

import { headers } from 'next/headers';

export default function Page() {
  const pathname = headers().get('x-pathname');
  const segment = headers().get('x-user-segment');

  return <div data-segment={segment}>Content for {pathname}</div>;
}
Enter fullscreen mode Exit fullscreen mode

What Middleware Is Not For

Middleware runs at the edge with a limited runtime — no Node.js built-ins, no filesystem access, no database connections. It's for fast, lightweight request inspection and modification.

Heavy logic, database queries, and complex business logic belong in API routes or Server Components, not middleware. If you find middleware doing significant processing, that's a sign the logic belongs elsewhere.

The Matcher Config — Getting It Right

The matcher is worth getting right. Every path that matches runs middleware — unnecessary matches mean unnecessary latency.

export const config = {
  matcher: [
    // Match everything except static files and images
    '/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)',
  ],
};
Enter fullscreen mode Exit fullscreen mode

For route group matching:

export const config = {
  matcher: [
    '/dashboard/:path*',    // matches /dashboard and /dashboard/anything
    '/api/:path*',          // matches all API routes
    '/((?!login|signup).*)', // matches everything except login and signup
  ],
};
Enter fullscreen mode Exit fullscreen mode

Test your matchers before deploying. An overly broad matcher that runs middleware on image requests and static files adds latency to every asset load.

Summary

Middleware runs before matched routes at the edge. Use it for: auth guards that redirect unauthenticated users, conditional redirects based on request properties, geo-based routing, A/B testing with cookie-persisted assignments, and adding request headers for Server Components.

Keep it fast and lightweight — no database queries, no heavy computation. The edge runtime is intentionally limited to keep middleware responses fast.

Rate Limiting With Middleware

A common pattern: simple rate limiting at the edge before requests hit your API routes.

// middleware.ts — simple in-memory rate limiting (use Redis for production)
const rateLimitMap = new Map<string, { count: number; resetTime: number }>();

function isRateLimited(ip: string, limit = 10, windowMs = 60000): boolean {
  const now = Date.now();
  const record = rateLimitMap.get(ip);

  if (!record || now > record.resetTime) {
    rateLimitMap.set(ip, { count: 1, resetTime: now + windowMs });
    return false;
  }

  if (record.count >= limit) return true;

  record.count++;
  return false;
}

export function middleware(request: NextRequest) {
  if (request.nextUrl.pathname.startsWith('/api/')) {
    const ip = request.ip || request.headers.get('x-forwarded-for') || 'unknown';

    if (isRateLimited(ip)) {
      return new NextResponse('Too Many Requests', { status: 429 });
    }
  }

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

For production rate limiting across edge instances, use a shared store like Upstash Redis — in-memory Maps don't persist across serverless invocations.

Top comments (0)