Skip to content
Codeloom
Next.js

Next.js Middleware Patterns for Production Apps

Practical middleware patterns for Next.js including authentication, A/B testing, geolocation routing, bot detection, and request logging.

·7 min read · By Codeloom
Intermediate 11 min read

What you'll learn

  • How Next.js middleware runs at the edge before every matched request
  • Authentication and authorization patterns
  • A/B testing and feature flags with cookies
  • Rate limiting, bot detection, and request logging

Prerequisites

None — this post is self-contained.

Next.js middleware runs at the edge before a request reaches your pages or API routes. It can rewrite URLs, redirect users, set headers, and modify cookies without spinning up a full server. This makes it ideal for cross-cutting concerns that need to execute on every request with minimal latency. This article covers production-ready patterns beyond the basics.

How Middleware Works

Middleware is defined in a single middleware.ts file at the root of your project (or inside src/). It runs on every request that matches the configured matcher.

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

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

export const config = {
  matcher: [
    // Match all paths except static files and Next.js internals
    '/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)',
  ],
};

The matcher uses a path pattern to control which routes trigger the middleware. Without a matcher, middleware runs on every request, including static assets, which wastes edge compute.

Authentication Guard

The most common middleware pattern is redirecting unauthenticated users to a login page:

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

const protectedRoutes = ['/dashboard', '/settings', '/account'];
const authRoutes = ['/login', '/register'];

export function middleware(request: NextRequest) {
  const { pathname } = request.nextUrl;
  const sessionToken = request.cookies.get('session-token')?.value;

  const isProtected = protectedRoutes.some((route) =>
    pathname.startsWith(route)
  );
  const isAuthRoute = authRoutes.some((route) =>
    pathname.startsWith(route)
  );

  // Redirect unauthenticated users to login
  if (isProtected && !sessionToken) {
    const loginUrl = new URL('/login', request.url);
    loginUrl.searchParams.set('callbackUrl', pathname);
    return NextResponse.redirect(loginUrl);
  }

  // Redirect authenticated users away from auth pages
  if (isAuthRoute && sessionToken) {
    return NextResponse.redirect(new URL('/dashboard', request.url));
  }

  return NextResponse.next();
}

export const config = {
  matcher: ['/dashboard/:path*', '/settings/:path*', '/account/:path*', '/login', '/register'],
};

Role-Based Access Control

Extend the auth pattern with role checking by reading a JWT or session cookie:

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

const roleRoutes: Record<string, string[]> = {
  '/admin': ['admin'],
  '/moderator': ['admin', 'moderator'],
  '/dashboard': ['admin', 'moderator', 'user'],
};

async function getUserRole(token: string): Promise<string | null> {
  try {
    const secret = new TextEncoder().encode(process.env.JWT_SECRET);
    const { payload } = await jwtVerify(token, secret);
    return payload.role as string;
  } catch {
    return null;
  }
}

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

  for (const [route, allowedRoles] of Object.entries(roleRoutes)) {
    if (pathname.startsWith(route)) {
      if (!token) {
        return NextResponse.redirect(new URL('/login', request.url));
      }

      const role = await getUserRole(token);
      if (!role || !allowedRoles.includes(role)) {
        return NextResponse.redirect(new URL('/unauthorized', request.url));
      }
    }
  }

  return NextResponse.next();
}

A/B Testing with Cookies

Middleware can assign users to experiment groups and rewrite to variant pages without client-side JavaScript:

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

const EXPERIMENT_COOKIE = 'ab-pricing-page';
const VARIANTS = ['control', 'variant-a', 'variant-b'];

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

  if (pathname === '/pricing') {
    let variant = request.cookies.get(EXPERIMENT_COOKIE)?.value;

    // Assign a variant if the user does not have one
    if (!variant || !VARIANTS.includes(variant)) {
      variant = VARIANTS[Math.floor(Math.random() * VARIANTS.length)];
    }

    // Rewrite to the variant page
    const url = request.nextUrl.clone();
    url.pathname = `/pricing/${variant}`;

    const response = NextResponse.rewrite(url);

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

    return response;
  }

  return NextResponse.next();
}

The user always sees /pricing in their browser, but the server delivers different content based on their assigned variant. Analytics can read the cookie to track conversion rates.

Geolocation-Based Routing

Vercel and other edge platforms inject geolocation data into the request. Use it for locale detection or content customization:

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

