Middleware Before Rendering

In a traditional Next.js application deployed on Vercel, each request it reaches the CDN first, then — if not in cache — it is forwarded to the SSR server. Edge middleware fits between these two stages: comes performed at the edge of the Vercel network, before routing to the SSR server and before the response is served from the cache.

This positioning is critical: the middleware can modify the request, redirect the user, set cookies or headers, and all that happens in a V8-based runtime with cold start near zero. Vercel measures a latency of P50 < 10ms for most middleware in their edge regions (100+ global).

What You Will Learn

  • Middleware structure and file convention in Next.js App Router
  • Geolocation: Routing by country, region and language
  • Stable A/B testing with persistent cookies
  • Feature flags at the edge without round-trips to the server
  • Lightweight authentication in the middleware
  • Limitations of the Vercel Edge Runtime compared to Node.js
  • Local middleware debugging with next dev

The File middleware.ts

In Next.js (App Router and Pages Router), the middleware is defined in the file middleware.ts (o .js) in the project root. The exported function receives an object NextRequest and must give back a NextResponse:

// middleware.ts - struttura base

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

// La funzione middleware e chiamata per ogni richiesta che corrisponde al matcher
export function middleware(request: NextRequest): NextResponse {
  // request.nextUrl: URL della richiesta (con searchParams, pathname, etc.)
  // request.headers: header HTTP
  // request.cookies: cookie della richiesta
  // request.geo: geolocalizzazione (solo su Vercel, non in local dev)
  // request.ip: IP del client

  const response = NextResponse.next(); // Prosegui senza modifiche

  // Aggiunge un header custom alla risposta
  response.headers.set('X-Middleware-Applied', 'true');

  return response;
}

// Configura su quali path il middleware viene eseguito
// IMPORTANTE: specificare il matcher migliora le performance
// evitando l'esecuzione per asset statici (_next/static, immagini, etc.)
export const config = {
  matcher: [
    // Esegui su tutte le route eccetto quelle statiche e API
    '/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)',
  ],
};

Geolocation: Routing by Country and Language

Vercel enriches each request with geographic information via the subject request.geo. In production (not in local dev), they are available country, region and city:

// middleware.ts - redirect geografico per lingua

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

// Mappa paese -> locale preferito
const COUNTRY_LOCALE_MAP: Record<string, string> = {
  IT: 'it',
  DE: 'de',
  FR: 'fr',
  ES: 'es',
  PT: 'pt',
  BR: 'pt-BR',
  // Default: 'en' per tutti gli altri paesi
};

const SUPPORTED_LOCALES = ['en', 'it', 'de', 'fr', 'es', 'pt'];
const DEFAULT_LOCALE = 'en';

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

  // Non processare le route che gia hanno un locale nel path
  // Esempio: /it/dashboard non deve essere reindirizzato
  const pathLocale = pathname.split('/')[1];
  if (SUPPORTED_LOCALES.includes(pathLocale)) {
    return NextResponse.next();
  }

  // Determina il locale preferito
  // Priorita: 1) Cookie impostato dall'utente, 2) Paese IP, 3) Accept-Language, 4) Default
  const cookieLocale = request.cookies.get('NEXT_LOCALE')?.value;
  if (cookieLocale && SUPPORTED_LOCALES.includes(cookieLocale)) {
    return rewriteWithLocale(request, cookieLocale);
  }

  const country = request.geo?.country ?? 'US';
  const geoLocale = COUNTRY_LOCALE_MAP[country] ?? DEFAULT_LOCALE;

  // Verifica Accept-Language come fallback
  const acceptLanguage = request.headers.get('Accept-Language') ?? '';
  const browserLocale = parseAcceptLanguage(acceptLanguage, SUPPORTED_LOCALES);

  const finalLocale = geoLocale !== DEFAULT_LOCALE
    ? geoLocale
    : (browserLocale ?? DEFAULT_LOCALE);

  return rewriteWithLocale(request, finalLocale);
}

function rewriteWithLocale(request: NextRequest, locale: string): NextResponse {
  const url = request.nextUrl.clone();
  url.pathname = `/${locale}${url.pathname}`;
  // Rewrite (non redirect) per mantenere l'URL originale nel browser
  // L'utente vede /dashboard ma viene servita /it/dashboard
  return NextResponse.rewrite(url);
}

function parseAcceptLanguage(header: string, supported: string[]): string | null {
  // Parsa "it-IT,it;q=0.9,en-US;q=0.8,en;q=0.7"
  const languages = header
    .split(',')
    .map((lang) => {
      const [code, q] = lang.trim().split(';q=');
      return { code: code.split('-')[0].toLowerCase(), q: parseFloat(q ?? '1') };
    })
    .sort((a, b) => b.q - a.q);

  for (const { code } of languages) {
    if (supported.includes(code)) return code;
  }
  return null;
}

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

Stable A/B Testing with Cookies

