Middleware vóór weergave

In een traditionele Next.js-applicatie die op Vercel wordt ingezet, wordt elk verzoek het bereikt eerst het CDN en wordt vervolgens – als het niet in de cache zit – doorgestuurd naar de SSR-server. Edge-middleware past tussen deze twee fasen: komt uitgevoerd aan de rand van het Vercel-netwerk, voordat het naar de SSR-server wordt geleid en voordat het antwoord vanuit de cache wordt geserveerd.

Deze positionering is van cruciaal belang: de middleware kan worden gewijzigd het verzoek indienen, de gebruiker omleiden, cookies of headers instellen, en zo gebeurt in een op V8 gebaseerde runtime met een koude start nabij nul. Vercel maatregelen een latentie van P50 < 10 ms voor de meeste middleware in hun randregio's (100+ wereldwijd).

Wat je gaat leren

  • Middleware-structuur en bestandsconventie in Next.js App Router
  • Geolocatie: routebepaling per land, regio en taal
  • Stabiele A/B-testen met permanente cookies
  • Voorzien van vlaggen aan de rand zonder retourvluchten naar de server
  • Lichtgewicht authenticatie in middleware
  • Beperkingen van de Vercel Edge Runtime vergeleken met Node.js
  • Lokale middleware-foutopsporing met next dev

Het bestand middleware.ts

In Next.js (App Router en Pages Router) wordt de middleware in het bestand gedefinieerd middleware.ts (o .js) in de projectroot. De geëxporteerde functie ontvangt een object NextRequest en moet teruggeven een 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)$).*)',
  ],
};

Geolocatie: routering op land en taal

Vercel verrijkt elke aanvraag met geografische informatie via het onderwerp request.geo. In productie (niet in lokale ontwikkeling) zijn ze beschikbaar land, regio en stad:

// 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+).*)'],
};

Stabiele A/B-testen met cookies

Bij A/B-testen aan de rand moet elke gebruiker in zijn eigen groep blijven gedurende de sessie (of langer). De standaardoplossing is wijs de groep toe bij de eerste login en zet de keuze vast in een 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: ['/'],
};

Functievlaggen aan de rand

Met functievlaggen kunt u functies voor subsets inschakelen van gebruikers zonder een nieuwe implementatie. Met edge-middleware kunt u vlaggen lezen vanuit een edge-configuratie (Vercel Edge Config) en pas deze toe voordat u rendert:

// 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+).*)'],
};

Lichtgewicht authenticatie in middleware

Middleware is een geweldige plek om JWT te verifiëren en om te leiden niet-geverifieerde gebruikers voordat het verzoek de server bereikt:

// 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+).*)'],
};

Beperkingen van de Vercel Edge Runtime

De Vercel Edge Runtime is gebaseerd op V8 met een subset van de Node.js API. De belangrijkste beperkingen waar u rekening mee moet houden:

Kenmerkend Edge-runtime Node.js-runtime
Koud begin < 5 ms ~50-500 ms
Maximale time-out Jaren '30 (op Vercel Pro) 15min (op Vercel Enterprise)
Maximaal geheugen 128 MB 3 GB
fs-module (bestandssysteem) Niet beschikbaar Beschikbaar
Ingebouwde Node.js (pad, cryptoknooppunt) Gedeeltelijk Compleet
Webcrypto-API Beschikbaar Beschikbaar (vanaf knooppunt 17)
API ophalen Oorspronkelijk Native (vanaf knooppunt 18)
npm-pakketten met native bindingen Niet ondersteund Ondersteund

Bibliotheken zijn niet compatibel met Edge Runtime

Veel populaire npm-bibliotheken zijn niet compatibel met de Vercel Edge Runtime: Prisma (gebruikt native stuurprogramma's), bcrypt (native binding), scherp (native afbeeldingen). Voordat u een bibliotheek in middleware gebruikt, moet u controleren of deze de edge-runtime ondersteunt. Prisma heeft een edge-compatibele adapter (@prisma/adapter-pg met @neondatabase/serverless), maar niet alle functies zijn beschikbaar.

Debuggen en testen van middleware

De middleware kan lokaal worden getest met next dev. Enkele beperkingen: request.geo dat is altijd zo undefined lokaal (gebruik een nepversie) en Edge Config vereist de omgevingsvariabele 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;
}

Conclusies en volgende stappen

Vercel middleware is een van de krachtigste tools voor pas het gedrag van een Next.js-toepassing zonder wijziging aan logica aan de serverzijde. De positie aan de rand — vóór weergave en cache — maakt het ideaal voor snelle routeringsbeslissingen ze vereisen geen databasetoegang.

De getoonde patronen (geolokalisatie, A/B-testen, functievlaggen, authenticatie) ze dekken de meeste veelvoorkomende gebruiksscenario's. De sleutel tot succes is houd middleware lichtgewicht: complexe logica of trage databasetoegang behoren tot servercomponenten of routehandlers.

Volgende artikelen in de serie

  • Artikel 7: Geografische routing aan de rand: personalisatie Inhoud en AVG-naleving: geofencing met Cloudflare Workers, gelokaliseerde prijzen en blokkeren vanwege lokale regelgeving.
  • Artikel 8: Cache-API en invalidatiestrategieën in Cloudflare Werknemers: verouderd-terwijl-revalideren en ongeldig maken door middel van een sleutel in de productie.
  • Artikel 9: Testen van werknemers op lokaal niveau — Miniflare, Vitest en Wrangler Dev: complete workflow voor testen zonder implementatie.