12. Middleware
🛡️ Middleware: страж у ворот Next.js
Заголовок раздела «🛡️ Middleware: страж у ворот Next.js»Middleware — это функция, которая запускается перед обработкой каждого запроса. Представь вахтёра 💂 на входе в здание: он проверяет пропуск, записывает всех посетителей и может перенаправить в другую дверь, даже не пуская внутрь. Middleware живёт в файле middleware.ts в корне проекта (рядом с app/).
📁 Где живёт Middleware
Заголовок раздела «📁 Где живёт Middleware»├── app/│ ├── page.tsx│ └── dashboard/│ └── page.tsx├── middleware.ts ← здесь! Один файл на всё приложение├── next.config.mjs└── package.jsonMiddleware запускается на Edge Runtime — сверхбыстрая среда исполнения, которая работает ближе к пользователю 🌍
🔧 Базовый синтаксис
Заголовок раздела «🔧 Базовый синтаксис»import { NextRequest, NextResponse } from 'next/server';
export function middleware(request: NextRequest) { // Читаем информацию о запросе console.log('Запрос:', request.method, request.url);
// Просто пропускаем дальше return NextResponse.next();}
// Конфигурация: к каким путям применятьexport const config = { matcher: [ // Применять к /dashboard и всему под ним '/dashboard/:path*', // К API, кроме /api/public '/api/((?!public/).*)', ],};🎯 matcher: точная настройка
Заголовок раздела «🎯 matcher: точная настройка»matcher — это ключевой параметр конфигурации. Он определяет, для каких путей запускать middleware:
export const config = { matcher: [ // ✅ Строка — точное совпадение '/about',
// ✅ Wildcard — любой путь под /dashboard '/dashboard/:path*',
// ✅ Regex — исключаем статику и _next '/((?!_next/static|_next/image|favicon.ico|api/public).*)',
// ✅ Массив условий с сопоставлением { source: '/((?!_next/static|_next/image|favicon.ico).*)', // Только если нет предзагрузки страницы missing: [ { type: 'header', key: 'next-router-prefetch' }, { type: 'header', key: 'purpose', value: 'prefetch' }, ], }, ],};
// Применять ко ВСЕМ путям (не рекомендуется для продакшена):export const config = { matcher: '/:path*',};🔀 Redirect: перенаправление запросов
Заголовок раздела «🔀 Redirect: перенаправление запросов»Redirect меняет URL в браузере — пользователь видит новый адрес:
import { NextRequest, NextResponse } from 'next/server';
export function middleware(request: NextRequest) { const { pathname } = request.nextUrl;
// 1. Редирект старых URL на новые if (pathname.startsWith('/old-blog/')) { const newPath = pathname.replace('/old-blog/', '/blog/'); return NextResponse.redirect(new URL(newPath, request.url), { status: 301, // Permanent redirect }); }
// 2. Редирект с www на без www const hostname = request.headers.get('host') ?? ''; if (hostname.startsWith('www.')) { const newHostname = hostname.slice(4); return NextResponse.redirect( new URL(request.url.replace(hostname, newHostname)), { status: 301 } ); }
// 3. Редирект HTTP на HTTPS if ( process.env.NODE_ENV === 'production' && !request.headers.get('x-forwarded-proto')?.includes('https') ) { return NextResponse.redirect( new URL(request.url.replace('http://', 'https://')), { status: 301 } ); }
return NextResponse.next();}🔄 Rewrite: внутреннее перенаправление
Заголовок раздела «🔄 Rewrite: внутреннее перенаправление»Rewrite изменяет URL внутренне, но браузер видит исходный URL. Как телепортация для запроса 🌀
import { NextRequest, NextResponse } from 'next/server';
export function middleware(request: NextRequest) { const { pathname } = request.nextUrl;
// 1. Мультиязычность без изменения URL // /products → /en/products (внутренне) const lang = request.cookies.get('NEXT_LOCALE')?.value ?? 'ru'; if (!pathname.startsWith('/ru') && !pathname.startsWith('/en')) { return NextResponse.rewrite( new URL(`/${lang}${pathname}`, request.url) ); }
// 2. Feature flags: новая версия страницы const beta = request.cookies.get('beta-user')?.value === 'true'; if (pathname === '/pricing' && beta) { return NextResponse.rewrite(new URL('/pricing-v2', request.url)); }
// 3. Прокси к внешнему API (скрываем URL) // /api/legacy/* → https://old-api.example.com/* if (pathname.startsWith('/api/legacy/')) { const apiPath = pathname.replace('/api/legacy', ''); return NextResponse.rewrite( new URL(apiPath, 'https://old-api.example.com') ); }
return NextResponse.next();}🔐 Auth Guard: защита маршрутов
Заголовок раздела «🔐 Auth Guard: защита маршрутов»Самый популярный сценарий использования middleware — проверка аутентификации:
import { NextRequest, NextResponse } from 'next/server';import { jwtVerify } from 'jose';
const PUBLIC_PATHS = [ '/', '/login', '/register', '/about', '/api/auth',];
const ADMIN_PATHS = ['/admin'];
function isPublicPath(pathname: string): boolean { return PUBLIC_PATHS.some(path => pathname === path || pathname.startsWith(path + '/') );}
async function verifyToken(token: string) { try { const secret = new TextEncoder().encode(process.env.JWT_SECRET); const { payload } = await jwtVerify(token, secret); return payload as { userId: string; role: string }; } catch { return null; }}
export async function middleware(request: NextRequest) { const { pathname } = request.nextUrl;
// Пропускаем публичные пути if (isPublicPath(pathname)) { return NextResponse.next(); }
// Получаем токен из куки или заголовка const token = request.cookies.get('auth-token')?.value ?? request.headers.get('authorization')?.replace('Bearer ', '');
// Нет токена — редирект на логин if (!token) { const loginUrl = new URL('/login', request.url); loginUrl.searchParams.set('from', pathname); // сохраняем куда хотели попасть return NextResponse.redirect(loginUrl); }
// Проверяем токен const payload = await verifyToken(token);
if (!payload) { // Невалидный токен — очищаем и редиректим const response = NextResponse.redirect(new URL('/login', request.url)); response.cookies.delete('auth-token'); return response; }
// Проверяем права для admin-путей if (ADMIN_PATHS.some(p => pathname.startsWith(p))) { if (payload.role !== 'admin') { return NextResponse.redirect(new URL('/403', request.url)); } }
// Добавляем данные пользователя в заголовки для Server Components const requestHeaders = new Headers(request.headers); requestHeaders.set('x-user-id', payload.userId); requestHeaders.set('x-user-role', payload.role);
return NextResponse.next({ request: { headers: requestHeaders }, });}
export const config = { matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'],};🧪 A/B Testing с Middleware
Заголовок раздела «🧪 A/B Testing с Middleware»Middleware идеален для A/B тестирования — разделяем пользователей на группы:
import { NextRequest, NextResponse } from 'next/server';
const AB_COOKIE = 'ab-variant';
export function middleware(request: NextRequest) { const { pathname } = request.nextUrl;
// A/B тест только для главной страницы if (pathname !== '/') return NextResponse.next();
// Получаем или назначаем вариант let variant = request.cookies.get(AB_COOKIE)?.value;
if (!variant) { // 50/50 разделение variant = Math.random() < 0.5 ? 'a' : 'b'; }
// Показываем разный контент const url = request.nextUrl.clone(); url.pathname = variant === 'b' ? '/home-v2' : '/home-v1';
const response = NextResponse.rewrite(url);
// Сохраняем вариант в куке if (!request.cookies.has(AB_COOKIE)) { response.cookies.set(AB_COOKIE, variant, { maxAge: 60 * 60 * 24 * 30, // 30 дней httpOnly: false, // доступно из JS для аналитики }); }
// Передаём вариант для аналитики response.headers.set('x-ab-variant', variant);
return response;}🌍 Геолокация: разный контент для разных стран
Заголовок раздела «🌍 Геолокация: разный контент для разных стран»import { NextRequest, NextResponse } from 'next/server';import { geolocation } from '@vercel/functions'; // только на Vercel
export function middleware(request: NextRequest) { const { pathname } = request.nextUrl;
// Vercel автоматически добавляет геолокацию в заголовки const country = request.geo?.country ?? request.headers.get('x-vercel-ip-country') ?? 'US';
const city = request.geo?.city ?? 'Unknown'; const region = request.geo?.region ?? 'Unknown';
// Блокируем страны по закону (пример) const BLOCKED_COUNTRIES = ['XX', 'YY']; if (BLOCKED_COUNTRIES.includes(country)) { return NextResponse.redirect(new URL('/geo-blocked', request.url)); }
// Разный контент для разных регионов if (pathname === '/pricing') { const EUROPEAN_COUNTRIES = ['DE', 'FR', 'IT', 'ES', 'NL', 'BE', 'AT']; if (EUROPEAN_COUNTRIES.includes(country)) { return NextResponse.rewrite(new URL('/pricing-eu', request.url)); } if (country === 'US') { return NextResponse.rewrite(new URL('/pricing-us', request.url)); } }
// Добавляем геоданные в заголовки const response = NextResponse.next(); response.headers.set('x-country', country); response.headers.set('x-city', city);
return response;}⏱️ Rate Limiting: концепция ограничения запросов
Заголовок раздела «⏱️ Rate Limiting: концепция ограничения запросов»На Edge Runtime нет памяти между запросами, поэтому rate limiting требует внешнего хранилища:
import { NextRequest, NextResponse } from 'next/server';import { Ratelimit } from '@upstash/ratelimit';import { Redis } from '@upstash/redis';
// Создаём rate limiter: 10 запросов в 10 секундconst ratelimit = new Ratelimit({ redis: Redis.fromEnv(), limiter: Ratelimit.slidingWindow(10, '10 s'), analytics: true,});
export async function middleware(request: NextRequest) { const { pathname } = request.nextUrl;
// Rate limiting только для API if (!pathname.startsWith('/api/')) { return NextResponse.next(); }
// Идентифицируем пользователя по IP const ip = request.ip ?? request.headers.get('x-forwarded-for')?.split(',')[0] ?? 'anonymous';
// Проверяем лимит const { success, limit, reset, remaining } = await ratelimit.limit( `ratelimit_${ip}` );
if (!success) { return NextResponse.json( { error: 'Too Many Requests', message: `Превышен лимит запросов. Попробуйте через ${Math.ceil((reset - Date.now()) / 1000)} секунд`, }, { status: 429, headers: { 'X-RateLimit-Limit': limit.toString(), 'X-RateLimit-Remaining': remaining.toString(), 'X-RateLimit-Reset': new Date(reset).toISOString(), 'Retry-After': Math.ceil((reset - Date.now()) / 1000).toString(), }, } ); }
// Добавляем заголовки с информацией о лимитах const response = NextResponse.next(); response.headers.set('X-RateLimit-Limit', limit.toString()); response.headers.set('X-RateLimit-Remaining', remaining.toString());
return response;}🍪 Работа с Cookies и Headers в Middleware
Заголовок раздела «🍪 Работа с Cookies и Headers в Middleware»import { NextRequest, NextResponse } from 'next/server';
export function middleware(request: NextRequest) { const response = NextResponse.next();
// === ЧТЕНИЕ ===
// Куки из запроса const theme = request.cookies.get('theme')?.value ?? 'dark'; const locale = request.cookies.get('NEXT_LOCALE')?.value ?? 'ru'; const allCookies = request.cookies.getAll();
// Заголовки запроса const userAgent = request.headers.get('user-agent') ?? ''; const referer = request.headers.get('referer') ?? ''; const isMobile = /Mobile|Android|iPhone|iPad/.test(userAgent);
// === ЗАПИСЬ ===
// Устанавливаем куки в ответе response.cookies.set('theme', theme, { path: '/', maxAge: 60 * 60 * 24 * 365, });
// Добавляем заголовки к ответу response.headers.set('x-theme', theme); response.headers.set('x-locale', locale); response.headers.set('x-is-mobile', isMobile.toString());
// Удаляем куки // response.cookies.delete('old-session');
return response;}🔗 Читаем данные Middleware в Server Components
Заголовок раздела «🔗 Читаем данные Middleware в Server Components»// app/dashboard/page.tsx — Server Componentimport { headers } from 'next/headers';
export default async function DashboardPage() { // Читаем заголовки, которые добавил Middleware const headersList = await headers(); const userId = headersList.get('x-user-id'); const userRole = headersList.get('x-user-role'); const country = headersList.get('x-country'); const isMobile = headersList.get('x-is-mobile') === 'true';
// Используем для персонализации const user = await db.user.findUnique({ where: { id: userId! } });
return ( <div> <h1>Привет, {user?.name}!</h1> <p>Страна: {country}</p> <p>Роль: {userRole}</p> </div> );}