Next.js 16: міграція з middleware.ts на proxy.ts — повний гід

У Next.js 16 файл middleware.ts офіційно перейменовано на proxy.ts. Розбираємо причини, codemod-міграцію, нові обмеження runtime та безпечні патерни автентифікації, які витримають CVE-2025-29927.

Next.js 16 proxy.ts: Міграція з middleware

Реліз Next.js 16 приніс одну з найпомітніших змін у роутингу за останні кілька років: файл middleware.ts офіційно перейменовано на proxy.ts. На перший погляд — звичайна косметика. Але якщо чесно, за нею стоїть зміна філософії: команда Vercel хоче, щоб ви менше покладалися на цей рівень для бізнес-логіки та автентифікації, а більше — на Server Components, Server Actions і Data Access Layer (DAL).

У цьому посібнику розберемо: навіщо зробили перейменування, як виконати міграцію буквально за хвилину завдяки codemod, які пастки чекають на матчерах і чому уразливість CVE-2025-29927 назавжди змінила підхід до автентифікації в App Router.

Чесно зізнаюся: коли я вперше побачив changelog, реакція була «ну от, знову». Але після кількох вечорів з новим API стало зрозуміло — зміна назрівала давно.

Що саме змінилося в Next.js 16

До 16-ї версії точкою входу для перехоплення запитів був middleware.ts у корені проєкту. Тепер той самий файл називається proxy.ts, а замість іменованого експорту middleware очікується proxy (або default-експорт). Логіка не змінилася — інтерфейс той самий, набір API той самий. Просто сигнатура файлу нова.

Ключові факти, які варто запам'ятати:

  • Файл: middleware.tsproxy.ts у корені проєкту або в src/.
  • Експорт: функція middlewareproxy.
  • Конфіг-флаги: skipMiddlewareUrlNormalizeskipProxyUrlNormalize, аналогічно для skipTrailingSlashRedirect.
  • Runtime: proxy.ts працює на Node.js runtime за замовчуванням і Edge не підтримує.
  • Сумісність: middleware.ts досі працює, але задепрековано — на Edge ще можна, на Node — вже ні.

Навіщо було перейменовувати

В офіційному блозі команда Next.js пояснила доволі просто: слово «middleware» історично перенасичене значеннями. У світі Express це проміжний шар обробки запитів, у Redux — мідлвара екшенів, у NestJS — guards/interceptors. Програмісти автоматично припускали, що Next.js middleware — це місце для бізнес-логіки, повноцінної автентифікації, читання БД. А насправді він задумувався як тонкий мережевий шар перед застосунком: редіректи, A/B-тестування, geo-routing, базові перевірки заголовків.

Назва proxy чесніше описує суть: це reverse-proxy перед вашим додатком. Він має знаходитися ближче до клієнта, бути швидким, без БД-викликів і без важких операцій. Ось і все.

Покрокова міграція: codemod за 30 секунд

Vercel надає офіційний codemod, який автоматично перейменує файл і експорт:

npx @next/codemod@latest middleware-to-proxy ./

Що зробить codemod:

  1. Перейменує middleware.ts на proxy.ts (або .js).
  2. Замінить export middleware на proxy.
  3. Оновить імпорти, які посилаються на стару назву.
  4. Замінить конфіг-флаги в next.config.js.

Після запуску обов'язково перевірте git diff — codemod не зачіпає вашу бізнес-логіку, лише перейменування. Якщо ви хочете залишитися на Edge runtime (наприклад, через geo-routing з низькою латентністю), не запускайте codemod і продовжуйте використовувати middleware.ts. Тільки майте на увазі: це шлях у нікуди.

Базовий proxy.ts: мінімальний приклад

Ось скелет нового proxy.ts, який перевіряє наявність session-кукі та редіректить неавторизованих користувачів:

// proxy.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';

export function proxy(request: NextRequest) {
  const session = request.cookies.get('session')?.value;
  const { pathname } = request.nextUrl;

  const isProtected = pathname.startsWith('/dashboard') ||
                      pathname.startsWith('/settings');

  if (isProtected && !session) {
    const loginUrl = new URL('/login', request.url);
    loginUrl.searchParams.set('callbackUrl', pathname);
    return NextResponse.redirect(loginUrl);
  }

  return NextResponse.next();
}

export const config = {
  matcher: [
    '/dashboard/:path*',
    '/settings/:path*',
  ],
};

Зверніть увагу: ми не валідуємо JWT, не звертаємося до бази, не розшифровуємо токен. Ми лише перевіряємо наявність кукі — це швидка перевірка на рівні мережі. Сама автентифікація відбувається пізніше, ближче до даних.

Урок CVE-2025-29927: proxy — не межа безпеки

