IAMUVIN

Next.js & React

Next.js Middleware Patterns for Production Applications

Uvin Vindula·September 2, 2024·10 min read
Share

TL;DR

Next.js middleware runs before every matched request on the edge, making it the single most powerful interception point in your entire application. I use it on every project I ship — EuroParts Lanka, FreshMart, client dashboards — for authentication redirects, rate limiting, geo-based routing, bot detection, and A/B testing. This article covers the exact patterns I run in production, the mistakes that cost me hours of debugging, and a complete middleware.ts template you can drop into any Next.js project today. No theory. Real code from real applications.


What Middleware Is in Next.js

Middleware in Next.js is a function that runs on the edge before a request reaches your route handlers or pages. It intercepts every matched request, lets you inspect or modify the request and response, and can redirect, rewrite, or block traffic entirely — all before your server components even begin rendering.

The key thing most developers miss: middleware runs in the Edge Runtime. That means no Node.js APIs. No fs. No native modules. No heavy npm packages. You get the Web API surface — Request, Response, Headers, URL, crypto.subtle, and fetch. That constraint is actually a strength. It forces you to keep your middleware lean, which is exactly what you want for code that runs on every single request.

Here is the simplest possible middleware:

typescript
// middleware.ts (root of your project)
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';

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

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

That matcher config is critical. Without it, middleware runs on every request including static assets, images, and internal Next.js routes. The regex pattern above excludes those. I have seen production apps where someone forgot the matcher and wondered why their Lighthouse score dropped 20 points — middleware was running on every image request.


Authentication Redirects

This is the pattern I reach for first on every project. Before a user hits any protected route, middleware checks their session and redirects unauthenticated users to the login page. No flicker. No loading state on a protected page that immediately bounces them away.

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

const PROTECTED_ROUTES = ['/dashboard', '/settings', '/account', '/orders'];
const AUTH_ROUTES = ['/login', '/register', '/forgot-password'];

function isProtectedRoute(pathname: string): boolean {
  return PROTECTED_ROUTES.some((route) => pathname.startsWith(route));
}

function isAuthRoute(pathname: string): boolean {
  return AUTH_ROUTES.some((route) => pathname.startsWith(route));
}

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

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

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

  return NextResponse.next();
}

A few things I learned the hard way:

Always pass the `callbackUrl`. When a user hits /dashboard/analytics without being logged in, they get redirected to /login?callbackUrl=/dashboard/analytics. After they authenticate, you send them back to exactly where they were trying to go. Skipping this creates a frustrating experience where users always land on the default dashboard.

Do not verify JWTs in middleware. I know it is tempting. You have a JWT, you want to decode it and check expiry. But JWT verification with jsonwebtoken requires Node.js crypto — which is not available in the Edge Runtime. You can use jose (which supports the Web Crypto API), but even then, keep it light. Check for token existence and basic structure in middleware. Do full verification in your API routes or server components where you have the full Node.js runtime.

Cookie names matter. If you use NextAuth.js, the session cookie is next-auth.session-token in development and __Secure-next-auth.session-token in production (when served over HTTPS). I once spent two hours debugging why my auth middleware worked locally but failed in staging. The cookie name was different.

typescript
function getSessionToken(request: NextRequest): string | undefined {
  return (
    request.cookies.get('__Secure-next-auth.session-token')?.value ??
    request.cookies.get('next-auth.session-token')?.value
  );
}

Rate Limiting at the Edge

Rate limiting in middleware is one of the most underused patterns I see. Most developers add rate limiting in their API route handlers, which means the request has already hit your server, been parsed, and started executing before you decide to reject it. Middleware lets you reject abusive traffic before it reaches any of your application code.

The challenge: you cannot use Redis or any external database directly from the Edge Runtime in a simple way. So I use a lightweight in-memory approach for basic protection, combined with headers that downstream services can use for more sophisticated limiting.

typescript
const RATE_LIMIT_WINDOW = 60 * 1000; // 1 minute
const MAX_REQUESTS = 100;

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

function rateLimit(ip: string): { allowed: boolean; remaining: number } {
  const now = Date.now();
  const record = rateLimitMap.get(ip);

  if (!record || now - record.timestamp > RATE_LIMIT_WINDOW) {
    rateLimitMap.set(ip, { count: 1, timestamp: now });
    return { allowed: true, remaining: MAX_REQUESTS - 1 };
  }

  if (record.count >= MAX_REQUESTS) {
    return { allowed: false, remaining: 0 };
  }

  record.count++;
  return { allowed: true, remaining: MAX_REQUESTS - record.count };
}