A/B testing at the edge requires each user to stay in their own group throughout the session (or longer). The standard solution is assign the group at first login and persist the choice in a cookie:

// middleware.ts - A/B testing per homepage

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

const AB_COOKIE = 'ab_homepage';
const AB_VARIANTS = ['control', 'variant_a', 'variant_b'] as const;
type AbVariant = typeof AB_VARIANTS[number];

// Distribuzione del traffico: 50% control, 25% variant_a, 25% variant_b
const WEIGHTS = [0.5, 0.25, 0.25];

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

  // Applica A/B test solo sulla homepage
  if (pathname !== '/') {
    return NextResponse.next();
  }

  const response = NextResponse.next();

  // Controlla se l'utente ha gia un gruppo assegnato
  let variant = request.cookies.get(AB_COOKIE)?.value as AbVariant | undefined;

  if (!variant || !AB_VARIANTS.includes(variant as AbVariant)) {
    // Assegna un nuovo gruppo deterministicamente
    variant = assignVariant(request);

    // Salva il gruppo nel cookie (1 mese di durata)
    response.cookies.set(AB_COOKIE, variant, {
      maxAge: 30 * 24 * 60 * 60, // 30 giorni
      path: '/',
      httpOnly: false, // Visibile a JavaScript per il tracking
      sameSite: 'lax',
    });
  }

  // Aggiunge il gruppo come header per il server component e analytics
  response.headers.set('X-AB-Variant', variant);

  // Rewrite verso la variante corretta
  if (variant !== 'control') {
    const url = request.nextUrl.clone();
    url.pathname = `/experiments/homepage/${variant}`;
    return NextResponse.rewrite(url, { headers: response.headers });
  }

  return response;
}

function assignVariant(request: NextRequest): AbVariant {
  // Usa l'IP + User-Agent per un'assegnazione deterministica (non cambia tra ricariche)
  const seed = `${request.ip ?? ''}${request.headers.get('User-Agent') ?? ''}`;
  const hash = simpleHash(seed);
  const normalized = (hash % 1000) / 1000; // Normalizza a [0, 1)

  let cumulative = 0;
  for (let i = 0; i < WEIGHTS.length; i++) {
    cumulative += WEIGHTS[i];
    if (normalized < cumulative) return AB_VARIANTS[i];
  }

  return AB_VARIANTS[0]; // Fallback al control
}

function simpleHash(str: string): number {
  let hash = 0;
  for (let i = 0; i < str.length; i++) {
    hash = ((hash << 5) - hash) + str.charCodeAt(i);
    hash |= 0; // Converte a 32-bit integer
  }
  return Math.abs(hash);
}

export const config = {
  matcher: ['/'],
};

Feature Flags at the Edge

Feature flags allow you to enable features for subsets of users without a new deployment. With edge middleware, you can read flags from an edge config (Vercel Edge Config) and apply them before rendering:

// middleware.ts - feature flags con Vercel Edge Config

import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
import { get } from '@vercel/edge-config';

interface FeatureFlags {
  newDashboard: boolean;
  betaFeatures: string[]; // Lista di user IDs con accesso beta
  maintenanceMode: boolean;
}

export async function middleware(request: NextRequest): Promise<NextResponse> {
  // Edge Config ha latenza < 1ms (replicated globally)
  // get() e sincrono in termini di latenza percepita
  const flags = await get<FeatureFlags>('featureFlags');

  // Modalita manutenzione globale
  if (flags?.maintenanceMode) {
    const url = request.nextUrl.clone();
    url.pathname = '/maintenance';
    return NextResponse.rewrite(url);
  }

  // Reindirizza alla nuova dashboard se il flag e attivo
  if (flags?.newDashboard && request.nextUrl.pathname === '/dashboard') {
    const url = request.nextUrl.clone();
    url.pathname = '/dashboard-v2';
    return NextResponse.rewrite(url);
  }

  // Accesso beta: controlla se l'utente e nella lista
  const userId = request.cookies.get('user_id')?.value;
  if (userId && flags?.betaFeatures?.includes(userId)) {
    const response = NextResponse.next();
    response.headers.set('X-Beta-User', 'true');
    return response;
  }

  return NextResponse.next();
}

export const config = {
  matcher: ['/((?!_next|api|.*\\.\\w+).*)'],
};

Lightweight Authentication in Middleware

Middleware is a great place to verify JWT and redirect unauthenticated users before the request reaches the server:

// middleware.ts - verifica JWT leggera all'edge

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

const PUBLIC_PATHS = ['/login', '/register', '/api/auth', '/_next', '/favicon.ico'];
const AUTH_COOKIE = 'session_token';

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

  // Salta le route pubbliche
  if (PUBLIC_PATHS.some((p) => pathname.startsWith(p))) {
    return NextResponse.next();
  }

  const token = request.cookies.get(AUTH_COOKIE)?.value;

  if (!token) {
    return redirectToLogin(request);
  }

  try {
    // Verifica il JWT usando Web Crypto API (disponibile nell'edge runtime)
    const isValid = await verifyJwt(token, request.headers.get('x-jwt-secret') ?? '');

    if (!isValid) {
      return redirectToLogin(request);
    }

    return NextResponse.next();
  } catch {
    return redirectToLogin(request);
  }
}

