Next.js Middleware i Edge Runtime: Przewodnik po zaawansowanych wzorcach

Zaawansowane wzorce middleware w Next.js — od uwierzytelniania JWT na Edge Runtime, przez rate limiting z Upstash Redis, po bezpieczeństwo po CVE-2025-29927. Praktyczne przykłady kodu TypeScript.

Wprowadzenie: Czym właściwie jest Next.js Middleware i dlaczego warto go poznać

Next.js Middleware, czyli oprogramowanie pośredniczące w ekosystemie Next.js, to mechanizm pozwalający na wykonywanie kodu przed zakończeniem przetwarzania żądania HTTP. Innymi słowy — działa na warstwie pomiędzy przeglądarką użytkownika a właściwą logiką aplikacji, przechwytując, modyfikując i przekierowując żądania zanim w ogóle trafią do komponentów serwerowych, tras API czy statycznych zasobów.

Brzmi abstrakcyjnie? W praktyce to jedno z najpotężniejszych narzędzi w arsenale programisty Next.js w 2026 roku.

Wraz z dojrzewaniem App Routera i rosnącą popularnością Edge Runtime, middleware stał się fundamentalnym elementem architektury nowoczesnych aplikacji webowych. Wykonywany jest na Edge Runtime domyślnie, co oznacza, że kod uruchamia się w lokalizacjach geograficznie bliskich użytkownikowi — a nie na jednym centralnym serwerze gdzieś za oceanem.

W architekturze App Routera middleware stanowi pierwszą linię przetwarzania żądań. Zanim żądanie trafi do layoutu, strony czy trasy API, middleware może je zmodyfikować, przekierować użytkownika, ustawić nagłówki odpowiedzi lub całkowicie zablokować dostęp. To czyni go idealnym miejscem do implementacji uwierzytelniania, internacjonalizacji, testów A/B czy rate limitingu.

Warto podkreślić, że middleware nie jest żadną nowością w świecie programowania webowego — to wzorzec dobrze znany z Express.js, Koa, Django i wielu innych frameworków. Jednak implementacja w Next.js wyróżnia się natywną integracją z Edge Runtime. To fundamentalna zmiana w porównaniu z tradycyjnym middleware wykonywanym na jednym centralnym serwerze.

Popularność tego rozwiązania wzrosła szczególnie po upowszechnieniu się wzorca Server Components. Programiści (włącznie ze mną) szybko odkryli, że middleware jest idealnym miejscem do logiki wykonywanej przed jakimkolwiek renderowaniem — niezależnie od tego, czy chodzi o stronę statyczną, dynamiczną, czy trasę API.

W tym artykule przejdziemy przez zaawansowane wzorce middleware — od podstawowej konfiguracji, przez bezpieczeństwo i wydajność, aż po wdrożenie produkcyjne. Każdy wzorzec zilustruję praktycznym kodem TypeScript, gotowym do użycia w prawdziwych projektach. No to zaczynajmy.

Podstawy middleware: Jak działa middleware.ts

Middleware w Next.js definiujemy w pliku middleware.ts (lub middleware.js) umieszczonym w katalogu głównym projektu — obok katalogu app lub src. Jeśli używasz struktury z katalogiem src, plik powinien znajdować się w src/middleware.ts. I tu ważna uwaga: projekt może zawierać tylko jeden plik middleware. Do tego ograniczenia wrócimy w sekcji o kompozycji wielu funkcji.

Zrozumienie, jak middleware współdziała z resztą architektury, jest kluczowe. Middleware wykonuje się przed rozwiązaniem trasy, co oznacza dostęp do surowego żądania HTTP zanim framework zdecyduje, który komponent powinien zostać wyrenderowany. Dzięki temu możemy wpływać na to, jaka treść zostanie ostatecznie dostarczona użytkownikowi — poprzez przekierowania, przepisywanie ścieżek (rewrite) lub modyfikację nagłówków.

Cykl życia żądania w kontekście middleware wygląda tak:

  1. Przeglądarka wysyła żądanie HTTP do serwera Next.js.
  2. Middleware przechwytuje żądanie przed dopasowaniem do trasy.
  3. Middleware może zmodyfikować żądanie, odpowiedź, przekierować użytkownika lub zwrócić odpowiedź bezpośrednio.
  4. Jeśli middleware nie przerwie cyklu, żądanie trafia do odpowiedniej trasy (strony, layoutu lub API route).

Zobaczmy podstawową strukturę pliku middleware:

// middleware.ts - Podstawowa konfiguracja middleware w Next.js
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';

export function middleware(request: NextRequest) {
  // Odczytanie informacji o żądaniu
  const { pathname, searchParams } = request.nextUrl;
  const userAgent = request.headers.get('user-agent') ?? 'nieznany';

  // Logowanie żądania (w Edge Runtime nie ma dostępu do console.log
  // w tradycyjny sposób, ale Vercel zbiera logi z Edge Functions)
  console.log(`[Middleware] ${request.method} ${pathname}`);

  // Tworzenie zmodyfikowanej odpowiedzi z dodatkowymi nagłówkami
  const response = NextResponse.next();

  // Dodanie niestandardowych nagłówków bezpieczeństwa
  response.headers.set('X-Content-Type-Options', 'nosniff');
  response.headers.set('X-Frame-Options', 'DENY');
  response.headers.set('Referrer-Policy', 'strict-origin-when-cross-origin');

  return response;
}

// Konfiguracja matchera - middleware będzie uruchamiany
// tylko dla pasujących ścieżek
export const config = {
  matcher: [
    // Dopasuj wszystkie ścieżki oprócz statycznych zasobów
    '/((?!_next/static|_next/image|favicon.ico).*)',
  ],
};

Obiekt NextRequest rozszerza standardowy interfejs Request z Web API o dodatkowe właściwości specyficzne dla Next.js. Najważniejsze z nich to nextUrl (obiekt URL z informacjami o bazowej ścieżce i parametrach), cookies (wygodne API do odczytu i zapisu ciasteczek) oraz geo (dane geolokalizacyjne na platformie Vercel). Ważny szczegół: NextRequest jest niemutowalny — nie można bezpośrednio modyfikować nagłówków przychodzącego żądania. Zamiast tego modyfikujemy obiekt odpowiedzi NextResponse.

Middleware może zwrócić jedną z kilku odpowiedzi:

  • NextResponse.next() — kontynuuj przetwarzanie żądania normalnie
  • NextResponse.redirect(url) — przekieruj użytkownika na inny adres
  • NextResponse.rewrite(url) — przepisz żądanie na inną trasę (URL w przeglądarce się nie zmieni)
  • NextResponse.json(data) — zwróć odpowiedź JSON bezpośrednio z middleware
  • new Response(body, options) — zwróć dowolną odpowiedź HTTP