function getRateLimitResponse(ip: string, response: NextResponse): NextResponse {
  const { allowed, remaining } = rateLimit(ip);

  if (!allowed) {
    return new NextResponse('Too Many Requests', {
      status: 429,
      headers: {
        'Retry-After': '60',
        'X-RateLimit-Limit': String(MAX_REQUESTS),
        'X-RateLimit-Remaining': '0',
      },
    });
  }

  response.headers.set('X-RateLimit-Limit', String(MAX_REQUESTS));
  response.headers.set('X-RateLimit-Remaining', String(remaining));
  return response;
}

Important caveat: In-memory rate limiting only works reliably on single-instance deployments. On Vercel with edge functions, each edge node has its own memory, so a user hitting different edge locations will get separate rate limit counters. For production at scale, use Vercel's KV store or Upstash Redis with their Edge-compatible SDK:

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

const ratelimit = new Ratelimit({
  redis: Redis.fromEnv(),
  limiter: Ratelimit.slidingWindow(100, '1 m'),
  analytics: true,
});

async function rateLimitMiddleware(request: NextRequest): Promise<NextResponse | null> {
  const ip = request.headers.get('x-forwarded-for') ?? '127.0.0.1';
  const { success, limit, remaining, reset } = await ratelimit.limit(ip);

  if (!success) {
    return new NextResponse('Too Many Requests', {
      status: 429,
      headers: {
        'X-RateLimit-Limit': String(limit),
        'X-RateLimit-Remaining': String(remaining),
        'X-RateLimit-Reset': String(reset),
        'Retry-After': String(Math.ceil((reset - Date.now()) / 1000)),
      },
    });
  }

  return null;
}

I use Upstash on every production project now. The free tier handles 10,000 requests per day, and the latency overhead is under 5ms from most edge locations.


Geo-Based Routing

Geo-routing is something I implement on nearly every client project that serves multiple markets. Next.js gives you geolocation data for free in middleware through the request.geo object on Vercel's edge network.

typescript
interface GeoConfig {
  defaultLocale: string;
  countryLocaleMap: Record<string, string>;
  blockedCountries: string[];
}

const GEO_CONFIG: GeoConfig = {
  defaultLocale: 'en',
  countryLocaleMap: {
    LK: 'si',
    JP: 'ja',
    DE: 'de',
    FR: 'fr',
    ES: 'es',
  },
  blockedCountries: [],
};

function handleGeoRouting(request: NextRequest): NextResponse | null {
  const country = request.geo?.country ?? 'US';
  const { pathname } = request.nextUrl;

  // Block restricted countries if needed
  if (GEO_CONFIG.blockedCountries.includes(country)) {
    return new NextResponse('This service is not available in your region', {
      status: 451,
    });
  }

  // Skip if already has locale prefix
  const hasLocalePrefix = /^\/(en|si|ja|de|fr|es)(\/|$)/.test(pathname);
  if (hasLocalePrefix) return null;

  // Skip API routes and static files
  if (pathname.startsWith('/api') || pathname.startsWith('/_next')) return null;

  // Redirect to locale-specific path
  const locale = GEO_CONFIG.countryLocaleMap[country] ?? GEO_CONFIG.defaultLocale;
  if (locale !== GEO_CONFIG.defaultLocale) {
    const url = request.nextUrl.clone();
    url.pathname = `/${locale}${pathname}`;
    return NextResponse.redirect(url);
  }

  return null;
}

On one client project — an e-commerce platform serving both Sri Lanka and the UK — I used geo-routing to redirect users to region-specific pricing pages. A user in Colombo sees LKR pricing. A user in London sees GBP. No query parameters, no client-side detection that flickers. The correct version loads on the first request.

One thing to watch: request.geo is only populated on Vercel's edge network. In local development, it returns undefined. Always provide fallback defaults and test with the x-vercel-ip-country header override:

bash
curl -H "x-vercel-ip-country: LK" http://localhost:3000/products

Bot Detection

Bot traffic is a real problem. On FreshMart, we were seeing 40% of API traffic from scrapers and bots before I added detection in middleware. Basic bot detection does not need AI or expensive third-party services. Pattern matching on user agents and request characteristics catches the vast majority.

typescript
const BOT_USER_AGENTS = [
  'bot', 'crawler', 'spider', 'scraper', 'wget', 'curl',
  'python-requests', 'go-http-client', 'java/', 'php/',
  'headlesschrome', 'phantomjs', 'selenium',
];