async function verifyJwt(token: string, secret: string): Promise<boolean> {
  try {
    const [headerB64, payloadB64, signatureB64] = token.split('.');
    if (!headerB64 || !payloadB64 || !signatureB64) return false;

    // Verifica la firma con HMAC-SHA256
    const encoder = new TextEncoder();
    const key = await crypto.subtle.importKey(
      'raw',
      encoder.encode(secret),
      { name: 'HMAC', hash: 'SHA-256' },
      false,
      ['verify']
    );

    const signature = base64UrlDecode(signatureB64);
    const data = encoder.encode(`${headerB64}.${payloadB64}`);

    const isValid = await crypto.subtle.verify('HMAC', key, signature, data);
    if (!isValid) return false;

    // Verifica la scadenza
    const payload = JSON.parse(atob(payloadB64.replace(/-/g, '+').replace(/_/g, '/')));
    if (payload.exp && payload.exp < Date.now() / 1000) return false;

    return true;
  } catch {
    return false;
  }
}

function base64UrlDecode(str: string): ArrayBuffer {
  const base64 = str.replace(/-/g, '+').replace(/_/g, '/');
  const padded = base64 + '=='.slice(0, (4 - base64.length % 4) % 4);
  const binary = atob(padded);
  const bytes = new Uint8Array(binary.length);
  for (let i = 0; i < binary.length; i++) {
    bytes[i] = binary.charCodeAt(i);
  }
  return bytes.buffer;
}

function redirectToLogin(request: NextRequest): NextResponse {
  const url = request.nextUrl.clone();
  url.pathname = '/login';
  url.searchParams.set('callbackUrl', request.nextUrl.pathname);
  return NextResponse.redirect(url);
}

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

Limitations of the Vercel Edge Runtime

The Vercel Edge Runtime is based on V8 with a subset of the Node.js API. The main limitations to be aware of:

Characteristic Edge Runtime Node.js Runtime
Cold start < 5ms ~50-500ms
Maximum timeout 30s (on Vercel Pro) 15min (on Vercel Enterprise)
Maximum memory 128MB 3GB
fs module (filesystem) Not available Available
Node.js built-ins (path, crypto node) Partial Complete
Web Crypto API Available Available (from Node 17)
Fetch API Native Native (from Node 18)
npm packages with native bindings Not supported Supported

Libraries Incompatible with Edge Runtime

Many popular npm libraries are not compatible with the Vercel Edge Runtime: Prisma (uses native drivers), bcrypt (native binding), sharp (native images). Before using a library in middleware, verify that it supports the edge runtime. Prisma has an edge-compatible adapter (@prisma/adapter-pg with @neondatabase/serverless), but not all features are available.

Middleware Debugging and Testing

The middleware is testable locally with next dev. Some limitations: request.geo it always is undefined locally (use a mock), and Edge Config requires the environment variable EDGE_CONFIG:

// middleware.ts con mock per development locale

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

function getGeo(request: NextRequest): { country?: string; region?: string } {
  // In development, simula la geolocalizzazione tramite header custom
  // Utile per testare diverse regioni senza deploy
  if (process.env.NODE_ENV === 'development') {
    return {
      country: request.headers.get('X-Mock-Country') ?? 'US',
      region: request.headers.get('X-Mock-Region') ?? 'CA',
    };
  }

  return {
    country: request.geo?.country,
    region: request.geo?.region,
  };
}

export function middleware(request: NextRequest): NextResponse {
  const { country, region } = getGeo(request);

  // Logica di routing geo-based
  const response = NextResponse.next();
  response.headers.set('X-Country', country ?? 'unknown');
  response.headers.set('X-Region', region ?? 'unknown');

  return response;
}

Conclusions and Next Steps

Vercel middleware is one of the most powerful tools for customize the behavior of a Next.js application without modification server-side logic. Its position at the edge — before rendering and cache — makes it ideal for quick routing decisions that they do not require database access.

The patterns shown (geolocalization, A/B testing, feature flags, authentication) they cover most of the common use cases. The key to success is keep middleware lightweight: complex logic or slow database accesses belong to Server Components or Route Handlers.

Next Articles in the Series

  • Article 7: Geographic Routing at the Edge — Personalization Content and GDPR Compliance: geo-fencing with Cloudflare Workers, localized pricing and block due to local regulations.
  • Article 8: Cache API and Invalidation Strategies in Cloudflare Workers: stale-while-revalidate and invalidation by key in production.
  • Article 9: Testing of Workers in Local — Miniflare, Vitest and Wrangler Dev: complete workflow for testing without deployment.