Перейти к содержимому

12. Middleware

Middleware — это функция, которая запускается перед обработкой каждого запроса. Представь вахтёра 💂 на входе в здание: он проверяет пропуск, записывает всех посетителей и может перенаправить в другую дверь, даже не пуская внутрь. Middleware живёт в файле middleware.ts в корне проекта (рядом с app/).


├── app/
│ ├── page.tsx
│ └── dashboard/
│ └── page.tsx
├── middleware.ts ← здесь! Один файл на всё приложение
├── next.config.mjs
└── package.json

Middleware запускается на Edge Runtime — сверхбыстрая среда исполнения, которая работает ближе к пользователю 🌍


middleware.ts
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 — это ключевой параметр конфигурации. Он определяет, для каких путей запускать 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 меняет URL в браузере — пользователь видит новый адрес:

middleware.ts
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 изменяет 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();
}

Самый популярный сценарий использования middleware — проверка аутентификации:

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

Middleware идеален для A/B тестирования — разделяем пользователей на группы:

middleware.ts
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;
}

🌍 Геолокация: разный контент для разных стран

Заголовок раздела «🌍 Геолокация: разный контент для разных стран»
middleware.ts
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 требует внешнего хранилища:

middleware.ts
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;
}

middleware.ts
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;
}

// app/dashboard/page.tsx — Server Component
import { 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>
);
}