const ALLOWED_BOTS = ['googlebot', 'bingbot', 'slurp', 'duckduckbot'];

function detectBot(request: NextRequest): {
  isBot: boolean;
  isAllowedBot: boolean;
} {
  const userAgent = (request.headers.get('user-agent') ?? '').toLowerCase();

  const isAllowedBot = ALLOWED_BOTS.some((bot) => userAgent.includes(bot));
  if (isAllowedBot) return { isBot: true, isAllowedBot: true };

  const isBot =
    BOT_USER_AGENTS.some((bot) => userAgent.includes(bot)) ||
    !request.headers.get('accept-language') ||
    !request.headers.get('accept');

  return { isBot, isAllowedBot: false };
}

function handleBotDetection(request: NextRequest): NextResponse | null {
  const { pathname } = request.nextUrl;

  // Only apply bot detection to API routes
  if (!pathname.startsWith('/api')) return null;

  const { isBot, isAllowedBot } = detectBot(request);

  if (isBot && !isAllowedBot) {
    return new NextResponse('Forbidden', { status: 403 });
  }

  return null;
}

The trick is the ALLOWED_BOTS list. You absolutely want Googlebot and Bingbot to reach your pages — blocking them tanks your SEO. But you do not want random scrapers hammering your API endpoints. I apply bot detection only to /api routes and let legitimate crawlers access pages normally.

For more sophisticated detection, I add request frequency analysis. If a single IP makes 50 requests in 10 seconds with no cookies and a suspicious user agent, that is a bot regardless of what its user agent string claims.


A/B Testing with Middleware

Middleware is the cleanest way to implement A/B testing without client-side flicker. The user gets assigned to a variant before the page renders, so they never see the wrong version first.

typescript
const AB_TEST_COOKIE = 'ab-test-variant';
const VARIANTS = ['control', 'variant-a', 'variant-b'] as const;
type Variant = (typeof VARIANTS)[number];

function getOrAssignVariant(request: NextRequest): {
  variant: Variant;
  response: NextResponse;
} {
  const existingVariant = request.cookies.get(AB_TEST_COOKIE)?.value as Variant;

  if (existingVariant && VARIANTS.includes(existingVariant)) {
    return { variant: existingVariant, response: NextResponse.next() };
  }

  // Assign variant based on weighted distribution
  const random = Math.random();
  let variant: Variant;
  if (random < 0.5) variant = 'control';
  else if (random < 0.8) variant = 'variant-a';
  else variant = 'variant-b';

  const response = NextResponse.next();
  response.cookies.set(AB_TEST_COOKIE, variant, {
    httpOnly: false, // Client needs to read this for analytics
    maxAge: 60 * 60 * 24 * 30, // 30 days
    sameSite: 'lax',
    path: '/',
  });

  return { variant, response };
}

function handleABTest(request: NextRequest): NextResponse {
  const { pathname } = request.nextUrl;

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

  const { variant, response } = getOrAssignVariant(request);

  if (variant !== 'control') {
    const url = request.nextUrl.clone();
    url.pathname = `/pricing/${variant}`;
    return NextResponse.rewrite(url, { headers: response.headers });
  }

  return response;
}

The important detail here is using NextResponse.rewrite() instead of NextResponse.redirect(). A rewrite serves the variant page at the original URL — the user still sees /pricing in their browser, but they are actually viewing /pricing/variant-a. This keeps your URLs clean and prevents users from manually navigating to variant URLs.

I used this exact pattern on a client's SaaS landing page. We tested three pricing layouts over four weeks. Variant A (with annual savings highlighted prominently) converted 23% better than the control. We would never have known that without flicker-free A/B testing at the middleware level.


Chaining Multiple Middleware

Here is where most tutorials fall short. Real applications need authentication, rate limiting, geo-routing, and bot detection running together. Next.js only supports a single middleware.ts file, so you need to chain these concerns yourself.

I use a pipeline pattern:

typescript
type MiddlewareHandler = (
  request: NextRequest
) => NextResponse | null | Promise<NextResponse | null>;

async function runMiddlewarePipeline(
  request: NextRequest,
  handlers: MiddlewareHandler[]
): Promise<NextResponse> {
  for (const handler of handlers) {
    const result = await handler(request);
    if (result) return result;
  }
  return NextResponse.next();
}

export async function middleware(request: NextRequest) {
  return runMiddlewarePipeline(request, [
    handleBotDetection,
    rateLimitMiddleware,
    handleGeoRouting,
    handleAuthentication,
    handleABTest,
  ]);
}

