렌더링 전 미들웨어

Vercel에 배포된 기존 Next.js 애플리케이션에서는 각 요청이 CDN에 먼저 도달한 다음 캐시에 없으면 전달됩니다. SSR 서버에. Edge 미들웨어는 다음 두 단계 사이에 적합합니다. SSR 서버로 라우팅하기 전에 Vercel 네트워크의 가장자리에서 수행됩니다. 그리고 응답이 캐시에서 제공되기 전에.

이 위치 지정은 매우 중요합니다. 미들웨어는 수정할 수 있습니다. 요청, 사용자 리디렉션, 쿠키 또는 헤더 설정 등 콜드 스타트가 0에 가까운 V8 기반 런타임에서 발생합니다. Vercel 대책 대부분의 미들웨어에 대해 P50 < 10ms의 대기 시간 가장자리 지역(전역 100개 이상).

무엇을 배울 것인가

  • Next.js App Router의 미들웨어 구조 및 파일 규칙
  • 지리적 위치: 국가, 지역, 언어별 라우팅
  • 영구 쿠키를 사용한 안정적인 A/B 테스트
  • 서버로의 왕복 없이 에지의 기능 플래그
  • 미들웨어의 경량 인증
  • Node.js와 비교한 Vercel Edge 런타임의 제한 사항
  • 로컬 미들웨어 디버깅 next dev

파일 middleware.ts

Next.js(App Router 및 Pages Router)에서는 미들웨어가 파일에 정의되어 있습니다. middleware.ts (o .js) 프로젝트 루트에 있습니다. 내보낸 함수는 객체를 받습니다. NextRequest 그리고 돌려주어야 한다 에 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)$).*)',
  ],
};

지리적 위치: 국가 및 언어별 라우팅

Vercel은 제목을 통해 지리 정보로 각 요청을 강화합니다. request.geo. 프로덕션(로컬 개발이 아님)에서는 사용 가능합니다. 국가, 지역 및 도시:

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

쿠키를 이용한 안정적인 A/B 테스트

엣지에서의 A/B 테스트에서는 각 사용자가 자신의 그룹에 있어야 합니다. 세션 내내(또는 그 이상). 표준 솔루션은 처음 로그인할 때 그룹을 할당하고 쿠키에 선택 사항을 유지합니다.

// 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: ['/'],
};

가장자리의 기능 플래그

기능 플래그를 사용하면 하위 집합에 대한 기능을 활성화할 수 있습니다. 새로 배포하지 않은 사용자의 수입니다. Edge 미들웨어를 사용하면 플래그를 읽을 수 있습니다. Edge 구성(Vercel Edge Config)에서 렌더링하기 전에 적용합니다.

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

미들웨어의 경량 인증

미들웨어는 JWT를 검증하고 리디렉션할 수 있는 좋은 장소입니다. 요청이 서버에 도달하기 전에 인증되지 않은 사용자:

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

Vercel Edge 런타임의 제한 사항

Vercel Edge 런타임은 Node.js API의 하위 집합이 포함된 V8을 기반으로 합니다. 알아야 할 주요 제한 사항은 다음과 같습니다.

특성 엣지 런타임 Node.js 런타임
콜드 스타트 < 5ms ~50-500ms
최대 시간 초과 30초(Vercel Pro) 15분(Vercel Enterprise의 경우)
최대 메모리 128MB 3GB
fs 모듈(파일 시스템) 사용할 수 없음 사용 가능
Node.js 내장(경로, 암호화 노드) 부분 완벽한
웹 암호화 API 사용 가능 사용 가능(노드 17부터)
API 가져오기 토종의 네이티브(노드 18에서)
네이티브 바인딩이 포함된 npm 패키지 지원되지 않음 지원됨

Edge 런타임과 호환되지 않는 라이브러리

널리 사용되는 많은 npm 라이브러리는 Vercel Edge Runtime과 호환되지 않습니다. Prisma(네이티브 드라이버 사용), bcrypt(네이티브 바인딩), Sharp(네이티브 이미지). 미들웨어에서 라이브러리를 사용하기 전에 해당 라이브러리가 Edge 런타임을 지원하는지 확인하세요. Prisma에는 엣지 호환 어댑터(@prisma/adapter-pg ~와 함께 @neondatabase/serverless), 그러나 모든 기능을 사용할 수 있는 것은 아닙니다.

미들웨어 디버깅 및 테스트

미들웨어는 다음을 사용하여 로컬에서 테스트할 수 있습니다. next dev. 몇 가지 제한사항: request.geo 항상 그렇습니다 undefined 로컬로(모의 사용) Edge Config에는 환경 변수가 필요합니다. 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;
}

결론 및 다음 단계

Vercel 미들웨어는 다음을 위한 가장 강력한 도구 중 하나입니다. 수정 없이 Next.js 애플리케이션의 동작을 사용자 정의 서버 측 논리. 가장자리에서의 위치 - 렌더링 전 및 캐시 — 빠른 라우팅 결정에 이상적입니다. 데이터베이스 액세스가 필요하지 않습니다.

표시된 패턴(지역화, A/B 테스트, 기능 플래그, 인증) 이는 대부분의 일반적인 사용 사례를 다룹니다. 성공의 열쇠는 미들웨어를 경량으로 유지: 복잡한 논리 또는 느린 데이터베이스 액세스 서버 구성 요소 또는 경로 처리기에 속합니다.

시리즈의 다음 기사

  • 제7조: 엣지에서의 지리적 라우팅 — 개인화 콘텐츠 및 GDPR 규정 준수: Cloudflare Workers를 통한 지오펜싱, 현지화된 가격 현지 규정으로 인해 차단됩니다.
  • 제8조: Cloudflare의 캐시 API 및 무효화 전략 작업자: 재검증 중 오래된 작업 및 프로덕션 키에 의한 무효화.
  • 제9조: 현지 작업자 테스트 — Miniflare, Vitest 및 Wrangler Dev: 배포 없이 테스트하기 위한 완전한 워크플로입니다.