Konfiguracja matchera: Precyzyjne dopasowywanie tras

Konfiguracja matchera (config.matcher) pozwala określić, dla jakich ścieżek middleware powinien być uruchamiany. Jest to kluczowe z punktu widzenia wydajności — middleware powinien działać tylko tam, gdzie jest naprawdę potrzebny.

Bez matchera middleware uruchamia się dla każdego żądania, łącznie z plikami statycznymi i obrazami. To zwykle nie jest to, czego chcemy.

Matcher jest ewaluowany na etapie budowania aplikacji, więc Next.js może zoptymalizować routing i pominąć middleware dla ścieżek, które nie pasują. To istotna różnica w porównaniu z filtrowaniem wewnątrz samej funkcji — matcher działa na poziomie infrastruktury, zanim jakikolwiek JavaScript zostanie wykonany.

Podstawowe wzorce matchera

Matcher obsługuje składnię path-to-regexp, co daje sporą elastyczność:

// middleware.ts - Zaawansowana konfiguracja matcherów
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';

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

export const config = {
  matcher: [
    // Dopasuj konkretną ścieżkę
    '/dashboard',

    // Dopasuj ścieżkę z dynamicznym segmentem
    '/dashboard/:path',

    // Dopasuj ścieżkę z dowolną liczbą segmentów (wildcard)
    '/api/:path*',

    // Dopasuj wszystkie ścieżki zaczynające się od /admin
    '/admin/:path*',

    // Zaawansowany matcher z wyrażeniem regularnym
    // Dopasuj wszystko oprócz zasobów statycznych i plików graficznych
    '/((?!_next/static|_next/image|favicon\\.ico|.*\\.png$|.*\\.jpg$).*)',
  ],
};

Warunkowe matchery z has i missing

Next.js obsługuje też zaawansowane matchery warunkowe — pozwalają dopasować żądania na podstawie obecności (lub braku) nagłówków, ciasteczek i parametrów zapytania. Szczerze mówiąc, to jedna z moich ulubionych funkcji:

// middleware.ts - Matchery warunkowe z has/missing
export const config = {
  matcher: [
    {
      // Dopasuj ścieżki /api/* tylko gdy żądanie zawiera
      // nagłówek Authorization
      source: '/api/:path*',
      has: [
        {
          type: 'header',
          key: 'authorization',
        },
      ],
    },
    {
      // Dopasuj ścieżki /dashboard/* gdy NIE ma ciasteczka sesji
      // (użytkownik niezalogowany)
      source: '/dashboard/:path*',
      missing: [
        {
          type: 'cookie',
          key: 'session-token',
        },
      ],
    },
    {
      // Dopasuj gdy parametr zapytania 'preview' ma wartość 'true'
      source: '/:path*',
      has: [
        {
          type: 'query',
          key: 'preview',
          value: 'true',
        },
      ],
    },
    {
      // Dopasuj żądania z określonego hosta (przydatne w aplikacjach
      // wielodomenowych)
      source: '/:path*',
      has: [
        {
          type: 'host',
          value: 'admin.mojaaplikacja.pl',
        },
      ],
    },
  ],
};

Warunkowe matchery są niezwykle użyteczne, bo pozwalają zmniejszyć logikę warunkową wewnątrz samej funkcji middleware. Zamiast sprawdzać warunki w kodzie, definiujemy je deklaratywnie w konfiguracji. Na przykład matcher z missing dla ciasteczka sesji uruchomi middleware uwierzytelniania wyłącznie dla niezalogowanych użytkowników — nie trzeba weryfikować tokenu przy każdym żądaniu. To spora optymalizacja, bo nawet lokalna weryfikacja JWT wymaga operacji kryptograficznych.

Matchery z typem host są szczególnie przydatne w architekturach wielodomenowych, gdzie jedna aplikacja obsługuje wiele domen. Dzięki nim możesz mieć odrębną logikę middleware dla każdej domeny bez rozdzielania kodu na osobne projekty.

Wykluczanie tras

Częstym wzorcem jest uruchamianie middleware dla wszystkich tras z wyjątkiem pewnych ścieżek. Zazwyczaj chcemy wykluczyć:

  • _next/static — pliki statyczne wygenerowane przez Next.js
  • _next/image — zoptymalizowane obrazy
  • favicon.ico — ikona strony
  • Pliki z rozszerzeniami graficznymi (.png, .jpg, .svg, .webp)

Wyrażenie regularne z negacją lookahead to standardowe podejście.

Edge Runtime: Wykonywanie kodu na krawędzi sieci

Edge Runtime to lekkie środowisko uruchomieniowe oparte na standardach Web API, zaprojektowane do wykonywania kodu w globalnie rozproszonych lokalizacjach (tzw. edge locations). Nazwa "Edge" odnosi się do krawędzi sieci CDN — serwerów rozmieszczonych w wielu lokalizacjach na całym świecie, jak najbliżej użytkowników końcowych.

W przeciwieństwie do tradycyjnego Node.js, Edge Runtime oferuje znacznie krótsze czasy zimnego startu i niższe opóźnienia — ale kosztem ograniczonego zestawu dostępnych API.

Koncepcja Edge Computing nie jest nowa — CDN-y od lat serwują statyczne zasoby z lokalizacji bliskich użytkownikom. Jednak Edge Runtime rozszerza tę ideę o możliwość wykonywania dynamicznego kodu. Zamiast tylko dostarczać pliki statyczne, serwery edge mogą teraz uruchamiać logikę biznesową, weryfikować tokeny, przepisywać żądania i podejmować decyzje o routingu. Wszystko bez opóźnień wynikających z komunikacji z centralnym serwerem.

Kluczowe różnice między Edge Runtime a Node.js Runtime

Warto znać te różnice, zanim zaczniesz pisać swój middleware:

  • Rozmiar pakietu — Edge Runtime ma limit rozmiaru kodu (zazwyczaj 1-4 MB na Vercel), co wymusza lekkość.
  • Dostępne API — bazuje na Web API (fetch, Request, Response, crypto, TextEncoder), ale nie obsługuje większości modułów Node.js (fs, path, net, child_process).
  • Czas wykonania — ograniczony do 30 sekund na Vercel, ale w praktyce middleware powinien kończyć pracę w milisekundach.
  • Brak dostępu do systemu plików — nie można czytać ani zapisywać plików.
  • Ograniczone biblioteki npm — tylko te kompatybilne z Edge Runtime (np. jose zamiast jsonwebtoken).

Zalety Edge Runtime

Pomimo tych ograniczeń, korzyści są naprawdę znaczące:

  • Minimalne opóźnienia — kod wykonywany jest najbliżej użytkownika, co redukuje latency nawet o 80-90%.
  • Szybki zimny start — Edge Functions uruchamiają się w milisekundach, bez typowego opóźnienia cold startu.
  • Globalna dystrybucja — na Vercel kod jest automatycznie wdrażany w ponad 30 regionach.
  • Automatyczna skalowalność — bez konieczności konfiguracji infrastruktury.