Order matters. I always run bot detection and rate limiting first — there is no point checking authentication for a bot you are about to block. Geo-routing comes next because it might redirect before auth is relevant. Auth comes before A/B testing because you might want different test logic for authenticated versus anonymous users.

Each handler returns null to pass control to the next handler, or returns a NextResponse to short-circuit the pipeline. This keeps each concern isolated and testable.


Performance Impact

Middleware runs on every matched request, so performance is not optional. I have measured the impact across multiple production deployments, and here is what I have found:

Baseline overhead: An empty middleware that just calls NextResponse.next() adds roughly 1-3ms per request on Vercel's edge network. That is negligible.

Auth check (cookie read + path matching): 1-2ms additional. Cookie parsing and string comparison are fast operations.

Rate limiting with Upstash Redis: 3-8ms additional. The Redis round-trip is the bottleneck. On Vercel's edge, Upstash typically responds in under 5ms because their edge nodes are co-located.

Geo-routing: Under 1ms additional. request.geo is pre-populated by Vercel's edge — there is no external lookup happening in your code.

Bot detection (user agent parsing): Under 1ms additional. String comparisons are nearly free.

Total with all patterns combined: 8-15ms overhead on a typical request. For context, the average API route handler takes 50-200ms. Middleware overhead is under 10% of total request time in the worst case.

The rules I follow to keep it fast:

  • Never make more than one external network call in middleware.
  • Never import heavy libraries. Every byte increases cold start time.
  • Use matcher config aggressively to skip routes that do not need middleware.
  • Cache regex compilations at module scope, not inside the handler function.

Common Mistakes

After running middleware in production across dozens of projects, these are the mistakes I see most often — and that I have made myself.