У березні 2025 року дослідники розкрили уразливість CVE-2025-29927 з оцінкою CVSS 9.1. Атакуючий міг повністю обійти middleware-перевірки, додавши заголовок x-middleware-subrequest до запиту. Уразливі версії: 11.1.4–12.3.4, 13.0.0–13.5.8, 14.0.0–14.2.24, 15.0.0–15.2.2.

Уроком стало архітектурне правило, яке тепер навіть документація Next.js повторює явно:

Proxy / middleware — це не межа безпеки. Це маршрутизатор. Реальні перевірки автентифікації мають жити у Route Handlers, Server Actions і Data Access Layer.

Це означає, що навіть якщо ваш proxy.ts ідеально перевіряє кукі, кожен Server Action і кожен запит до БД має повторно перевіряти сесію. Звучить як дублювання? Так. Але саме воно врятує вас від наступного CVE, помилки в матчері або випадкового рефакторингу, який «забере» певний маршрут з-під захисту.

Data Access Layer: золотий стандарт 2026

DAL — це централізований модуль, через який проходять усі читання й записи даних. У ньому, перед поверненням даних, ви завжди викликаєте verifySession(). Виглядає це приблизно так:

// lib/dal.ts
import 'server-only';
import { cookies } from 'next/headers';
import { cache } from 'react';
import { redirect } from 'next/navigation';
import { decrypt } from './session';
import { db } from './db';

export const verifySession = cache(async () => {
  const cookie = (await cookies()).get('session')?.value;
  const session = await decrypt(cookie);

  if (!session?.userId) {
    redirect('/login');
  }

  return { isAuth: true, userId: session.userId };
});

export const getCurrentUser = cache(async () => {
  const session = await verifySession();

  const user = await db.user.findUnique({
    where: { id: session.userId },
    select: { id: true, email: true, role: true },
  });

  return user;
});

Тепер у будь-якому Server Component або Server Action достатньо викликати getCurrentUser() — і автентифікація автоматично перевіряється. Завдяки cache() від React один запит = одна перевірка, навіть якщо викликати функцію десять разів у різних компонентах. Дуже зручно.

HttpOnly-кукі замість localStorage

Ще одне залізне правило: токен сесії має жити в HttpOnly-кукі, а не в localStorage. Причини банальні, але часто забуваються:

  • localStorage невидимий серверу. Ваш proxy.ts просто не зможе його прочитати — а отже, перевірка автентифікації на рівні мережі неможлива.
  • XSS читає localStorage без проблем. Будь-який JS на сторінці отримує доступ до сховища. HttpOnly-флаг закриває кукі від клієнтського JS.
  • HttpOnly-кукі автоматично надсилаються з кожним запитом, тому сервер може перевірити сесію без зайвих заголовків.
// app/api/login/route.ts
import { cookies } from 'next/headers';

export async function POST(req: Request) {
  // ... перевірка credentials, генерація токена
  const cookieStore = await cookies();
  cookieStore.set('session', token, {
    httpOnly: true,
    secure: process.env.NODE_ENV === 'production',
    sameSite: 'lax',
    maxAge: 60 * 60 * 24, // 24 години
    path: '/',
  });

  return Response.json({ ok: true });
}

Matcher-патерни: типові пастки

Найпоширеніша помилка — занадто загальний матчер, який ловить власні редіректи й створює нескінченний цикл (ERR_TOO_MANY_REDIRECTS). Я сам колись на цьому згорів — годину дебажив, поки не зрозумів, що матчер ловить сам себе. Класичний антипатерн:

// ❌ Цей патерн ловить /api/auth/signin і робить нескінченний редірект
export const config = {
  matcher: ['/((?!api|_next/static|_next/image|.*\.png$).*)'],
};

Краще описати маршрути явно:

// ✅ Явний список захищених сегментів
export const config = {
  matcher: [
    '/dashboard/:path*',
    '/admin/:path*',
    '/settings/:path*',
  ],
};

Якщо все-таки потрібен «catch-all», обов'язково перевіряйте всередині proxy, чи ви вже не на сторінці входу:

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

  if (pathname.startsWith('/login') || pathname.startsWith('/api/auth')) {
    return NextResponse.next();
  }
  // ... решта логіки
}

Rate-limiting на рівні proxy

Хоч важка логіка не належить proxy, обмеження частоти запитів — навпаки, ідеальний кейс. Це швидка перевірка, яка має відсіювати трафік ДО того, як він дійде до Server Actions. Приклад на Upstash Redis:

// proxy.ts
import { Ratelimit } from '@upstash/ratelimit';
import { Redis } from '@upstash/redis';

const ratelimit = new Ratelimit({
  redis: Redis.fromEnv(),
  limiter: Ratelimit.slidingWindow(10, '10 s'),
});