I tu kluczowa informacja: middleware w Next.js zawsze działa na Edge Runtime. Nie ma możliwości uruchomienia go w środowisku Node.js. To fundamentalna decyzja architektoniczna. Jeśli potrzebujesz pełnego dostępu do API Node.js (np. komunikacji z bazą danych przez natywne sterowniki), przenieś tę logikę do Route Handlers lub Server Components.

Praktyczna konsekwencja? Musisz starannie dobierać biblioteki. Przed dodaniem jakiejkolwiek zależności sprawdź, czy jest kompatybilna z Edge Runtime. Popularne biblioteki jak bcrypt, jsonwebtoken czy sterowniki baz danych (pg, mysql2) po prostu nie zadziałają. Alternatywy kompatybilne z Web API — jak jose zamiast jsonwebtoken — to jedyna droga.

Uwierzytelnianie i autoryzacja w middleware

To chyba najczęstsze zastosowanie middleware i szczerze — ciężko się dziwić. Ponieważ middleware działa przed załadowaniem jakiejkolwiek strony, skutecznie blokuje dostęp do chronionych zasobów zanim serwer w ogóle zacznie renderować komponent React. Szybsze przekierowanie niezalogowanych użytkowników, mniejsze zużycie zasobów. Win-win.

Ale — jak dowiemy się z kolejnej sekcji — middleware nie powinien być jedyną warstwą zabezpieczeń.

Kluczowe jest rozróżnienie między weryfikacją tokenu (czy token jest prawidłowy i nie wygasł) a autoryzacją (czy użytkownik ma uprawnienia do żądanego zasobu). Middleware jest idealny do szybkiej weryfikacji JWT — wymaga jedynie kryptograficznego sprawdzenia podpisu, bez komunikacji z bazą danych. Bardziej zaawansowana autoryzacja powinna być realizowana na poziomie logiki aplikacji, gdzie dostępny jest pełen kontekst.

Weryfikacja tokenów JWT z biblioteką jose

Biblioteka jose jest kompatybilna z Edge Runtime (w przeciwieństwie do popularnego jsonwebtoken). Oto kompletna implementacja:

// middleware.ts - Uwierzytelnianie JWT z biblioteką jose
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
import { jwtVerify, importSPKI, type JWTPayload } from 'jose';

// Interfejs opisujący strukturę payloadu naszego tokenu
interface TokenPayload extends JWTPayload {
  sub: string;        // Identyfikator użytkownika
  email: string;      // Adres e-mail
  role: 'admin' | 'user' | 'moderator'; // Rola użytkownika
  permissions: string[]; // Lista uprawnień
}

// Klucz symetryczny do weryfikacji tokenów (w produkcji
// pobierz z zmiennych środowiskowych)
const JWT_SECRET = new TextEncoder().encode(
  process.env.JWT_SECRET ?? 'domyslny-klucz-tylko-do-developmentu'
);

// Ścieżki publiczne niewymagające uwierzytelnienia
const PUBLIC_PATHS = [
  '/login',
  '/rejestracja',
  '/api/auth/login',
  '/api/auth/register',
  '/api/health',
  '/',
];

// Mapa wymaganych ról dla poszczególnych ścieżek
const ROLE_REQUIREMENTS: Record<string, string[]> = {
  '/admin': ['admin'],
  '/moderacja': ['admin', 'moderator'],
  '/ustawienia/globalne': ['admin'],
};

async function verifyToken(token: string): Promise<TokenPayload | null> {
  try {
    const { payload } = await jwtVerify(token, JWT_SECRET, {
      algorithms: ['HS256'],
      // Weryfikacja wystawcy i odbiorcy tokenu
      issuer: 'mojaaplikacja.pl',
      audience: 'mojaaplikacja-frontend',
    });
    return payload as TokenPayload;
  } catch (error) {
    // Token wygasł, jest nieprawidłowy lub został sfałszowany
    return null;
  }
}

function checkRoleAccess(pathname: string, userRole: string): boolean {
  // Sprawdź czy ścieżka wymaga określonej roli
  for (const [path, allowedRoles] of Object.entries(ROLE_REQUIREMENTS)) {
    if (pathname.startsWith(path)) {
      return allowedRoles.includes(userRole);
    }
  }
  // Domyślnie zezwól na dostęp zalogowanym użytkownikom
  return true;
}

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

  // Pomiń weryfikację dla ścieżek publicznych
  if (PUBLIC_PATHS.some(path => pathname === path || pathname.startsWith(path + '/'))) {
    return NextResponse.next();
  }

  // Pobierz token z ciasteczka lub nagłówka Authorization
  const tokenFromCookie = request.cookies.get('auth-token')?.value;
  const tokenFromHeader = request.headers
    .get('authorization')
    ?.replace('Bearer ', '');
  const token = tokenFromCookie ?? tokenFromHeader;

  // Brak tokenu - przekieruj do logowania
  if (!token) {
    const loginUrl = new URL('/login', request.url);
    loginUrl.searchParams.set('powrot', pathname);
    return NextResponse.redirect(loginUrl);
  }

  // Zweryfikuj token
  const payload = await verifyToken(token);

  if (!payload) {
    // Token nieprawidłowy - wyczyść ciasteczko i przekieruj
    const loginUrl = new URL('/login', request.url);
    loginUrl.searchParams.set('powrot', pathname);
    loginUrl.searchParams.set('przyczyna', 'sesja-wygasla');
    const response = NextResponse.redirect(loginUrl);
    response.cookies.delete('auth-token');
    return response;
  }

  // Sprawdź uprawnienia oparte na rolach
  if (!checkRoleAccess(pathname, payload.role)) {
    return NextResponse.json(
      { blad: 'Brak uprawnień do tego zasobu' },
      { status: 403 }
    );
  }

  // Przekaż informacje o użytkowniku do kolejnych warstw
  // poprzez nagłówki żądania
  const response = NextResponse.next();
  response.headers.set('x-user-id', payload.sub);
  response.headers.set('x-user-role', payload.role);
  response.headers.set('x-user-email', payload.email);

  return response;
}

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

Powyższy kod implementuje pełny wzorzec uwierzytelniania i autoryzacji opartej na rolach (RBAC). Kilka ważnych szczegółów:

  • Używamy jwtVerify z biblioteki jose, kompatybilnej z Edge Runtime.
  • Token może pochodzić z ciasteczka (dla przeglądarki) lub nagłówka Authorization (dla API).
  • Informacje o użytkowniku przekazywane są dalej przez nagłówki żądania.
  • Kontrola dostępu oparta na rolach jest konfigurowalna przez mapę ROLE_REQUIREMENTS.

