DEV Community

Atlas Whoff
Atlas Whoff

Posted on

Next.js Middleware Deep Dive: A/B Testing and Tenant Routing

What Middleware Is For

Next.js middleware runs before a request reaches your route handler—at the edge, before any rendering. It's the right place for:

  • Authentication checks
  • Redirects based on user state
  • Rate limiting
  • A/B testing assignment
  • Geolocation-based routing
  • Request logging

Basic Setup

// middleware.ts (in root, next to app/)
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';

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

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

Authentication Middleware

import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
import { verifyToken } from './lib/auth';

const PUBLIC_PATHS = ['/login', '/signup', '/api/auth', '/', '/pricing'];

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

  // Allow public paths
  if (PUBLIC_PATHS.some(path => pathname.startsWith(path))) {
    return NextResponse.next();
  }

  // Check auth token
  const token = request.cookies.get('session')?.value
    ?? request.headers.get('Authorization')?.replace('Bearer ', '');

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

  try {
    const payload = await verifyToken(token);

    // Pass user data to route handlers via headers
    const response = NextResponse.next();
    response.headers.set('x-user-id', payload.userId);
    response.headers.set('x-user-role', payload.role);
    return response;

  } catch {
    const response = NextResponse.redirect(new URL('/login', request.url));
    response.cookies.delete('session');
    return response;
  }
}
Enter fullscreen mode Exit fullscreen mode

Role-Based Access

const ROLE_REQUIREMENTS: Record<string, string[]> = {
  '/admin': ['admin', 'superadmin'],
  '/api/admin': ['admin', 'superadmin'],
  '/settings/billing': ['admin', 'owner'],
};

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

  if (!payload) {
    return NextResponse.redirect(new URL('/login', request.url));
  }

  // Check role requirements
  for (const [path, requiredRoles] of Object.entries(ROLE_REQUIREMENTS)) {
    if (pathname.startsWith(path)) {
      if (!requiredRoles.includes(payload.role)) {
        return NextResponse.redirect(new URL('/403', request.url));
      }
    }
  }

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

Edge Rate Limiting

import { Ratelimit } from '@upstash/ratelimit';
import { Redis } from '@upstash/redis';

const ratelimit = new Ratelimit({
  redis: Redis.fromEnv(),
  limiter: Ratelimit.slidingWindow(10, '10 s'),
});

export async function middleware(request: NextRequest) {
  const ip = request.ip ?? request.headers.get('x-forwarded-for') ?? 'anonymous';

  // Only rate limit API routes
  if (request.nextUrl.pathname.startsWith('/api/')) {
    const { success, limit, remaining, reset } = await ratelimit.limit(ip);

    if (!success) {
      return new NextResponse('Too Many Requests', {
        status: 429,
        headers: {
          'X-RateLimit-Limit': limit.toString(),
          'X-RateLimit-Remaining': remaining.toString(),
          'X-RateLimit-Reset': reset.toString(),
        },
      });
    }
  }

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

Geolocation and i18n Routing

export function middleware(request: NextRequest) {
  // Next.js provides geo data at the edge
  const country = request.geo?.country ?? 'US';
  const locale = request.headers.get('accept-language')?.split(',')[0] ?? 'en';

  // Redirect EU users to GDPR-compliant subdomain
  const EU_COUNTRIES = ['DE', 'FR', 'IT', 'ES', 'NL', 'BE', 'AT', 'SE'];
  if (EU_COUNTRIES.includes(country) && !request.nextUrl.hostname.includes('eu.')) {
    const url = request.nextUrl.clone();
    url.hostname = `eu.${url.hostname}`;
    return NextResponse.redirect(url);
  }

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

Passing Data to Route Handlers

// Middleware sets headers
const response = NextResponse.next();
response.headers.set('x-user-id', payload.userId);
response.headers.set('x-org-id', payload.orgId);

// Route handler reads them
export async function GET(request: NextRequest) {
  const userId = request.headers.get('x-user-id')!;
  const orgId = request.headers.get('x-org-id')!;

  const data = await db.projects.findMany({ where: { orgId } });
  return Response.json(data);
}
Enter fullscreen mode Exit fullscreen mode

Performance Notes

Middleware runs at the edge (Vercel Edge Network or Node.js). Keep it fast:

  • No database queries (use KV stores like Upstash Redis)
  • No heavy computation
  • JWT verification is fine (crypto is fast)
  • Keep the matcher narrow—don't run on static assets

Middleware that adds 5ms to every request adds up. Target < 10ms total.


Auth middleware with JWT verification, role-based access, and rate limiting: Whoff Agents AI SaaS Starter Kit.

Top comments (0)