Middleware выполняется перед каждым подходящим запросом — до рендеринга, до обработчиков, максимально близко к пользователю. Отсюда и его сила, и его главное ограничение: код там работает в урезанной среде выполнения и на горячем пути всего трафика.
Разберём, что туда стоит класть, а что — категорически нет.
Что такое Middleware
Middleware — функция, которая выполняется до обработки запроса. Работает на Edge Runtime (V8 isolates), поэтому очень быстрая.
Запрос → Middleware → Route Handler/Page
↓
Redirect?
Rewrite?
Modify headers?Edge Runtime ограничения: Нет Node.js API (fs, path), нет eval(), размер бандла до 4MB. Используйте только Edge-совместимые пакеты.
Базовый middleware
// middleware.ts (в корне проекта)
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
export function middleware(request: NextRequest) {
// Логируем все запросы
console.log(`[${request.method}] ${request.nextUrl.pathname}`);
// Продолжаем обработку
return NextResponse.next();
}
// Определяем, для каких путей работает middleware
export const config = {
matcher: [
// Все пути, кроме статики и API
'/((?!_next/static|_next/image|favicon.ico|api).*)',
],
};Авторизация
Middleware — не граница безопасности. Он решает, куда отправить пользователя, а не какие данные ему можно. Обработчики маршрутов и серверные компоненты доступны и в обход навигации, поэтому проверка прав должна находиться там, где данные читаются. Middleware при этом остаётся полезен — он избавляет от рендеринга страницы, которую всё равно нельзя показать, — но полагаться на него как на единственную защиту нельзя.
Проверка токена
// middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
const protectedRoutes = ['/dashboard', '/profile', '/settings'];
const authRoutes = ['/login', '/register'];
export function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
// Получаем токен из cookie
const token = request.cookies.get('auth-token')?.value;
// Защищённые роуты без токена → редирект на логин
if (protectedRoutes.some(route => pathname.startsWith(route))) {
if (!token) {
const loginUrl = new URL('/login', request.url);
loginUrl.searchParams.set('redirect', pathname);
return NextResponse.redirect(loginUrl);
}
}
// Авторизован, но на странице логина → на главную
if (authRoutes.some(route => pathname.startsWith(route))) {
if (token) {
return NextResponse.redirect(new URL('/dashboard', request.url));
}
}
return NextResponse.next();
}
export const config = {
matcher: ['/dashboard/:path*', '/profile/:path*', '/settings/:path*', '/login', '/register'],
};JWT валидация
// middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
import { jwtVerify } from 'jose';
const JWT_SECRET = new TextEncoder().encode(process.env.JWT_SECRET!);
async function verifyToken(token: string): Promise<{ userId: string } | null> {
try {
const { payload } = await jwtVerify(token, JWT_SECRET);
return { userId: payload.sub as string };
} catch {
return null;
}
}
export async function middleware(request: NextRequest) {
const token = request.cookies.get('token')?.value;
if (!token) {
return NextResponse.redirect(new URL('/login', request.url));
}
const payload = await verifyToken(token);
if (!payload) {
// Невалидный токен — удаляем и редиректим
const response = NextResponse.redirect(new URL('/login', request.url));
response.cookies.delete('token');
return response;
}
// Добавляем userId в заголовки для использования в API/pages
const requestHeaders = new Headers(request.headers);
requestHeaders.set('x-user-id', payload.userId);
return NextResponse.next({
request: {
headers: requestHeaders,
},
});
}
export const config = {
matcher: ['/dashboard/:path*', '/api/protected/:path*'],
};Редиректы и rewrites
Редиректы
// middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
const redirects: Record<string, string> = {
'/old-page': '/new-page',
'/blog/old-post': '/articles/new-post',
'/products': '/catalog',
};
export function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
// Проверяем редиректы
if (pathname in redirects) {
return NextResponse.redirect(new URL(redirects[pathname], request.url), 301);
}
// Редирект с trailing slash
if (pathname !== '/' && pathname.endsWith('/')) {
return NextResponse.redirect(
new URL(pathname.slice(0, -1), request.url),
308
);
}
return NextResponse.next();
}Rewrite (прокси)
// middleware.ts
export function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
// Проксируем /api/legacy/* на старый бэкенд
if (pathname.startsWith('/api/legacy')) {
const newUrl = new URL(pathname.replace('/api/legacy', ''), 'https://old-api.example.com');
newUrl.search = request.nextUrl.search;
return NextResponse.rewrite(newUrl);
}
// Проксируем по поддомену
const hostname = request.headers.get('host');
if (hostname?.startsWith('api.')) {
return NextResponse.rewrite(new URL(`/api${pathname}`, request.url));
}
return NextResponse.next();
}A/B тестирование
// middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
const EXPERIMENT_COOKIE = 'ab-experiment';
export function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
// Только для главной
if (pathname !== '/') {
return NextResponse.next();
}
// Проверяем, есть ли уже вариант
let variant = request.cookies.get(EXPERIMENT_COOKIE)?.value;
if (!variant) {
// Случайно выбираем вариант (50/50)
variant = Math.random() < 0.5 ? 'control' : 'experiment';
}
// Rewrite на соответствующую страницу
const response = NextResponse.rewrite(
new URL(variant === 'experiment' ? '/home-new' : '/home', request.url)
);
// Сохраняем вариант в cookie (30 дней)
response.cookies.set(EXPERIMENT_COOKIE, variant, {
maxAge: 60 * 60 * 24 * 30,
httpOnly: true,
});
return response;
}
export const config = {
matcher: '/',
};Многовариантный тест
// middleware.ts
type Experiment = {
name: string;
variants: { name: string; weight: number }[];
paths: string[];
};
const experiments: Experiment[] = [
{
name: 'pricing-page',
variants: [
{ name: 'control', weight: 50 },
{ name: 'variant-a', weight: 25 },
{ name: 'variant-b', weight: 25 },
],
paths: ['/pricing'],
},
];
function selectVariant(variants: { name: string; weight: number }[]): string {
const total = variants.reduce((sum, v) => sum + v.weight, 0);
let random = Math.random() * total;
for (const variant of variants) {
random -= variant.weight;
if (random <= 0) return variant.name;
}
return variants[0].name;
}
export function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
for (const experiment of experiments) {
if (!experiment.paths.some(p => pathname.startsWith(p))) continue;
const cookieName = `exp-${experiment.name}`;
let variant = request.cookies.get(cookieName)?.value;
if (!variant || !experiment.variants.some(v => v.name === variant)) {
variant = selectVariant(experiment.variants);
}
// Rewrite на вариант: /pricing → /pricing/variant-a
const response = NextResponse.rewrite(
new URL(`${pathname}/${variant}`, request.url)
);
response.cookies.set(cookieName, variant, {
maxAge: 60 * 60 * 24 * 30,
});
// Добавляем заголовок для аналитики
response.headers.set('x-experiment', `${experiment.name}:${variant}`);
return response;
}
return NextResponse.next();
}Геолокация
// middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
export function middleware(request: NextRequest) {
// Vercel автоматически добавляет geo-заголовки
const country = request.geo?.country || 'US';
const city = request.geo?.city || 'Unknown';
// Редирект на локализованную версию
if (country === 'RU' && !request.nextUrl.pathname.startsWith('/ru')) {
return NextResponse.redirect(new URL(`/ru${request.nextUrl.pathname}`, request.url));
}
// Добавляем geo в заголовки
const response = NextResponse.next();
response.headers.set('x-country', country);
response.headers.set('x-city', city);
return response;
}Rate limiting
// middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
// Простой in-memory rate limiter (для production используйте Redis)
const rateLimit = new Map<string, { count: number; resetAt: number }>();
const WINDOW_MS = 60 * 1000; // 1 минута
const MAX_REQUESTS = 100;
function getRateLimitInfo(ip: string): { allowed: boolean; remaining: number } {
const now = Date.now();
const record = rateLimit.get(ip);
if (!record || record.resetAt < now) {
rateLimit.set(ip, { count: 1, resetAt: now + WINDOW_MS });
return { allowed: true, remaining: MAX_REQUESTS - 1 };
}
record.count++;
if (record.count > MAX_REQUESTS) {
return { allowed: false, remaining: 0 };
}
return { allowed: true, remaining: MAX_REQUESTS - record.count };
}
export function middleware(request: NextRequest) {
// Только для API
if (!request.nextUrl.pathname.startsWith('/api')) {
return NextResponse.next();
}
const ip = request.ip ?? request.headers.get('x-forwarded-for') ?? 'unknown';
const { allowed, remaining } = getRateLimitInfo(ip);
if (!allowed) {
return new NextResponse('Too Many Requests', {
status: 429,
headers: {
'Retry-After': '60',
'X-RateLimit-Remaining': '0',
},
});
}
const response = NextResponse.next();
response.headers.set('X-RateLimit-Remaining', remaining.toString());
return response;
}
export const config = {
matcher: '/api/:path*',
};Итоги
| Задача | Решение в Middleware |
|---|---|
| Авторизация | Проверка cookie/JWT, редирект |
| Редиректы | NextResponse.redirect() |
| Прокси | NextResponse.rewrite() |
| A/B тесты | Cookie + rewrite на вариант |
| Геолокация | request.geo (Vercel) |
| Rate limiting | In-memory / Redis |
Правила, которые я применяю к middleware.
config.matcher — обязателен, и он должен быть узким. Без него код выполняется для каждого запроса, включая статику, изображения и служебные файлы. Это и лишняя задержка на всём трафике, и лишние счета там, где middleware тарифицируется по вызовам.
Только лёгкие проверки. Разбор куки, чтение заголовка, редирект — да. Запрос к базе, обращение к внешнему API, тяжёлые вычисления — нет: эта задержка добавляется к каждому запросу, а не к одному эндпоинту.
Проверка подписи токена — да, проверка сессии в базе — нет. Это частный, но важный случай предыдущего правила и причина, по которой в middleware обычно оказывается именно JWT.
Учитывайте ограничения среды выполнения. Middleware работает не в полноценном Node.js: часть встроенных модулей и библиотек там недоступна. Библиотеку для работы с JWT нужно выбирать с оглядкой на это — многие популярные пакеты рассчитаны на Node и в middleware не заработают.
Ограничение частоты в памяти — только для одного экземпляра. Счётчик в переменной модуля не переживёт масштабирование: на нескольких экземплярах каждый будет считать свои запросы, и реальный лимит окажется кратно выше заданного. Для настоящего ограничения нужно общее хранилище.
Про A/B-тесты в middleware. Приём рабочий и часто рекомендуемый, но помните о его цене для кэширования: страница, отдающая разные варианты одному адресу, перестаёт быть кэшируемой целиком. На проекте с ISR это может стоить больше, чем даст сам тест, — считайте заранее.
Комментарии
Система комментариев скоро будет подключена. А пока вы можете написать мне в Telegram или на email.