Route handlers: route.ts как HTTP-интерфейс — методы, params и смена кеширования в Next 15
route.ts экспортирует GET/POST как функции над web Request/Response. Динамические сегменты приходят как params через await. Next 15 сделал GET динамичным по умолчанию — кеш включается через force-static. Хендлеры — публичный HTTP-интерфейс; server actions — внутренние мутации.
Команда на Next.js 14 выкатывает app/api/feature-flags/route.ts — GET-хендлер, который читает флаги из Postgres и возвращает JSON. В dev всё работает. В проде саппорт заводит тред: «мы выключили kill switch для сломанного чекаута двадцать минут назад, а пользователи всё ещё его видят». Хендлер в порядке; запрос в порядке. Проблема в том, что никто ничего не запрашивал: в Next 14 GET-хендлер без динамических API статически кешировался на этапе сборки, и прод раздавал JSON-снимок с последнего деплоя. Команда лечит это через export const dynamic = 'force-dynamic' и идёт дальше. Через год они обновляются до Next 15, где дефолт перевернулся на «без кеша» — и другой хендлер, тихо полагавшийся на кеш сборки как на щит для дорогого агрегирующего запроса, начинает бить в базу на каждый запрос. Одна файловая конвенция — два противоположных продакшен-инцидента. Route handlers просты; у их дефолтов кеширования есть история.
route.ts: HTTP-методы как экспортируемые функции
Route handler — замена pages/api в App Router: файл route.ts в любом сегменте app/, экспортирующий async-функции с именами HTTP-методов — GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS. Каждая функция получает веб-стандартный Request (или расширенный NextRequest) и возвращает веб-стандартный Response (или NextResponse). Никакого мутируемого res в стиле Express — вы возвращаете ответ, и именно это делает хендлеры компонуемыми и тривиально тестируемыми: вызвал функцию, проверил Response.
// app/api/items/route.ts
import { NextRequest, NextResponse } from "next/server";
export async function GET(request: NextRequest) {
const page = request.nextUrl.searchParams.get("page") ?? "1";
const items = await db.items.findPage(Number(page));
return NextResponse.json({ items });
}
export async function POST(request: NextRequest) {
const body = await request.json(); // web API: тело читается однократно
const created = await db.items.create(body);
return NextResponse.json(created, { status: 201 });
}На практике важны два правила контракта. Первое: route.ts и page.tsx не могут сосуществовать в одном сегменте — URL разрешается ровно в один из них, и сборка падает, если оба на него претендуют. Второе: если вы экспортировали часть методов, а клиент вызвал неэкспортированный, Next сам вернёт 405 Method Not Allowed — кроме OPTIONS, который Next реализует автоматически (перечисляя разрешённые методы), если вы не определили его сами.
Поскольку интерфейс — это пара web Request/Response, переносится всё, что вы знаете из fetch: request.headers.get('authorization'), await request.text() для сырых тел (именно так нужна проверка подписи вебхуков в стиле Stripe — распарсите в JSON, и проверка подписи провалится, потому что подпись считается над точными байтами), request.formData() для загрузок. NextRequest добавляет удобства сверху: request.nextUrl (разобранный URL с searchParams) и request.cookies для чтения.
Динамические сегменты: params теперь Promise
Хендлеры вложены в то же дерево папок, что и страницы, поэтому динамические сегменты работают одинаково: app/api/items/[id]/route.ts матчит /api/items/42. Значения сегментов приходят вторым аргументом — и с Next 15 поле params этого аргумента стало Promise, который нужно await-ить: часть той же миграции async-request-API, что сделала асинхронными cookies() и headers():
// app/api/items/[id]/route.ts
export async function GET(
_request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params; // Next 15: await it
const item = await db.items.find(id);
if (!item) return Response.json({ error: "not found" }, { status: 404 });
return Response.json(item);
}Синхронная форма в 15 ещё работает с предупреждением о deprecation — ровно та вещь, что усыпляет кодовую базу: компилируется, работает, а миграционный долг копится, пока следующая мажорная версия не превратит его в жёсткую ошибку. Есть codemod (next-async-request-api); прогнать его лучше, чем позже править сорок хендлеров руками.
Сценарий отказа: двойное чтение тела. await request.json() потребляет поток; второе чтение бросает исключение. Команды напарываются на это, когда логирующая обёртка читает тело «просто залогировать», а настоящий хендлер получает уже исчерпанный поток. Если читать нужно дважды — сначала request.clone(): это ответ веб-платформы, а не причуда Next.
Вебхук-хендлер делает `const event = await request.json()` и затем передаёт `request` в общий хелпер verifySignature(request), который вызывает `await request.text()`. Проверка подписи всегда падает или бросает исключение. Почему?
Переворот кеширования: Next 14 кешировал GET, Next 15 — нет
Это часть с историей, и два инцидента из Hook — её две половины. В Next 14 и раньше GET-хендлер трактовался как статическая страница: если он не использовал динамических API (не читал объект запроса по существу, без cookies()/headers(), без других экспортированных методов рядом), Next вычислял его на этапе сборки и раздавал кешированный результат. Эндпоинт флагов, эндпоинт статуса, эндпоинт «текущей цены» — все молча замораживались в момент деплоя. Опасным это делало нарушение ментальной модели: в fetch('/api/feature-flags') ничто не выглядит кешируемым для читателя кода.
Next 15 перевернул дефолт: GET-хендлеры не кешируются — каждый запрос выполняет функцию. Если статическое поведение нужно, вы включаете его per-file через конфиг сегмента:
// Вернуться к статическому кешированию на этапе сборки (Next 15+)
export const dynamic = "force-static";
export const revalidate = 60; // optional: ISR-style re-generation every 60sКомпромисс честен в обе стороны. Динамика по умолчанию совпадает с ожиданиями от «API-эндпоинта» и убивает класс багов со stale-снимками; цена — хендлеры, которые опирались на старый дефолт как на бесплатный кеш (дорогой агрегат из Hook), после апгрейда превращаются в per-request нагрузку на базу. Лечение — сделать кеширование явным: force-static плюс revalidate для данных, терпящих устаревание, или кеш уровня приложения (unstable_cache, Redis), когда нужны и осведомлённость о запросе, и дешёвые чтения. Дефолты мигрируют; явный конфиг переживает апгрейды.
Route handler или server action?
Оба выполняют серверный код в одной кодовой базе, поэтому команды их смешивают. Правило выбора: route handler — публичный HTTP-контракт; server action — внутренний RPC для вашего же UI.
Берите route handler, когда эндпоинт вызывает что-то кроме ваших собственных Next.js-компонентов: сторонние вебхуки, мобильное приложение, партнёрская интеграция, OAuth-коллбэк — всё, чему нужен стабильный URL, явные статус-коды, content negotiation или не-JSON-ответы (файлы, стримы, редиректы с Set-Cookie). Хендлеры — это ещё и то, что в инциденте можно дёрнуть curl-ом.
Берите server action, когда ваша же форма или компонент мутирует данные: вы получаете типизированные аргументы вместо ручного парсинга тел, прогрессивное улучшение форм и встроенную интеграцию с revalidatePath/revalidateTag. Отсюда два следствия, которые удивляют. Экшены выполняются последовательно для каждого клиента — это не инструмент параллельной загрузки данных. И экшены компилируются в POST-эндпоинты со сгенерированными фреймворком идентификаторами — они всё ещё в сети, всё ещё достижимы атакующим и требуют тех же проверок авторизации, что и любой хендлер; «внутренний» описывает предполагаемого вызывающего, а не границу безопасности.
Антипаттерн — команда, которая строит app/api/.../route.ts под каждый сабмит формы и потом вручную пишет fetch-вызовы, состояния загрузки и инвалидацию кеша, которые экшены дали бы бесплатно, — или обратное: server action как де-факто публичный API, чей контракт на идентификаторах ломается при каждом деплое.
После апгрейда 14 → 15 GET-хендлер /api/leaderboard, агрегирующий 2 млн строк, переходит от ничтожной нагрузки к ~40 q/s в Postgres, а p95-латентность эндпоинта утраивается. Что произошло и каков правильный по форме фикс?
- 01Каков контракт файла route.ts — экспорты, аргументы, возврат и два структурных правила?
- 02Как менялось кеширование GET-хендлеров между Next 14 и 15 и что ломается в каждую сторону?
Route handler — это файл route.ts, экспортирующий HTTP-методы как async-функции над парой веб-платформы: Request на входе, Response на выходе, без мутируемого res. Он владеет сегментом эксклюзивно — route.ts и page.tsx не могут претендовать на один URL, — а Next сам отдаёт 405 для отсутствующих методов и автоматический OPTIONS. NextRequest добавляет nextUrl с разобранными searchParams и доступ к cookies; тела — одноразовые потоки, поэтому вебхук-хендлеры читают text() один раз, проверяют подпись над сырыми байтами и только потом парсят — а всё, чему нужно второе чтение, обязано сначала clone(). Динамические сегменты работают как в страницах, с поправкой Next 15: params в контексте — Promise, который нужно await-ить, часть миграции async-request-API с поддержкой codemod. История кеширования важна операционно: Next 14 замораживал обычные GET-хендлеры на этапе сборки, раздавая stale-снимки флагов и цен и обжигая команды, считавшие, что эндпоинт выполняется на каждый запрос; Next 15 инвертировал дефолт в динамику, разморозив эти баги и одновременно обнажив хендлеры, прятавшие дорогие запросы за неявным кешем. Вывод — записывать кеширование явно: force-static плюс revalidate, когда устаревание приемлемо, явные слои кеша, когда нет, — дефолты мигрируют между мажорами, явный конфиг — нет. Наконец, граница с server actions: хендлеры — публичная HTTP-поверхность для вебхуков, мобильных клиентов, OAuth-коллбэков, стримов и всего curl-able; экшены — типизированный внутренний RPC для ваших форм со встроенной ревалидацией, выполняющийся последовательно для клиента, — и, несмотря на слово «внутренний», это всё ещё достижимые атакующим POST-эндпоинты, требующие той же авторизации, что и любой хендлер. Теперь, когда встретишь GET-эндпоинт, будто замороженный на момент деплоя, или хендлер, удвоивший нагрузку на базу после апгрейда, — ты знаешь, куда смотреть: дефолт кеширования, изменившийся между версиями, и одна строка конфига, которая делает его явным.
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.