open atlas
↑ К треку
Next.js с нуля до senior NEXT · 03 · 01

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 Middle ◷ 17 min
Уровень
ОсновыJuniorMiddleSenior

Команда на 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-латентность эндпоинта утраивается. Что произошло и каков правильный по форме фикс?

Вспомните перед уходом
  1. 01
    Каков контракт файла route.ts — экспорты, аргументы, возврат и два структурных правила?
  2. 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-уровень. Открой, попробуй, потом открой ответ.

вспомнитьприменитьуглубить0 из 6 завершено
Связанные уроки

Что-то непонятно?

Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.

хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources2
expand
  1. 01
  2. 02

Trademarks belong to their respective owners. Editorial reference only.