Мимо страниц: route.ts и proxy.ts

кеш и прод≈ 11 минут

Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.

Не каждому адресу нужна разметка. Вебхук платёжной системы, выгрузка в CSV, health-check для мониторинга ждут данных, а не страницы — и в App Router такой адрес делает файл route.ts: функция получает Request и возвращает Response. А перед ней, до того как Next вообще решит, какой маршрут отвечает, успевает поработать proxy.ts — то, что до шестнадцатой версии звалось middleware.

Разделы темы:·

В папке app адрес занимает файл page.tsx — это вы знаете из статьи «App Router: адрес — это папка». Рядом с ним есть второе зарезервированное имя: route.ts. Оно тоже забирает себе адрес папки, но отвечает не деревом React, а тем, что вы вернули: JSON, текстом, файлом, пустотой с кодом статуса.

Обработчик — обычная функция. Она принимает Request и возвращает Response: те самые объекты веб-платформы, которые вы видели с другой стороны провода в статье про fetch. Никакого res.send и никакого фреймворкового объекта ответа здесь нет.

В настоящем route.ts перед функцией стоит export. Песочница выполняет один файл без модулей, поэтому в примерах это слово опущено — во всём остальном код тот же.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Статус 200 и заголовок content-type никто не выставлял — их поставил Response.json. И обратите внимание на последние две строки примера: обработчик вызван напрямую, без сервера, без Next, без сети. Это не поблажка песочницы, а суть route.ts — там лежит функция, которую можно вызвать и протестировать как любую другую.

Так тоже можно, и работать будет — ровно до того момента, когда клиент попросит у ответа JSON и получит строку. Разница в одном заголовке, который легко не заметить.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

new Response(строка) считает содержимое обычным текстом. Браузер, fetch и чужой сервис ориентируются на заголовок, поэтому руками собранный ответ придётся руками и подписывать: { headers: { "content-type": "application/json" } }. Response.json делает это за вас.

page.tsxroute.ts
Что экспортируетодну функцию по умолчаниюпо функции на метод: GET, POST…
Чем отвечаетразметкой — деревом Reactобъектом Response
Кто читает ответбраузер, который рисует страницулюбой клиент: чужой сервис, curl, приложение
Когда нуженэкран для человекаответ для программы

В одной папке эти два имени не уживаются: адрес у папки один, и делить его не с кем. Документация Next формулирует это без оговорок: «There cannot be a route.js file at the same route as page.js». Поэтому обработчики обычно держат отдельным поддеревом app/api/…, где страниц нет вовсе.

И ещё одно, о чём стоит знать заранее: обработчик по умолчанию динамический. В таблице сборки он помечен ƒ — Dynamic, то есть выполняется на каждый запрос. Статическим ответ становится, только если попросить об этом явно: export const dynamic = "force-static", revalidate или 'use cache' — про это две предыдущие статьи главы.

Своему же интерфейсу отдельный /api в App Router чаще всего не нужен: данные серверный компонент читает прямо в себе (await вместо useEffect), а изменения делают Server Actions. route.ts остаётся там, где клиент — не ваша страница: вебхук чужого сервиса, публичный API, выгрузка файла, ответ монитору «жив».

Экспортов в файле может быть несколько, и имя функции — это и есть метод запроса. Next знает семь имён: GET, HEAD, OPTIONS, POST, PUT, DELETE, PATCH. Функция с любым другим именем просто не будет никем вызвана.

Имена строго прописными. Экспорт get вместо GET в режиме разработки даёт ошибку в консоли сервера: «Detected lowercase method 'get'… Export the uppercase 'GET' method name to fix this error». А export default, обязательный для page.tsx, здесь запрещён — на него Next отвечает «Export a named export for each HTTP method instead».

Интереснее другое: что случится с методом, которого вы не написали? Ответ Next достраивает сам, и правила у него ровно такие, как в примере ниже.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Ненаписанный метод получает 405 Method Not Allowed, а не 404: адрес существует, просто так с ним разговаривать нельзя. HEAD Next подставляет из GET, а OPTIONS отвечает сам — пустым телом, кодом 204 и заголовком Allow, в котором перечислено всё, что этот адрес принимает. Ни одной из этих строк в вашем файле нет.