Bezpieczeństwo: Lekcje z CVE-2025-29927

W marcu 2025 roku ujawniona została krytyczna podatność CVE-2025-29927 o wyniku CVSS 9.1, która — nie przesadzam — wstrząsnęła całą społecznością Next.js. Dotknęła wszystkie wersje od 11.1.4 do 15.2.2. To oznaczało, że ogromna liczba aplikacji produkcyjnych była potencjalnie narażona na atak.

Ta podatność powinna być obowiązkową lekturą dla każdego, kto buduje middleware w Next.js.

Na czym polegała podatność

Chodziło o wewnętrzny nagłówek x-middleware-subrequest, który Next.js wykorzystywał do śledzenia rekursywnych wywołań middleware i zapobiegania nieskończonym pętlom. Gdy middleware wywoływał wewnętrzne żądanie, framework dodawał ten nagłówek, a przy kolejnych wywołaniach sprawdzał jego obecność.

Problem? Atakujący mógł ręcznie dodać ten nagłówek do zewnętrznego żądania, co powodowało, że framework pomijał wykonanie middleware. W praktyce cała logika uwierzytelniania mogła zostać ominięta jednym żądaniem HTTP:

// UWAGA: Ten kod ilustruje atak - NIE używaj go w złośliwy sposób!
// Tak wyglądało żądanie omijające middleware przed łatką:
//
// curl -H "x-middleware-subrequest: middleware:middleware:middleware:middleware:middleware" \
//   https://cel-ataku.com/panel-admina
//
// Framework widział nagłówek i myślał, że to wewnętrzne żądanie,
// pomijając całkowicie wykonanie middleware (w tym weryfikację tokenu JWT).

Wnioski i podejście obrona w głąb (defense-in-depth)

Ta podatność jasno pokazała, że middleware nigdy nie powinien być jedyną warstwą zabezpieczeń. Oto kluczowe zasady:

  1. Weryfikuj uprawnienia na poziomie serwera — każda trasa API i komponent serwerowy powinien niezależnie weryfikować tożsamość użytkownika.
  2. Weryfikuj uprawnienia na poziomie bazy danych — stosuj polityki Row Level Security (RLS) w PostgreSQL lub odpowiedniki w innych bazach.
  3. Middleware jako warstwa optymalizacyjna — traktuj middleware jako mechanizm poprawiający UX (szybkie przekierowania, wcześniejsze odrzucanie żądań), a nie jako główną barierę bezpieczeństwa.
  4. Aktualizuj Next.js regularnie — podatność naprawiono w wersjach 14.2.25, 15.2.3 i późniejszych.
  5. Filtruj wewnętrzne nagłówki — na reverse proxy (np. nginx) blokuj nagłówki zaczynające się od x-middleware-.

Poniżej wzorzec obrony w głąb, w którym weryfikacja odbywa się na wielu poziomach:

// lib/auth/verify-session.ts
// Współdzielona funkcja weryfikacji sesji używana zarówno
// w middleware, jak i w Server Components oraz Route Handlers
import { jwtVerify } from 'jose';
import { cookies } from 'next/headers';

const JWT_SECRET = new TextEncoder().encode(process.env.JWT_SECRET!);

export interface SessionData {
  userId: string;
  email: string;
  role: string;
}

// Funkcja weryfikująca sesję - wywoływana niezależnie na każdym poziomie
export async function verifySession(): Promise<SessionData | null> {
  const cookieStore = await cookies();
  const token = cookieStore.get('auth-token')?.value;

  if (!token) return null;

  try {
    const { payload } = await jwtVerify(token, JWT_SECRET);
    return {
      userId: payload.sub as string,
      email: payload.email as string,
      role: payload.role as string,
    };
  } catch {
    return null;
  }
}

// -----------------------------------------------------------
// app/admin/page.tsx - Server Component z niezależną weryfikacją
// -----------------------------------------------------------
// import { verifySession } from '@/lib/auth/verify-session';
// import { redirect } from 'next/navigation';
//
// export default async function AdminPage() {
//   // Niezależna weryfikacja - nawet jeśli middleware zostanie
//   // ominięty, ta strona nadal sprawdzi uprawnienia
//   const session = await verifySession();
//   if (!session || session.role !== 'admin') {
//     redirect('/login');
//   }
//   return <div>Panel administracyjny</div>;
// }

// -----------------------------------------------------------
// app/api/users/route.ts - Route Handler z niezależną weryfikacją
// -----------------------------------------------------------
// import { verifySession } from '@/lib/auth/verify-session';
// import { NextResponse } from 'next/server';
//
// export async function GET() {
//   const session = await verifySession();
//   if (!session || session.role !== 'admin') {
//     return NextResponse.json(
//       { blad: 'Brak autoryzacji' },
//       { status: 401 }
//     );
//   }
//   // ... logika pobierania użytkowników
// }

Podejście obrona w głąb zapewnia, że nawet przy odkryciu nowej podatności w middleware, aplikacja pozostaje zabezpieczona. Każda warstwa działa niezależnie — jeśli atakujący ominie middleware, natrafi na weryfikację w Server Component; jeśli obejdzie i tę warstwę, zostanie zablokowany przez RLS w bazie danych. Takie podejście dramatycznie zwiększa koszt ataku.

Warto też rozważyć dodatkowy monitoring. Jeśli żądanie dociera do Route Handlera bez nagłówków ustawianych przez middleware (takich jak x-user-id), może to wskazywać na próbę ominięcia middleware. Logowanie takich zdarzeń pozwala na wczesne wykrycie ataków.

Rate limiting na Edge: Ograniczanie częstotliwości żądań

Rate limiting to kluczowy mechanizm ochrony API przed nadużyciami, atakami brute-force i przeciążeniem serwera. Bez niego pojedynczy złośliwy klient może wyczerpać zasoby serwera i uniemożliwić dostęp pozostałym użytkownikom. Implementacja w middleware pozwala odrzucać nadmierne żądania zanim dotrą do logiki aplikacji.

W kontekście Edge Runtime jest pewien haczyk: tradycyjne rozwiązania oparte na lokalnej pamięci serwera nie działają, bo każde żądanie może być obsługiwane przez inną instancję w innej lokalizacji. Potrzebujemy zewnętrznego, globalnie dostępnego magazynu danych. I tu wchodzi Upstash Redis — zaprojektowany specjalnie z myślą o środowiskach edge.

Implementacja z @upstash/ratelimit i Redis

Biblioteka @upstash/ratelimit jest stworzona do pracy w Edge Runtime. Używa Upstash Redis jako magazynu do śledzenia liczby żądań:

// middleware.ts - Rate limiting z @upstash/ratelimit
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
import { Ratelimit } from '@upstash/ratelimit';
import { Redis } from '@upstash/redis';