const COUNTRY_LOCALES: Record<string, string> = {
  DE: 'de',
  FR: 'fr',
  ES: 'es',
  JP: 'ja',
};

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

  // Skip if already on a locale path
  if (/^\/(de|fr|es|ja)\//.test(pathname)) {
    return NextResponse.next();
  }

  // Skip non-page requests
  if (pathname.startsWith('/api') || pathname.startsWith('/_next')) {
    return NextResponse.next();
  }

  const country = request.geo?.country || '';
  const locale = COUNTRY_LOCALES[country];

  if (locale) {
    const url = request.nextUrl.clone();
    url.pathname = `/${locale}${pathname}`;
    return NextResponse.redirect(url);
  }

  return NextResponse.next();
}

Rate Limiting

Edge-based rate limiting provides basic protection without a separate service. This example uses a simple in-memory approach suitable for single-region deployments:

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

const rateLimit = new Map<string, { count: number; resetTime: number }>();

const WINDOW_MS = 60 * 1000; // 1 minute
const MAX_REQUESTS = 100;

function isRateLimited(identifier: string): boolean {
  const now = Date.now();
  const entry = rateLimit.get(identifier);

  if (!entry || now > entry.resetTime) {
    rateLimit.set(identifier, { count: 1, resetTime: now + WINDOW_MS });
    return false;
  }

  entry.count++;
  return entry.count > MAX_REQUESTS;
}

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

    if (isRateLimited(ip)) {
      return NextResponse.json(
        { error: 'Too many requests' },
        {
          status: 429,
          headers: { 'Retry-After': '60' },
        }
      );
    }
  }

  return NextResponse.next();
}

For production deployments across multiple regions, use an external store like Upstash Redis instead of in-memory maps.

Bot Detection and Blocking

Block known bad bots or serve different content to crawlers:

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

const BLOCKED_BOTS = ['BadBot', 'ScraperX', 'SpamCrawler'];
const SEARCH_BOTS = ['Googlebot', 'Bingbot', 'DuckDuckBot'];

export function middleware(request: NextRequest) {
  const userAgent = request.headers.get('user-agent') || '';

  // Block malicious bots
  if (BLOCKED_BOTS.some((bot) => userAgent.includes(bot))) {
    return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
  }

  // Add header for search engine bots (useful for conditional rendering)
  if (SEARCH_BOTS.some((bot) => userAgent.includes(bot))) {
    const response = NextResponse.next();
    response.headers.set('x-is-crawler', 'true');
    return response;
  }

  return NextResponse.next();
}

Request Logging and Tracing

Add request IDs and timing headers for observability:

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

export function middleware(request: NextRequest) {
  const requestId = crypto.randomUUID();
  const startTime = Date.now();

  // Add request ID to downstream services
  const requestHeaders = new Headers(request.headers);
  requestHeaders.set('x-request-id', requestId);

  const response = NextResponse.next({
    request: { headers: requestHeaders },
  });

  // Add response headers for debugging
  response.headers.set('x-request-id', requestId);
  response.headers.set('x-response-time', `${Date.now() - startTime}ms`);

  return response;
}

Composing Multiple Patterns

When you need multiple middleware behaviors, compose them in a single function since Next.js only supports one middleware file:

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

type MiddlewareHandler = (
  request: NextRequest
) => NextResponse | undefined;

const authMiddleware: MiddlewareHandler = (request) => {
  const { pathname } = request.nextUrl;
  if (pathname.startsWith('/dashboard') && !request.cookies.has('session')) {
    return NextResponse.redirect(new URL('/login', request.url));
  }
  return undefined;
};

const loggingMiddleware: MiddlewareHandler = (request) => {
  // Log to external service (non-blocking)
  console.log(`${request.method} ${request.nextUrl.pathname}`);
  return undefined;
};

export function middleware(request: NextRequest) {
  const handlers = [loggingMiddleware, authMiddleware];

  for (const handler of handlers) {
    const result = handler(request);
    if (result) return result;
  }

  return NextResponse.next();
}

Wrapping Up

Next.js middleware gives you a single interception point for cross-cutting concerns at the edge. Use it for authentication guards, A/B testing, geolocation routing, rate limiting, and request tracing. Keep middleware logic fast since it runs on every matched request. For heavy computation or database queries, defer to server components or API routes. The key constraint to remember is that middleware runs in the Edge Runtime, which means no Node.js APIs like fs or native modules. Design your middleware to make fast decisions based on cookies, headers, and URL patterns.