レンダリング前のミドルウェア

Vercel にデプロイされた従来の Next.js アプリケーションでは、各リクエストは 最初に CDN に到達し、キャッシュにない場合は転送されます。 SSRサーバーに送信します。エッジ ミドルウェアは次の 2 つの段階の間に収まります。 SSR サーバーにルーティングする前に、Vercel ネットワークのエッジで実行されます。 応答がキャッシュから提供される前。

この位置付けは重要です。ミドルウェアは変更することができます。 リクエスト、ユーザーのリダイレクト、Cookie またはヘッダーなどの設定 V8 ベースのランタイムでコールド スタートがゼロに近い状態で発生します。ヴェルセル対策 ほとんどのミドルウェアのレイテンシは P50 < 10ms エッジ領域 (100 以上のグローバル)。

何を学ぶか

  • Next.js App Router のミドルウェア構造とファイル規則
  • 地理位置情報: 国、地域、言語によるルーティング
  • 永続的な Cookie による安定した 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+).*)'],
};

Cookie を使用した安定した A/B テスト

エッジでの A/B テストでは、各ユーザーが独自のグループに留まる必要があります セッション全体(またはそれ以上)。標準的な解決策は、 最初のログイン時にグループを割り当て、その選択を 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: ['/'],
};

エッジの機能フラグ

機能フラグを使用すると、サブセットの機能を有効にすることができます 新しい展開を行わないユーザーの数。エッジミドルウェアを使用すると、フラグを読み取ることができます エッジ設定 (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 ランタイムと互換性がありません。 Prisma (ネイティブ ドライバーを使用)、bcrypt (ネイティブ バインディング)、sharp (ネイティブ イメージ)。 ミドルウェアでライブラリを使用する前に、それがエッジ ランタイムをサポートしていることを確認してください。 Prisma にはエッジ互換アダプター (@prisma/adapter-pg con @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 ミドルウェアは、最も強力なツールの 1 つです。 Next.js アプリケーションの動作を変更せずにカスタマイズする サーバー側のロジック。レンダリング前のエッジでの位置 およびキャッシュ — ルーティングを迅速に決定するのに最適です。 データベースへのアクセスは必要ありません。

表示されるパターン (地理位置情報、A/B テスト、機能フラグ、認証) 一般的な使用例のほとんどをカバーしています。成功の鍵は ミドルウェアを軽量に保つ: 複雑なロジックまたは遅いデータベース アクセス サーバーコンポーネントまたはルートハンドラーに属します。

シリーズの次の記事

  • 第7条: エッジでの地理的ルーティング — パーソナライゼーション コンテンツとGDPRコンプライアンス: Cloudflare Workersによるジオフェンシング、ローカライズされた価格設定 地域の規制によりブロックされます。
  • 第8条: CloudflareのキャッシュAPIと無効化戦略 ワーカー: 本番環境での再検証中の失効とキーによる無効化。
  • 第9条: 地元の労働者の検査 — Miniflare、Vitest Wrangler Dev: 導入を必要としないテストのための完全なワークフロー。