// Inicjalizacja klienta Redis (dane z zmiennych środowiskowych)
const redis = new Redis({
  url: process.env.UPSTASH_REDIS_REST_URL!,
  token: process.env.UPSTASH_REDIS_REST_TOKEN!,
});

// Konfiguracja limitera z algorytmem przesuwnego okna (sliding window)
// Pozwala na 60 żądań w oknie 60 sekund
const generalLimiter = new Ratelimit({
  redis,
  limiter: Ratelimit.slidingWindow(60, '60 s'),
  analytics: true, // Włącz analitykę w panelu Upstash
  prefix: 'ratelimit:general',
});

// Bardziej restrykcyjny limiter dla endpointów uwierzytelniania
// (ochrona przed atakami brute-force)
const authLimiter = new Ratelimit({
  redis,
  limiter: Ratelimit.slidingWindow(5, '60 s'), // 5 prób na minutę
  analytics: true,
  prefix: 'ratelimit:auth',
});

// Limiter oparty na algorytmie token bucket dla API
// Pozwala na krótkie impulsy ruchu, ale ogranicza stały przepływ
const apiLimiter = new Ratelimit({
  redis,
  limiter: Ratelimit.tokenBucket(10, '10 s', 30),
  // 10 tokenów co 10 sekund, maksymalny pojemnik: 30 tokenów
  analytics: true,
  prefix: 'ratelimit:api',
});

// Funkcja wybierająca odpowiedni limiter na podstawie ścieżki
function selectLimiter(pathname: string): Ratelimit {
  if (pathname.startsWith('/api/auth')) return authLimiter;
  if (pathname.startsWith('/api/')) return apiLimiter;
  return generalLimiter;
}

// Funkcja identyfikująca klienta (klucz rate limitingu)
function getClientIdentifier(request: NextRequest): string {
  // Priorytet: ID użytkownika > IP z nagłówka proxy > IP żądania
  const userId = request.headers.get('x-user-id');
  if (userId) return `user:${userId}`;

  const forwardedFor = request.headers.get('x-forwarded-for');
  const ip = forwardedFor?.split(',')[0]?.trim() ?? 'anonymous';
  return `ip:${ip}`;
}

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

  // Wybierz odpowiedni limiter
  const limiter = selectLimiter(pathname);
  const identifier = getClientIdentifier(request);

  // Sprawdź limit
  const { success, limit, remaining, reset } = await limiter.limit(identifier);

  // Dodaj nagłówki informacyjne do odpowiedzi
  const response = success
    ? NextResponse.next()
    : NextResponse.json(
        {
          blad: 'Zbyt wiele żądań. Spróbuj ponownie później.',
          ponawiajPo: Math.ceil((reset - Date.now()) / 1000),
        },
        { status: 429 }
      );

  // Nagłówki zgodne ze standardem RFC 6585
  response.headers.set('X-RateLimit-Limit', limit.toString());
  response.headers.set('X-RateLimit-Remaining', remaining.toString());
  response.headers.set('X-RateLimit-Reset', reset.toString());

  if (!success) {
    response.headers.set(
      'Retry-After',
      Math.ceil((reset - Date.now()) / 1000).toString()
    );
  }

  return response;
}

export const config = {
  matcher: ['/api/:path*', '/login', '/rejestracja'],
};

Zwróć uwagę na dwa różne algorytmy rate limitingu:

  • Przesuwne okno (sliding window) — zapewnia równomierny rozkład żądań w czasie, bez problemu nagłego resetu licznika.
  • Token bucket — pozwala na krótkie impulsy zwiększonego ruchu (np. ładowanie dashboardu z wieloma widgetami), jednocześnie ograniczając stały przepływ.

Który wybrać? Dla formularzy logowania sliding window jest lepszy — chcemy ściśle kontrolować liczbę prób. Dla ogólnych endpointów API, gdzie użytkownik może potrzebować szybko załadować wiele zasobów naraz, token bucket lepiej odzwierciedla naturalny wzorzec ruchu.

Jest jeszcze kwestia identyfikacji klientów. W powyższym przykładzie niezalogowani użytkownicy identyfikowani są po IP. Ma to swoje ograniczenia — użytkownicy za tym samym NAT-em (np. w biurze) będą współdzielić limit. W produkcji warto rozważyć kombinację IP i fingerprintu przeglądarki lub dedykowane klucze API.

Geolokalizacja i internacjonalizacja

Middleware jest naturalnym miejscem do implementacji logiki zależnej od lokalizacji użytkownika. W erze globalnych aplikacji dostosowanie treści do regionu i języka to już nie dodatek — to konieczność. Na platformie Vercel obiekt żądania zawiera dane geolokalizacyjne, które pozwalają na automatyczne wykrywanie języka, przekierowania regionalne i testy A/B oparte na lokalizacji.

Implementacja i18n w middleware ma jedną wielką zaletę nad rozwiązaniami po stronie klienta — użytkownik od razu dostaje treść w odpowiednim języku. Żadnego migotania interfejsu, żadnych opóźnień. Middleware wykrywa język na podstawie wielu sygnałów: geolokalizacji IP, nagłówka Accept-Language, ciasteczka z preferencją lub parametru URL.

Automatyczne wykrywanie języka i przekierowanie

// middleware.ts - Geolokalizacja i internacjonalizacja (i18n)
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
import { match as matchLocale } from '@formatjs/intl-localematcher';
import Negotiator from 'negotiator';

// Obsługiwane języki
const SUPPORTED_LOCALES = ['pl', 'en', 'de', 'uk'] as const;
const DEFAULT_LOCALE = 'pl';

type SupportedLocale = (typeof SUPPORTED_LOCALES)[number];

// Mapowanie krajów na preferowane języki
const COUNTRY_LOCALE_MAP: Record<string, SupportedLocale> = {
  PL: 'pl',
  GB: 'en',
  US: 'en',
  DE: 'de',
  AT: 'de',
  CH: 'de',
  UA: 'uk',
};

// Funkcja wykrywająca preferowany język na podstawie nagłówka Accept-Language
function getPreferredLocaleFromHeader(request: NextRequest): string {
  const headers: Record<string, string> = {};
  const acceptLanguage = request.headers.get('accept-language');
  if (acceptLanguage) {
    headers['accept-language'] = acceptLanguage;
  }

  const negotiator = new Negotiator({ headers });
  const languages = negotiator.languages();

  try {
    return matchLocale(languages, [...SUPPORTED_LOCALES], DEFAULT_LOCALE);
  } catch {
    return DEFAULT_LOCALE;
  }
}