Not setting the matcher. I said this earlier, but it bears repeating. Without a matcher, your middleware runs on /_next/static/*, /favicon.ico, and every image request. Performance tanks.

Importing Node.js modules. The Edge Runtime does not support fs, path, crypto (the Node version), Buffer in some contexts, or any native C++ addon. If your middleware imports a library that imports any of these transitively, it will fail at runtime — not at build time. I always check a library's dependencies before importing it into middleware.

Infinite redirect loops. This happens when your middleware redirects to a URL that also matches the middleware. Classic case: redirecting unauthenticated users to /login, but /login matches the middleware pattern, which checks auth, which redirects to /login, forever. Always exclude your redirect targets from middleware matching.

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

Modifying the response body. Middleware can set headers, cookies, and status codes. It can redirect and rewrite. But it cannot stream a response body efficiently. If you need to inject HTML or modify the response body, use a route handler or a server component instead.

Over-engineering. I have seen middleware files that are 500+ lines with complex business logic, database queries through ORMs, and template rendering. Middleware should be a thin layer of routing logic. If your middleware needs to query a database to make a decision, that decision should probably live in a server component or API route instead.


My middleware.ts Template

This is the production template I start every project with. It includes all the patterns discussed above, properly chained, with clear extension points.

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

// ─── Configuration ────────────────────────────────────────────

const PROTECTED_ROUTES = ['/dashboard', '/settings', '/account'];
const AUTH_ROUTES = ['/login', '/register', '/forgot-password'];
const API_ROUTES_PREFIX = '/api';

const BOT_PATTERNS = [
  'bot', 'crawler', 'spider', 'scraper', 'wget', 'curl',
  'python-requests', 'headlesschrome', 'phantomjs', 'selenium',
];
const ALLOWED_BOTS = ['googlebot', 'bingbot', 'slurp', 'duckduckbot', 'facebookexternalhit'];

// ─── Helpers ──────────────────────────────────────────────────

function getSessionToken(request: NextRequest): string | undefined {
  return (
    request.cookies.get('__Secure-next-auth.session-token')?.value ??
    request.cookies.get('next-auth.session-token')?.value
  );
}

function getClientIp(request: NextRequest): string {
  return (
    request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ??
    request.headers.get('x-real-ip') ??
    '127.0.0.1'
  );
}

// ─── Bot Detection ────────────────────────────────────────────

function handleBotDetection(request: NextRequest): NextResponse | null {
  const { pathname } = request.nextUrl;
  if (!pathname.startsWith(API_ROUTES_PREFIX)) return null;

  const ua = (request.headers.get('user-agent') ?? '').toLowerCase();
  const isAllowed = ALLOWED_BOTS.some((b) => ua.includes(b));
  if (isAllowed) return null;

  const isBot =
    BOT_PATTERNS.some((b) => ua.includes(b)) ||
    (!request.headers.get('accept-language') && !request.headers.get('sec-fetch-mode'));

  if (isBot) {
    return new NextResponse('Forbidden', { status: 403 });
  }

  return null;
}

// ─── Authentication ───────────────────────────────────────────

function handleAuth(request: NextRequest): NextResponse | null {
  const { pathname } = request.nextUrl;
  const token = getSessionToken(request);

  if (PROTECTED_ROUTES.some((r) => pathname.startsWith(r)) && !token) {
    const loginUrl = new URL('/login', request.url);
    loginUrl.searchParams.set('callbackUrl', pathname);
    return NextResponse.redirect(loginUrl);
  }

  if (AUTH_ROUTES.some((r) => pathname.startsWith(r)) && token) {
    return NextResponse.redirect(new URL('/dashboard', request.url));
  }

  return null;
}

// ─── Security Headers ─────────────────────────────────────────

function applySecurityHeaders(response: NextResponse): NextResponse {
  response.headers.set('X-Frame-Options', 'DENY');
  response.headers.set('X-Content-Type-Options', 'nosniff');
  response.headers.set('Referrer-Policy', 'strict-origin-when-cross-origin');
  response.headers.set(
    'Permissions-Policy',
    'camera=(), microphone=(), geolocation=()'
  );
  response.headers.set('X-DNS-Prefetch-Control', 'on');
  return response;
}

// ─── Pipeline ─────────────────────────────────────────────────

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

function runPipeline(
  request: NextRequest,
  handlers: MiddlewareHandler[]
): NextResponse {
  for (const handler of handlers) {
    const result = handler(request);
    if (result) return applySecurityHeaders(result);
  }
  return applySecurityHeaders(NextResponse.next());
}

// ─── Main Middleware ──────────────────────────────────────────

export function middleware(request: NextRequest) {
  return runPipeline(request, [
    handleBotDetection,
    handleAuth,
    // Add more handlers here:
    // handleGeoRouting,
    // handleABTest,
    // handleRateLimit,
  ]);
}

// ─── Matcher ──────────────────────────────────────────────────

export const config = {
  matcher: [
    '/((?!_next/static|_next/image|favicon.ico|robots.txt|sitemap.xml|.*\\.(?:svg|png|jpg|jpeg|gif|webp|avif|ico|css|js)$).*)',
  ],
};

This template gives you bot detection, authentication redirects, and security headers out of the box. The commented-out handlers are ready to uncomment when you need geo-routing, A/B testing, or rate limiting. The pipeline pattern makes it trivial to add or remove concerns without touching other handlers.

I drop this file into every new Next.js project and customize the route arrays and handler logic for the specific application. It has saved me hours of boilerplate on every project since I started using it.


Key Takeaways

  • Middleware runs on the edge before your application code. Use it for routing decisions, not business logic. Keep it lean.
  • Always set the `matcher` config. Without it, you are running middleware on static assets and killing performance.
  • Authentication redirects belong in middleware. No flicker, no loading states on protected pages, and the callbackUrl pattern preserves user intent.
  • Rate limiting at the edge blocks abuse before it reaches your server. Use Upstash Redis for distributed deployments. In-memory works for single instances.
  • Geo-routing with `request.geo` is free on Vercel. Use it for locale redirects and region-specific content without client-side detection.
  • Bot detection on API routes prevents scraper abuse. Always whitelist legitimate search engine crawlers.
  • A/B testing with `rewrite()` eliminates flicker. Users see the original URL while getting served variant content.
  • Chain middleware handlers with a pipeline pattern. Order matters: bot detection first, auth second, everything else after.
  • Total overhead should stay under 15ms. One external network call maximum. No heavy imports. No complex logic.

If you want me to implement any of these patterns in your Next.js application, or you need a custom middleware strategy for your specific use case, check out my services and let's talk.


*Written by Uvin Vindula — Web3/AI engineer building production-grade applications from Sri Lanka and the UK. I write about the patterns I actually use in production, not the ones I read about in documentation. Follow my work at iamuvin.com or reach me at contact@uvin.lk.*

Working on a Web3 or AI project?

Share
Uvin Vindula

Uvin Vindula

Web3 and AI engineer based in Sri Lanka and the UK. Author of The Rise of Bitcoin. Director of Blockchain and Software Solutions at Terra Labz. Founder of uvin.lk — Sri Lanka's Bitcoin education platform with 10,000+ learners.