Главная/Статьи/Middleware в Next.js — auth, редиректы, A/B тесты

Middleware в Next.js — auth, редиректы, A/B тесты

Middleware выполняется до рендеринга страниц. Разбираем авторизацию, геолокацию, A/B тестирование и защиту роутов на Edge Runtime.

ДМ
Дмитрий Мещеряков
📅 5 августа 2026 г.📖 8 мин чтения

Middleware выполняется перед каждым подходящим запросом — до рендеринга, до обработчиков, максимально близко к пользователю. Отсюда и его сила, и его главное ограничение: код там работает в урезанной среде выполнения и на горячем пути всего трафика.

Разберём, что туда стоит класть, а что — категорически нет.

Что такое Middleware

Middleware — функция, которая выполняется до обработки запроса. Работает на Edge Runtime (V8 isolates), поэтому очень быстрая.

text
Запрос → Middleware → Route Handler/Page

         Redirect?
         Rewrite?
         Modify headers?
💡 Совет

Edge Runtime ограничения: Нет Node.js API (fs, path), нет eval(), размер бандла до 4MB. Используйте только Edge-совместимые пакеты.

Базовый middleware

typescript
// 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 при этом остаётся полезен — он избавляет от рендеринга страницы, которую всё равно нельзя показать, — но полагаться на него как на единственную защиту нельзя.

Проверка токена

typescript
// 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 валидация

typescript
// 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

Редиректы

typescript
// 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 (прокси)

typescript
// 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 тестирование

typescript
// 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: '/',
};

Многовариантный тест

typescript
// 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();
}

Геолокация

typescript
// 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

typescript
// 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 limitingIn-memory / Redis

Правила, которые я применяю к middleware.

config.matcher — обязателен, и он должен быть узким. Без него код выполняется для каждого запроса, включая статику, изображения и служебные файлы. Это и лишняя задержка на всём трафике, и лишние счета там, где middleware тарифицируется по вызовам.

Только лёгкие проверки. Разбор куки, чтение заголовка, редирект — да. Запрос к базе, обращение к внешнему API, тяжёлые вычисления — нет: эта задержка добавляется к каждому запросу, а не к одному эндпоинту.

Проверка подписи токена — да, проверка сессии в базе — нет. Это частный, но важный случай предыдущего правила и причина, по которой в middleware обычно оказывается именно JWT.

Учитывайте ограничения среды выполнения. Middleware работает не в полноценном Node.js: часть встроенных модулей и библиотек там недоступна. Библиотеку для работы с JWT нужно выбирать с оглядкой на это — многие популярные пакеты рассчитаны на Node и в middleware не заработают.

Ограничение частоты в памяти — только для одного экземпляра. Счётчик в переменной модуля не переживёт масштабирование: на нескольких экземплярах каждый будет считать свои запросы, и реальный лимит окажется кратно выше заданного. Для настоящего ограничения нужно общее хранилище.

💡 Совет

Про A/B-тесты в middleware. Приём рабочий и часто рекомендуемый, но помните о его цене для кэширования: страница, отдающая разные варианты одному адресу, перестаёт быть кэшируемой целиком. На проекте с ISR это может стоить больше, чем даст сам тест, — считайте заранее.

🚀

Хотите такое же решение?

Настрою окружение под ваш проект, учту специфику инфраструктуры и обучу команду.

Обсудить проект →
Бесплатная консультация · Ответ в течение дня

Комментарии

Система комментариев скоро будет подключена. А пока вы можете написать мне в Telegram или на email.