// Funkcja wykrywająca język na podstawie lokalizacji geograficznej
function getLocaleFromGeo(request: NextRequest): SupportedLocale | null {
  // Dane geolokalizacyjne dostępne na Vercel
  const country = request.headers.get('x-vercel-ip-country')
    ?? request.geo?.country;

  if (country && country in COUNTRY_LOCALE_MAP) {
    return COUNTRY_LOCALE_MAP[country];
  }
  return null;
}

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

  // Sprawdź czy ścieżka zawiera już prefiks języka
  const pathnameHasLocale = SUPPORTED_LOCALES.some(
    locale => pathname.startsWith(`/${locale}/`) || pathname === `/${locale}`
  );

  if (pathnameHasLocale) {
    return NextResponse.next();
  }

  // Priorytet wykrywania języka:
  // 1. Ciasteczko z preferencją użytkownika
  // 2. Geolokalizacja (kraj IP)
  // 3. Nagłówek Accept-Language
  // 4. Domyślny język

  const cookieLocale = request.cookies.get('preferred-locale')?.value;
  let detectedLocale: string;

  if (cookieLocale && SUPPORTED_LOCALES.includes(cookieLocale as SupportedLocale)) {
    detectedLocale = cookieLocale;
  } else {
    const geoLocale = getLocaleFromGeo(request);
    detectedLocale = geoLocale ?? getPreferredLocaleFromHeader(request);
  }

  // Przekieruj na wersję z prefiksem języka
  const redirectUrl = new URL(`/${detectedLocale}${pathname}`, request.url);
  redirectUrl.search = request.nextUrl.search;

  const response = NextResponse.redirect(redirectUrl);

  // Zapisz wykryty język w ciasteczku na 30 dni
  response.cookies.set('preferred-locale', detectedLocale, {
    maxAge: 60 * 60 * 24 * 30,
    path: '/',
    sameSite: 'lax',
  });

  return response;
}

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

Testy A/B oparte na geolokalizacji

Middleware doskonale nadaje się też do testów A/B, gdzie wariant przypisywany jest na podstawie lokalizacji lub losowo z zachowaniem spójności sesji:

// middleware.ts - Fragment odpowiedzialny za testy A/B
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';

// Konfiguracja aktywnych eksperymentów
const EXPERIMENTS = {
  'nowy-cennik': {
    variants: ['kontrolna', 'wariant-a', 'wariant-b'],
    // Wagi: 50% kontrolna, 25% wariant A, 25% wariant B
    weights: [0.5, 0.25, 0.25],
  },
  'nowy-layout-strony-glownej': {
    variants: ['stary', 'nowy'],
    weights: [0.5, 0.5],
  },
} as const;

type ExperimentName = keyof typeof EXPERIMENTS;

// Przypisanie wariantu na podstawie losowej wartości z wagami
function assignVariant(experimentName: ExperimentName): string {
  const experiment = EXPERIMENTS[experimentName];
  const random = Math.random();
  let cumulative = 0;

  for (let i = 0; i < experiment.weights.length; i++) {
    cumulative += experiment.weights[i];
    if (random < cumulative) {
      return experiment.variants[i];
    }
  }

  return experiment.variants[0]; // Domyślnie kontrolna
}

export function middleware(request: NextRequest) {
  const response = NextResponse.next();

  // Dla każdego eksperymentu sprawdź/przypisz wariant
  for (const [name, config] of Object.entries(EXPERIMENTS)) {
    const cookieName = `exp-${name}`;
    const existingVariant = request.cookies.get(cookieName)?.value;

    // Jeśli użytkownik ma już przypisany wariant, zachowaj go
    const variant = existingVariant
      && config.variants.includes(existingVariant as any)
      ? existingVariant
      : assignVariant(name as ExperimentName);

    // Zapisz wariant w ciasteczku (90 dni)
    if (!existingVariant) {
      response.cookies.set(cookieName, variant, {
        maxAge: 60 * 60 * 24 * 90,
        path: '/',
        sameSite: 'lax',
      });
    }

    // Przekaż wariant do aplikacji przez nagłówek
    response.headers.set(`x-experiment-${name}`, variant);
  }

  return response;
}

Dzięki temu warianty przypisywane są na krawędzi sieci, zanim strona zostanie wyrenderowana. Komponent serwerowy odczytuje wariant z nagłówka i renderuje odpowiednią wersję — zero migotania, zero "przeskakiwania" między wariantami. To kluczowa zaleta w porównaniu z rozwiązaniami client-side.

W połączeniu z danymi geolokalizacyjnymi możesz testować różne cenniki dla użytkowników z różnych krajów, dostosowywać układ strony do preferencji kulturowych czy testować strategie onboardingu w zależności od strefy czasowej. Jedyne ograniczenie — analityka eksperymentów musi być zbierana oddzielnie, bo middleware nie ma dostępu do narzędzi typu Google Analytics czy Mixpanel.

Kompozycja wielu funkcji middleware

Tu dochodzimy do jednego z większych wyzwań: Next.js pozwala na jeden plik middleware.ts. W rozbudowanych aplikacjach, gdzie potrzebujemy uwierzytelniania, rate limitingu, i18n, logowania i nagłówków bezpieczeństwa — umieszczenie tego wszystkiego w jednej funkcji to prosta droga do bałaganu.

Rozwiązanie? System potoku (pipeline) middleware, inspirowany podejściem z Express.js.

Każda funkcja middleware w łańcuchu otrzymuje żądanie, bieżącą odpowiedź i współdzielony kontekst, a następnie może kontynuować łańcuch lub go przerwać:

// lib/middleware/chain.ts - System łączenia funkcji middleware
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';

// Typ opisujący pojedynczą funkcję middleware w łańcuchu
export type MiddlewareFunction = (
  request: NextRequest,
  // Odpowiedź przekazywana między ogniwami łańcucha
  response: NextResponse,
  // Kontekst współdzielony między funkcjami middleware
  context: MiddlewareContext
) => Promise<MiddlewareResult>;

// Kontekst współdzielony w ramach jednego żądania
export interface MiddlewareContext {
  // Dowolne dane przekazywane między funkcjami
  [key: string]: unknown;
}

// Wynik wykonania pojedynczej funkcji middleware
export type MiddlewareResult =
  | { type: 'next'; response: NextResponse }  // Kontynuuj do następnej funkcji
  | { type: 'redirect'; response: NextResponse } // Przerwij łańcuch i przekieruj
  | { type: 'error'; response: NextResponse };   // Przerwij łańcuch z błędem

// Główna funkcja tworząca łańcuch middleware
export function createMiddlewareChain(
  ...middlewares: MiddlewareFunction[]
) {
  return async function chainedMiddleware(
    request: NextRequest
  ): Promise<NextResponse> {
    let response = NextResponse.next();
    const context: MiddlewareContext = {};

    for (const mw of middlewares) {
      try {
        const result = await mw(request, response, context);

        switch (result.type) {
          case 'next':
            // Kontynuuj z zaktualizowaną odpowiedzią
            response = result.response;
            break;
          case 'redirect':
          case 'error':
            // Przerwij łańcuch i zwróć odpowiedź natychmiast
            return result.response;
        }
      } catch (error) {
        console.error(
          `[Middleware] Błąd w funkcji middleware: ${error}`
        );
        // W przypadku błędu kontynuuj z domyślną odpowiedzią
        // (nie blokuj użytkownika z powodu błędu middleware)
      }
    }

    return response;
  };
}

