Мимо страниц: route.ts и proxy.ts
Здесь будет встроен видео-разбор темы (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. Песочница выполняет один файл без модулей, поэтому в примерах это слово опущено — во всём остальном код тот же.
Статус 200 и заголовок content-type никто не выставлял — их поставил Response.json. И обратите внимание на последние две строки примера: обработчик вызван напрямую, без сервера, без Next, без сети. Это не поблажка песочницы, а суть route.ts — там лежит функция, которую можно вызвать и протестировать как любую другую.
Так тоже можно, и работать будет — ровно до того момента, когда клиент попросит у ответа JSON и получит строку. Разница в одном заголовке, который легко не заметить.
new Response(строка) считает содержимое обычным текстом. Браузер, fetch и чужой сервис ориентируются на заголовок, поэтому руками собранный ответ придётся руками и подписывать: { headers: { "content-type": "application/json" } }. Response.json делает это за вас.
page.tsx | route.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 достраивает сам, и правила у него ровно такие, как в примере ниже.
Ненаписанный метод получает 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».
Второй аргумент Response.json — обычный ResponseInit: статус и заголовки. 201 означает «создано», 422 — «данные пришли, но они негодные». Клиенту этого достаточно, чтобы отличить свою ошибку от вашей, не разбирая текст.
А теперь самая частая поломка в обработчиках. Разработчик добавляет строку «посмотреть, что пришло» — и всё перестаёт работать. Предскажите, что напечатает пример.
Тело запроса — поток, и вычерпать его можно один раз. Первое чтение забрало всё, второму не досталось ничего. Текст сообщения у каждого движка свой, а тип один — TypeError. Лечится тем же, чем и любое двойное чтение: читаем один раз, а дальше работаем со строкой — const raw = await request.text(), затем JSON.parse(raw). Если нужны именно два независимых чтения, есть request.clone(), но обычно это признак того, что код стоит перечитать.
request.json() на непонятном теле бросает исключение. Без обработки оно всплывёт наружу и превратится в 500 — то есть «сервер сломался», хотя сломался запрос.
Правило простое: 4xx — клиент прислал не то, 5xx — сервер не справился с тем, что прислали. Ошибка разбора тела почти всегда первое.
Если на адрес отправляют форму — например, обычный <form action="/api/tasks"> без единой строчки JS, — тело читают через request.formData(). Это тот же объект FormData, что вы разбирали в статье про неуправляемые поля.
Квадратные скобки в имени папки работают у обработчиков так же, как у страниц: app/api/tasks/[id]/route.ts отвечает на /api/tasks/7. Сегмент приезжает вторым аргументом — в объекте контекста. И там его ждёт ровно та же ловушка, что в статье про динамические сегменты.
Диагностика показывает главное: params — промис, а не объект. Но посмотрите на вывод: код всё равно выполнился и ответил 200. id оказался undefined, JSON.stringify молча выбросил такое поле — и клиент получил бодрое «всё хорошо» с пустым телом. От такого ответа вас отделяла ровно одна диагностика компилятора.
Тип контекста можно не писать руками: 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 |
| 2 | proxy.ts — если адрес попал под matcher |
| 3 | переписывания адресов из beforeFiles |
| 4 | поиск по файловой системе: статика, page.tsx, route.ts |
| 5 | остальные переписывания и, если ничего не нашлось, 404 |
Четвёртый шаг — это и есть «выбор маршрута». Всё, что делает proxy.ts, происходит до него: обработчик из route.ts в этот момент ещё даже не выбран.
Вернули 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 и объяснением в теле.
Значение достаём из searchParams. Отсутствие параметра (null) и неизвестное значение — разные случаи, и путать их нельзя: первое означает «фильтра нет», второе — ошибку клиента.
Обратите внимание на export — в настоящем файле он обязателен, иначе Next решит, что методов в файле нет вовсе.
Лог, который ломает разбор
Обработчик писали второпях: сначала печатаем тело в лог, потом разбираем. Ни один запрос не проходит. Почините так, чтобы в выводе оказалось и содержимое тела, и созданная задача, а на неразбираемом теле ответ был 400.
Тело читается один раз. Читаем его в строку — и лог, и разбор работают с ней, а не с потоком. Разбор заворачиваем в try: битое тело — ошибка клиента, то есть 400, а не падение сервера.
Вывод станет таким: первое тело напечатается и вернётся с кодом 201, второе — напечатается и получит 400. Второй вариант — request.clone() перед первым чтением, но он честно копирует поток и нужен редко.
Привратник у /admin
Напишите proxy: адреса, начинающиеся с /admin, без заголовка authorization уводите на /login, добавив в строку запроса from с исходным путём. Всё остальное пропускайте дальше.
Проверки идут от дешёвой к дорогой: сначала путь, потом заголовок. «Пропустить» — это вернуть undefined.
С matcher первая проверка становится лишней: Next вызовет функцию только на подходящих адресах. Оставлять её всё же полезно — matcher легко расширить и забыть про код внутри. И помните: это удобство для пользователя, а не защита — данные обязан проверять тот, кто их отдаёт.