export async function proxy(request: NextRequest) {
  if (request.nextUrl.pathname.startsWith('/api/login')) {
    const ip = request.headers.get('x-forwarded-for') ?? 'anonymous';
    const { success } = await ratelimit.limit(ip);

    if (!success) {
      return new NextResponse('Too many requests', { status: 429 });
    }
  }

  return NextResponse.next();
}

RBAC та перевірки ролей

Через Partial Rendering у Next.js 16 layout-и не перерендеряться при кожному переході — тому перевірки ролей у layout-ах ненадійні. Виносьте їх у Server Components сторінок або, ще краще, у DAL-функції, які повертають дані. Приклад:

// app/admin/page.tsx
import { getCurrentUser } from '@/lib/dal';
import { redirect } from 'next/navigation';

export default async function AdminPage() {
  const user = await getCurrentUser();

  if (user.role !== 'admin') {
    redirect('/forbidden');
  }

  return <AdminDashboard user={user} />;
}

Чек-лист міграції з middleware.ts на proxy.ts

  • ✅ Запустіть npx @next/codemod@latest middleware-to-proxy ./
  • ✅ Перевірте git diff: перейменування файлу та експорту
  • ✅ Оновіть конфіг-флаги (skipProxyUrlNormalize тощо)
  • ✅ Винесіть автентифікацію в DAL з verifySession()
  • ✅ Перенесіть session-токени в HttpOnly + Secure + SameSite cookies
  • ✅ Замініть generic-матчер на явний список маршрутів
  • ✅ Додайте rate-limiting для /api/login та /api/auth
  • ✅ Перевірте, що в Server Actions є власна перевірка сесії
  • ✅ Оновіть Next.js до версії, що містить фікс CVE-2025-29927 (15.2.3+ або 16+)

FAQ

Чи можна продовжувати використовувати middleware.ts у Next.js 16?

Так, але з обмеженнями. Файл middleware.ts досі працює на Edge runtime, проте офіційно задепрековано і буде видалено в одній з наступних версій. На Node.js runtime він уже не підтримується. Рекомендується перейти на proxy.ts якомога раніше — поки міграція простіша, ніж зворотний бій з регресіями.

Чи працює proxy.ts на Edge Runtime?

Ні. proxy.ts запускається на Node.js runtime, і це не налаштовується. Якщо вам критично потрібен Edge (наприклад, для geo-routing з мінімальною латентністю), залишайтесь на middleware.ts доти, доки команда Vercel не запропонує заміну.

Чи безпечно покладатися лише на proxy.ts для автентифікації?

Ні. Уразливість CVE-2025-29927 показала, що навіть бездоганний proxy може бути обійдений через помилку фреймворка. Реальні перевірки сесії мають дублюватися в Server Actions, Route Handlers і Data Access Layer. Proxy — це маршрутизатор, а не межа безпеки.

Як перевіряти JWT у proxy.ts?

Не варто — JWT-валідація з криптографією є важкою для мережевого шару. У proxy перевіряйте лише наявність кукі. Повну валідацію з перевіркою підпису, expires і ролей виконуйте в DAL, який кешується через cache() і викликається в Server Components.

Чи потрібно переписувати Auth.js / NextAuth під proxy.ts?

Codemod автоматично перейменує файл, але перевірте офіційну документацію вашої auth-бібліотеки. Auth.js v5 та Better Auth v1 уже випустили оновлення з підтримкою proxy.ts. Auth0 SDK теж адаптовано — у документації описано приклад інтеграції через auth0.middleware(request) у новому файлі proxy.ts.

Підсумок

Перейменування middleware.ts на proxy.ts — не косметика, а явний сигнал: цей рівень не для бізнес-логіки. Тримайте його тонким, передбачуваним і швидким. Автентифікацію виносьте в DAL, токени — в HttpOnly-кукі, перевірки — повторюйте в кожному Server Action. Тоді наступний CVE для вас буде новиною з блогу, а не інцидентом продакшну.

Про Автора Marcus Okonkwo

Marcus picked up React in 2017 and has been writing it professionally ever since. Five years at Stripe on the Dashboard team, where he led the migration of the billing UI off a legacy webpack 4 setup onto Next.js with module federation for the embedded merchant components. He shipped the first internal RSC prototype there in late 2023, which mostly worked. Now he's a staff engineer at a logistics startup in Austin, owning the customer-facing Next.js app and the design system that backs it. He spends a disproportionate amount of time thinking about route group conventions, parallel routes for modal UX, and why next/image keeps surprising people on Cloudflare's image resizing. His writing favors annotated code over prose. Seven years of React, two of those deep in App Router land.