// -----------------------------------------------------------
// middleware.ts - Użycie łańcucha middleware
// -----------------------------------------------------------
// import { createMiddlewareChain } from '@/lib/middleware/chain';
// import { authMiddleware } from '@/lib/middleware/auth';
// import { rateLimitMiddleware } from '@/lib/middleware/rate-limit';
// import { i18nMiddleware } from '@/lib/middleware/i18n';
// import { securityHeadersMiddleware } from '@/lib/middleware/security';
// import { loggingMiddleware } from '@/lib/middleware/logging';
//
// // Kolejność ma znaczenie! Logging pierwszy, potem bezpieczeństwo,
// // rate limiting, uwierzytelnianie i na końcu i18n
// export const middleware = createMiddlewareChain(
//   loggingMiddleware,
//   securityHeadersMiddleware,
//   rateLimitMiddleware,
//   authMiddleware,
//   i18nMiddleware,
// );
//
// export const config = {
//   matcher: ['/((?!_next/static|_next/image|favicon\\.ico).*)'],
// };

Ten wzorzec pozwala na przejrzyste rozdzielenie odpowiedzialności — każda funkcja odpowiada za jedną logikę i może być niezależnie testowana. Kontekst współdzielony (MiddlewareContext) umożliwia komunikację między ogniwami. Na przykład, funkcja uwierzytelniania zapisuje dane użytkownika pod kluczem user, a funkcja i18n wykorzystuje je do personalizacji języka.

Istotny aspekt: obsługa błędów. W powyższej implementacji stosujemy strategię fail-open — jeśli którakolwiek funkcja rzuci wyjątek, łańcuch kontynuuje z domyślną odpowiedzią zamiast blokować użytkownika. To bezpieczniejsze z perspektywy dostępności, ale wymaga weryfikacji na kolejnych warstwach. Alternatywą jest fail-close (odpowiedź 500 przy błędzie), co jest bardziej restrykcyjne, ale ryzykowne przy przejściowych problemach z usługami zewnętrznymi.

Rekomendowana kolejność funkcji w łańcuchu:

  1. Logowanie — rejestruj każde żądanie na samym początku.
  2. Nagłówki bezpieczeństwa — ustaw CSP, HSTS i inne nagłówki.
  3. Rate limiting — odrzuć nadmierne żądania przed kosztowną weryfikacją tokenów.
  4. Uwierzytelnianie — zweryfikuj tożsamość użytkownika.
  5. Autoryzacja — sprawdź uprawnienia do konkretnego zasobu.
  6. Internacjonalizacja — wykryj i ustaw język.

Najlepsze praktyki wydajnościowe

Middleware wykonywany jest dla każdego pasującego żądania, więc jego wydajność bezpośrednio wpływa na czas ładowania całej aplikacji. Nawet 50-100 milisekund opóźnienia w middleware to odczuwalnie wolniejsze ładowanie każdej strony. W skali milionów żądań dziennie każda zaoszczędzona milisekunda ma znaczenie — zarówno dla użytkownika, jak i portfela.

Utrzymuj middleware lekkim

Idealnie, czas wykonania nie powinien przekraczać kilku milisekund. Oto konkretne wytyczne:

  • Unikaj zapytań do bazy danych — zamiast tego weryfikuj tokeny JWT lokalnie lub używaj lekkiego Upstash Redis do szybkich odczytów.
  • Nie importuj ciężkich bibliotek — każda zależność zwiększa rozmiar pakietu Edge Function. Używaj lekkich alternatyw (jose zamiast jsonwebtoken, @upstash/redis zamiast ioredis).
  • Minimalizuj wywołania sieciowe — każdy fetch dodaje opóźnienie. Rozważ buforowanie odpowiedzi.
  • Używaj precyzyjnych matcherów — zamiast globalnego middleware z wewnętrznym filtrowaniem.

Strategie buforowania

Edge Runtime nie ma wbudowanego cache między żądaniami, ale możesz zastosować kilka podejść:

  • Buforowanie w Redis — Upstash Redis (odczyty poniżej 5ms) do przechowywania wyników weryfikacji tokenów czy danych konfiguracyjnych.
  • Buforowanie po stronie klienta — odpowiednie nagłówki Cache-Control, żeby przeglądarka i CDN mogły buforować przekierowania.
  • Krótkotrwałe tokeny z odświeżaniem — tokeny JWT ważne 15 minut z mechanizmem refresh token zamiast weryfikacji z bazą danych przy każdym żądaniu.

Monitorowanie wydajności middleware

Vercel oferuje wbudowane narzędzia monitoringu, ale warto implementować też własne metryki:

  • Mierz czas wykonania za pomocą performance.now().
  • Loguj żądania przekraczające określony próg czasu.
  • Monitoruj liczbę odrzuconych żądań przez rate limiting.
  • Śledź nieudane weryfikacje tokenów — mogą wskazywać na próby ataku.
  • Porównuj czasy odpowiedzi między regionami edge.

Dobrą praktyką jest ustalenie budżetu wydajnościowego: np. maksymalnie 10ms dla prostych operacji (weryfikacja JWT, nagłówki) i 50ms dla operacji wymagających komunikacji z zewnętrznym serwisem. Jeśli middleware regularnie przekracza te progi, czas na optymalizację.

Wdrożenie: Vercel vs Self-hosted

Sposób wdrożenia aplikacji Next.js ma spory wpływ na zachowanie middleware. Różnice między Vercel a self-hostingiem są na tyle istotne, że warto je znać przed podjęciem decyzji.

Wdrożenie na Vercel

Vercel, jako twórca Next.js, oferuje najbardziej zintegrowane środowisko:

  • Automatyczna globalna dystrybucja — middleware jest wdrażany we wszystkich regionach edge bez żadnej konfiguracji.
  • Dane geolokalizacyjnerequest.geo jest automatycznie wypełniany danymi o kraju, regionie, mieście i współrzędnych.
  • Ochrona przed atakami — Vercel filtruje wewnętrzne nagłówki Next.js z zewnętrznych żądań (ochrona przed CVE-2025-29927).
  • Vercel Firewall i DDoS Protection — dodatkowe warstwy ochrony na poziomie infrastruktury.
  • Logi i analityka — wbudowane narzędzia do monitorowania Edge Functions.
  • Limity — rozmiar Edge Function do 4 MB, czas wykonania do 30 sekund.

Wdrożenie samodzielne (self-hosted)