Метод, которого нет в списке семи, до вашего файла вообще не доходит: на какой-нибудь PROPFIND Next отвечает 400 сам — произвольному имени из запроса нечего делать в диспетчере.

У Request те же методы чтения, что вы вызывали у ответа на клиенте: await request.json(), await request.text(), await request.formData(). Всё, что пришло по сети, — это unknown, и разбирать его нужно самому: тип на границе программы вы уже проходили в статье «Данные извне — это unknown».

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Второй аргумент Response.json — обычный ResponseInit: статус и заголовки. 201 означает «создано», 422 — «данные пришли, но они негодные». Клиенту этого достаточно, чтобы отличить свою ошибку от вашей, не разбирая текст.

А теперь самая частая поломка в обработчиках. Разработчик добавляет строку «посмотреть, что пришло» — и всё перестаёт работать. Предскажите, что напечатает пример.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Тело запроса — поток, и вычерпать его можно один раз. Первое чтение забрало всё, второму не досталось ничего. Текст сообщения у каждого движка свой, а тип один — TypeError. Лечится тем же, чем и любое двойное чтение: читаем один раз, а дальше работаем со строкой — const raw = await request.text(), затем JSON.parse(raw). Если нужны именно два независимых чтения, есть request.clone(), но обычно это признак того, что код стоит перечитать.

request.json() на непонятном теле бросает исключение. Без обработки оно всплывёт наружу и превратится в 500 — то есть «сервер сломался», хотя сломался запрос.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Правило простое: 4xx — клиент прислал не то, 5xx — сервер не справился с тем, что прислали. Ошибка разбора тела почти всегда первое.

Если на адрес отправляют форму — например, обычный <form action="/api/tasks"> без единой строчки JS, — тело читают через request.formData(). Это тот же объект FormData, что вы разбирали в статье про неуправляемые поля.

Квадратные скобки в имени папки работают у обработчиков так же, как у страниц: app/api/tasks/[id]/route.ts отвечает на /api/tasks/7. Сегмент приезжает вторым аргументом — в объекте контекста. И там его ждёт ровно та же ловушка, что в статье про динамические сегменты.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Диагностика показывает главное: params — промис, а не объект. Но посмотрите на вывод: код всё равно выполнился и ответил 200. id оказался undefined, JSON.stringify молча выбросил такое поле — и клиент получил бодрое «всё хорошо» с пустым телом. От такого ответа вас отделяла ровно одна диагностика компилятора.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Тип контекста можно не писать руками: next typegen раскладывает по проекту глобальный RouteContext, и для нашей папки это RouteContext<"/api/tasks/[id]"> — имена сегментов он берёт из дерева папок, а не из вашей памяти.

Есть код, которому нужно выполниться раньше всех маршрутов: увести неавторизованного на /login, перенаправить старые адреса, подмешать заголовок, выбрать вариант A/B-теста. В Next до шестнадцатой версии это был файл middleware.ts; в Next 16 он называется proxy.ts, а функция внутри — proxy. Лежит он в корне проекта (или в src) рядом с папкой app, а не внутри неё.

Переименование — не косметика, а уточнение смысла. «Middleware» звучало как слой приложения, куда можно положить бизнес-логику; на деле это привратник на входе, который смотрит на запрос до того, как Next решит, кто будет отвечать. Порядок такой:

ШагЧто делает Next
1заголовки и редиректы из next.config
2proxy.ts — если адрес попал под matcher
3переписывания адресов из beforeFiles
4поиск по файловой системе: статика, page.tsx, route.ts
5остальные переписывания и, если ничего не нашлось, 404

Четвёртый шаг — это и есть «выбор маршрута». Всё, что делает proxy.ts, происходит до него: обработчик из route.ts в этот момент ещё даже не выбран.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Вернули Response — на этом всё, ответ уходит клиенту и до маршрута дело не доходит. Не вернули ничего — запрос идёт дальше своим чередом. В настоящем proxy.ts вместо голого Response обычно берут NextResponse из next/server: NextResponse.redirect(url), NextResponse.rewrite(url) и NextResponse.next() — то же самое плюс удобная работа с куками и заголовками. Первый аргумент функции тогда типизируют как NextRequest: у него есть request.cookies и разобранный request.nextUrl.

Кому именно достаётся вызов, решает не if внутри функции, а matcher снаружи: export const config = { matcher: ["/admin/:path*"] }. Без него proxy зовётся вообще на всё — на картинки, на служебные адреса, на каждый префетч. Это работа на пути каждого запроса, и оплачивается она задержкой для пользователя.

В Next 16 proxy всегда выполняется в Node-рантайме, и задать в этом файле runtime нельзя: сборка падает с «Route segment config is not allowed in Proxy file». Раньше middleware жил в Edge-рантайме, где половины Node-API не было.

Чего в proxy.ts делать не стоит — тяжёлой работы и единственной проверки доступа. Редирект неавторизованного на /login здесь уместен: это удобство. Но настоящая проверка обязана стоять там, где лежат данные, — в обработчике, в действии, в серверном компоненте. Привратник, которого можно обойти, охраной не считается.

Файл со старым именем Next 16 подхватит, но при сборке напишет: «The "middleware" file convention is deprecated. Please use "proxy" instead» — и предложит codemod npx @next/codemod@canary middleware-to-proxy .

А вот держать оба файла сразу нельзя: сборка остановится с сообщением, что найдены и middleware, и proxy, и надо оставить один. Внутри proxy.ts подойдёт и export function proxy, и export default — Next смотрит сначала на именованный экспорт, потом на умолчательный.

  • route.ts занимает адрес папки и отвечает не разметкой, а Response; с page.tsx в одной папке он не уживается.
  • Обработчик — обычная функция (Request) => Response: её можно вызвать руками и протестировать без сервера.
  • Имя экспорта — это метод запроса; ненаписанные методы Next закрывает ответом 405, а HEAD и OPTIONS достраивает сам.
  • Тело запроса читается один раз: лог через text() перед json() ломает обработчик с TypeError.
  • В динамическом сегменте params приходит промисом — без await компилятор ругается, а ответ уходит пустым и с кодом 200.
  • proxy.ts (бывший middleware.ts) выполняется до выбора маршрута: редиректы и переписывания — да, тяжёлая логика и единственная проверка доступа — нет.

1/4В app/api/tasks/route.ts экспортирована одна функция GET. Что вернёт запрос POST /api/tasks?

2/4Обработчик сначала печатает await request.text() «чтобы посмотреть, что пришло», а следующей строкой вызывает await request.json(). Что произойдёт?

3/4В app/api/tasks/[id]/route.ts написано const id = context.params.id. Что скажет компилятор и что случится при запуске?

4/4Где в Next 16 живёт код, который должен выполниться до того, как выберется маршрут?

Фильтр в строке запроса

Обработчик отдаёт все задачи. Научите его понимать параметр ?done=: true — только сделанные, false — только оставшиеся, параметра нет — все. Любое другое значение — это ошибка клиента: ответьте 400 и объяснением в теле.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Значение достаём из searchParams. Отсутствие параметра (null) и неизвестное значение — разные случаи, и путать их нельзя: первое означает «фильтра нет», второе — ошибку клиента.

tsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Обратите внимание на export — в настоящем файле он обязателен, иначе Next решит, что методов в файле нет вовсе.

Лог, который ломает разбор

Обработчик писали второпях: сначала печатаем тело в лог, потом разбираем. Ни один запрос не проходит. Почините так, чтобы в выводе оказалось и содержимое тела, и созданная задача, а на неразбираемом теле ответ был 400.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Тело читается один раз. Читаем его в строку — и лог, и разбор работают с ней, а не с потоком. Разбор заворачиваем в try: битое тело — ошибка клиента, то есть 400, а не падение сервера.

tsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Вывод станет таким: первое тело напечатается и вернётся с кодом 201, второе — напечатается и получит 400. Второй вариант — request.clone() перед первым чтением, но он честно копирует поток и нужен редко.

Привратник у /admin

Напишите proxy: адреса, начинающиеся с /admin, без заголовка authorization уводите на /login, добавив в строку запроса from с исходным путём. Всё остальное пропускайте дальше.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Проверки идут от дешёвой к дорогой: сначала путь, потом заголовок. «Пропустить» — это вернуть undefined.

tsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

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