Przy self-hostingu (VPS, Kubernetes, Docker) sytuacja jest bardziej złożona:

  • Brak natywnego Edge Runtimenext start uruchamia middleware w Node.js na serwerze aplikacji. Działa, ale bez globalnej dystrybucji.
  • Brak danych geolokalizacyjnychrequest.geo jest pusty. Dane musisz uzyskać z nagłówków ustawianych przez CDN (np. CF-IPCountry dla Cloudflare).
  • Ręczna konfiguracja zabezpieczeń — musisz samodzielnie filtrować wewnętrzne nagłówki Next.js na reverse proxy.

Przykładowa konfiguracja nginx:

// Konfiguracja nginx (nie TypeScript, ale istotna dla kontekstu)
// Umieść w bloku server {} pliku konfiguracyjnego nginx:
//
// # Blokuj wewnętrzne nagłówki Next.js z zewnętrznych żądań
// proxy_set_header x-middleware-subrequest "";
// proxy_set_header x-middleware-invoke "";
// proxy_set_header x-middleware-next "";
//
// # Opcjonalnie: dodaj dane geolokalizacyjne z modułu GeoIP2
// # (wymaga modułu ngx_http_geoip2_module)
// proxy_set_header X-Geo-Country $geoip2_data_country_code;
// proxy_set_header X-Geo-City $geoip2_data_city_name;
// proxy_set_header X-Geo-Latitude $geoip2_data_location_latitude;
// proxy_set_header X-Geo-Longitude $geoip2_data_location_longitude;

// W middleware możesz wtedy odczytać te nagłówki:
function getGeoData(request: NextRequest) {
  return {
    country: request.headers.get('x-geo-country')
      ?? request.headers.get('x-vercel-ip-country')
      ?? request.geo?.country
      ?? null,
    city: request.headers.get('x-geo-city')
      ?? request.geo?.city
      ?? null,
  };
}

Alternatywne platformy Edge

Warto wspomnieć o innych opcjach:

  • Cloudflare Pages — z adapterem @cloudflare/next-on-pages można wdrożyć aplikację na globalnej sieci Cloudflare. Middleware działa jako Cloudflare Worker.
  • Netlify — obsługuje Next.js z Edge Functions opartymi na Deno.
  • AWS Lambda@Edge / CloudFront Functions — więcej konfiguracji, ale integracja z ekosystemem AWS.

Każda platforma ma swoje specyfiki. Kluczowe jest przetestowanie middleware na docelowej platformie, bo subtelne różnice w implementacji Edge Runtime mogą zaskoczyć. Na przykład zachowanie crypto.subtle może się nieznacznie różnić między platformami, co wpływa na weryfikację JWT.

Decyzja Vercel vs self-hosting powinna uwzględniać nie tylko koszty, ale też nakład pracy na konfigurację zabezpieczeń i monitoringu. Vercel oferuje to "z pudełka". Z drugiej strony — self-hosting daje pełną kontrolę i może być konieczny ze względu na wymagania regulacyjne czy integrację z istniejącą infrastrukturą.

Podsumowanie: Kluczowe wnioski i lista kontrolna

Next.js Middleware i Edge Runtime to potężne narzędzia do implementacji zaawansowanych wzorców na krawędzi sieci. Oto najważniejsze wnioski z tego przewodnika:

Kluczowe wnioski

  • Middleware działa przed trasami — to pierwsza linia przetwarzania żądań, idealna do uwierzytelniania, przekierowań i nagłówków.
  • Edge Runtime ma ograniczenia — brak systemu plików, ograniczone API Node.js, limit rozmiaru. Projektuj z myślą o tych ograniczeniach.
  • Nigdy nie polegaj wyłącznie na middleware — CVE-2025-29927 jasno to pokazała. Middleware to warstwa optymalizacyjna, nie jedyna bariera bezpieczeństwa.
  • Precyzyjne matchery — uruchamiaj middleware tylko tam, gdzie jest naprawdę potrzebny.
  • Kompozycja przez łańcuch — użyj wzorca potoku do organizacji wielu logik w jednym pliku middleware.
  • Rate limiting — odrzucaj nadmierne żądania zanim dotrą do logiki aplikacji.
  • Geolokalizacja zależy od platformy — natywna na Vercel, wymaga konfiguracji na self-hosted.

Lista kontrolna wdrożenia produkcyjnego

  1. Bezpieczeństwo
    • Weryfikacja uwierzytelniania na wielu poziomach (middleware, Server Components, Route Handlers).
    • Nagłówki bezpieczeństwa (CSP, HSTS, X-Frame-Options).
    • Filtrowanie wewnętrznych nagłówków Next.js na reverse proxy (self-hosted).
    • Rate limiting z odpowiednimi limitami dla różnych endpointów.
    • Regularne aktualizacje Next.js.
  2. Wydajność
    • Middleware poniżej 1-2 MB rozmiaru pakietu.
    • Brak zapytań do bazy danych wewnątrz middleware.
    • Precyzyjne matchery zamiast globalnego middleware z warunkami.
    • Monitoring czasu wykonania z alertami.
    • Biblioteki kompatybilne z Edge Runtime.
  3. Architektura
    • Wzorzec łańcucha middleware do organizacji wielu funkcji.
    • Logika w osobnych modułach dla łatwego testowania.
    • Dokumentacja kolejności i zależności.
    • Fail-open w middleware (nie blokuj użytkowników przy błędach).
  4. Wdrożenie
    • Testy middleware na docelowej platformie.
    • Monitoring i logowanie Edge Functions.
    • Procedura szybkiego wycofania middleware.
    • Weryfikacja dostępności danych geolokalizacyjnych.

Middleware w Next.js to narzędzie, które — przy odpowiednim zastosowaniu — znacząco poprawia bezpieczeństwo, wydajność i doświadczenie użytkownika. Kluczem jest zrozumienie jego możliwości i ograniczeń oraz stosowanie podejścia defense-in-depth.

Wraz z rozwojem ekosystemu w 2026 roku możemy spodziewać się lepszej integracji z bazami danych edge (Turso, PlanetScale, Neon), rozszerzonych API geolokalizacyjnych i nowych wzorców buforowania. Biblioteki takie jak @auth/core (NextAuth v5), @clerk/nextjs czy @supabase/ssr dostarczają już gotowe integracje z middleware Next.js.

Niezależnie od tych zmian, fundamenty opisane w tym przewodniku — lekkość middleware, bezpieczeństwo wielowarstwowe, precyzja matcherów i testowanie na docelowej platformie — pozostaną aktualne. Mam nadzieję, że ten przewodnik dostarczył Ci wiedzy i praktycznych wzorców potrzebnych do budowania bezpiecznych i wydajnych aplikacji Next.js.

O Autorze Editorial Team

Our team of expert writers and editors.