Динамические сегменты и асинхронные params

Next.js≈ 11 минут

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

Товаров тысяча, а файл нужен один. Квадратные скобки в имени папки превращают сегмент адреса в переменную: app/products/[id]/page.tsx отвечает и на /products/42, и на /products/kettle, а само значение приезжает в проп params. С Next.js 15 этот проп стал промисом, и просто прочитать params.id больше нельзя — компилятор сообщит об этом первым.

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

Тысячу папок под тысячу товаров никто не заводит. Вместо этого папку называют [id]: квадратные скобки говорят маршрутизатору, что сюда подойдёт любое значение сегмента, а какое именно стояло в адресе — узнает страница из пропа params.

Песочница учебника Next не запускает: там нет ни сервера, ни файловой системы. Сопоставление адреса с шаблоном воспроизводим руками — правило короткое, и в коде оно видно лучше, чем в пересказе.

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

Ключ в params — это имя внутри скобок, и никакой связи с содержимым адреса у него нет: назовёте папку [productId] — получите params.productId. Значение же всегда строка, даже если в адресе одни цифры.

Обратите внимание на последнюю строку: /products/42/reviews не подошёл. Динамический сегмент жаден к значениям, но занимает ровно один кусок пути — как одна папка в дереве, которой он и является.

ПапкаАдресparams
app/products/[id]/products/42{ id: "42" }
app/products/[id]/products/kettle{ id: "kettle" }
app/products/[id]/products404: сегменту нечем заполниться
app/products/[id]/products/42/reviews404: лишний сегмент
app/shop/[category]/[id]/shop/tea/42{ category: "tea", id: "42" }

А если рядом с [id] завести обычную папку new? Под адрес /products/new подойдут оба шаблона, и спор надо как-то решать. Next решает его в пользу буквального сегмента — попробуйте предсказать вывод.

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

Правило запоминается легко: чем меньше в шаблоне скобок, тем он точнее. Поэтому форма создания живёт в app/products/new/page.tsx и спокойно соседствует с [id] — до динамической папки очередь дойдёт только тогда, когда буквального совпадения не нашлось.

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

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

Скобки — часть имени папки, а не какая-то особая запись в конфиге. В терминале такое имя иногда приходится брать в кавычки: mkdir "[id]" — иначе оболочка попробует прочитать скобки по-своему.

До Next.js 15 params был обычным объектом: params.id — и всё. В 15-й версии этот проп (вместе с searchParams) стал асинхронным, и старая привычка ломается сразу. Хорошая новость: ломается она громко.

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

Диагностика описывает случившееся дословно: Свойство "id" не существует в типе "Promise<{ id: string; }>". И всё же код выполнился, напечатав undefined: ошибки типов запуск не отменяют — типы живут до сборки, а в браузер уезжает обычный JavaScript. Об этом подробно говорилось в части III.

Лечение видно из самой ошибки: промис надо дождаться. Компонент страницы для этого объявляют асинхронной функцией — серверные компоненты так умеют. Почему асинхронным бывает только серверный компонент, разбирает статья «Данные прямо в компоненте: await вместо useEffect» в следующей главе.

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

Вторая строка вывода — 421, а не 43. Адрес состоит из строк, и [id] не превращает 42 в число: это по-прежнему текст, который вы вытащили из URL. Проверка и разбор — ваша работа, к ней вернёмся в последнем разделе.

export default перед функцией страницы здесь снова опущен — почему, разбиралось в статье про App Router.

Зачем вообще было делать проп промисом? Чтобы отрисовка могла начаться раньше, чем известны детали запроса: общий каркас страницы уходит в браузер сразу, а место, которому нужен конкретный адрес, дожидается своего значения отдельно. Обещание вместо готового объекта — способ отодвинуть этот момент, и чем позже странице действительно понадобится адрес, тем больше успевает уехать до него.

В клиентском компоненте await не поставишь: функция с "use client" асинхронной быть не может. Зато есть use() из статьи про use() и Suspense — он разворачивает промис прямо в теле компонента.

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

Строк «вызов компонента» две, а «параметр прочитан» — одна. Первый раз функция дошла до use(), промис ещё не был выполнен, и React приостановил отрисовку, показав запасную разметку из Suspense. Когда промис выполнился, React вызвал компонент заново — и на этот раз use() вернул значение. Это ровно то поведение, которое разбиралось в статье про use() и Suspense: никакого особого «получения параметров» в React не завелось.

Документация, файловое хранилище, старые адреса из чужой CMS — там, где глубина пути заранее неизвестна, одного [id] мало. Многоточие перед именем делает сегмент «съедающим»: [...slug] забирает всё, что осталось от адреса, и отдаёт массивом.

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

Последняя строка — то самое место, где спотыкаются: [...slug] требует хотя бы один сегмент, поэтому сам /docs под него не подходит и отвечает 404. Чтобы папка ловила и родительский адрес, скобки удваивают.

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

Разница не только в адресах, но и в типе: у [...slug] это string[], у [[...slug]] — string[] | undefined. Отсюда и главная ловушка: params.slug.length на корневом адресе падает. В примере поэтому и стоит ?., а что скажет компилятор без такой осторожности — видно в первой задаче в конце статьи.

ПапкаКакие адреса ловитparams
[id]/docs/react{ id: "react" }
[id]/docs и /docs/a/bне ловит — 404
[...slug]/docs/a, /docs/a/b{ slug: ["a"] }, { slug: ["a", "b"] }
[...slug]/docsне ловит — 404
[[...slug]]/docs{ slug: undefined }
[[...slug]]/docs/a/b{ slug: ["a", "b"] }

Съедающий сегмент всегда последний в своей ветке: класть что-то глубже бессмысленно — до этого места очередь не дойдёт, всё уже съедено. И в очерёдности он проигрывает всем: сначала буквальные сегменты, потом [id], и только потом [...slug].

Строку запроса ?tag=react&sort=new папками не опишешь — под неё и нет папок. Next разбирает её сам и отдаёт странице вторым пропом, searchParams, тоже промисом. Тип у него на первый взгляд странный: { [key: string]: string | string[] | undefined }.

Три варианта — потому что ключ в адресе может встретиться ноль раз, один раз или несколько. Соберём такой объект сами, и станет видно, откуда берётся массив.

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

Именно поэтому нельзя просто передать значение туда, где ждут строку: компилятор помнит про все три случая, даже если вы про них забыли.

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

Выполнение при этом прошло и напечатало «Метка: react,next» — массив тихо склеился в строку. Такие места и есть причина, по которой в части III столько внимания уделено сужению: разбирать варианты приходится руками.

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

searchParams получает только page.tsx. Раскладке этот проп не дают: она не перерисовывается, когда меняется строка запроса, и данные из неё вводили бы в заблуждение.

Ещё одно различие стоит держать в голове. Значения из params можно узнать на сборке — для этого их перечисляют сами, функцией generateStaticParams из последнего раздела, и тогда страницы отрисовываются заранее. Строка запроса такой возможности не даёт вовсе: она известна только в момент запроса, и обращение к searchParams делает страницу динамической.

Пропсы params и searchParams приходят только компоненту страницы. Пробрасывать их вниз через десять уровней не обязательно: у клиентских компонентов есть хуки useParams() и useSearchParams() — они из следующей статьи, про переходы и роутер. Для серверной части выбор проще: передать значение пропсом сверху.

Соблазн сделать из этого контекст обычно ошибочен. Адрес и так один на всё дерево, а хук читает его из роутера напрямую — своя копия в контексте только расходится с настоящим адресом.

Хлопот прибавляется, когда параметров в адресе больше пары и клиент их не только читает, но и меняет: фильтр, сортировка, номер страницы. Тогда на каждый параметр приходится своё сужение типа и умолчание, как в firstValue, и своя запись обратно в адрес. Чем заменяют эти копии одного и того же кода, разобрано на полке React и Next.

[id] не проверяет ничего. /products/42, /products/kettle, /products/%D1%87%D0%B0%D0%B9 — все они дойдут до вашей страницы, и различать их придётся самим. Типизация тут не помощник: params.id честно объявлен строкой, потому что строкой и приходит.

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

Дальше на выбор: показать сообщение, отдать 404 или увести на другой адрес. Настоящая страница целиком выглядит так — этот пример компилируется, но не запускается: notFound() живёт в пакете next/navigation, а импортов песочница не знает.

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

Файлы not-found.tsx и error.tsx, которые подхватывают такой обрыв, разбирает последняя статья главы. Здесь важнее другое: решение о том, что считать несуществующим товаром, принимает страница, а не маршрутизатор.

Есть и обратный ход — заранее перечислить допустимые значения. Функция generateStaticParams рядом со страницей отдаёт список сегментов, для которых Next соберёт готовые страницы на сборке.

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

Форма ответа та же, что у params: массив объектов, где ключ — имя сегмента, а значение — строка. Для каталога из трёх товаров это три собранных заранее страницы; всё остальное по-прежнему обрабатывается на лету — или отвечает 404, если вы это запретили. Подробности статической сборки и кеширования ждут в главе про кеш и отдачу.

  • Квадратные скобки в имени папки делают сегмент переменной: [id] подходит под любое значение, а имя внутри скобок становится ключом в params.
  • Обычный динамический сегмент занимает ровно один кусок пути; буквальная папка рядом с ним всегда точнее и выигрывает спор за адрес.
  • [...slug] забирает весь остаток адреса массивом, но требует хотя бы один сегмент; [[...slug]] ловит и родительский адрес, отдавая slug: undefined.
  • С Next.js 15 params и searchParams — промисы: в серверном компоненте их ждут через await, в клиентском разворачивают через use().
  • У searchParams тип string | string[] | undefined: ключ мог повториться или не прийти вовсе, поэтому перед использованием его сужают.
  • Маршрутизатор не проверяет значения — всё приезжает строками; разбор, отказ через notFound() и список заранее собираемых адресов в generateStaticParams пишет сама страница.

1/4В проекте есть app/products/[id]/page.tsx и больше ничего внутри products. По каким адресам откроется эта страница?

2/4В компоненте объявлено params: Promise<{ id: string }>, а внутри написано console.log(params.id). Что увидит читатель после запуска?

3/4Папка называется app/docs/[[...slug]]. Что придёт в params по адресу /docs?

4/4Открыт адрес /list?tag=react&tag=next. Что окажется в searchParams.tag?

Хлебные крошки падают на корне

Папка документации называется [[...slug]], поэтому адрес /docs тоже её. Функция breadcrumbs собирает путь из сегментов, но на корне ломается — и компилятор об этом честно предупреждает. Почините её так, чтобы для корня возвращалось слово «Документация».

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

Тип string[] | undefined надо разобрать до обращения к массиву. Пустой список — это и есть корень раздела.

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

После правки диагностика исчезает и падения нет: ?? подставляет пустой массив, а проверка длины отделяет корень от вложенных страниц.

Прочитать оба пропа страницы

Компонент получает params и searchParams — оба промисами. Напишите тело describe: она должна вернуть строку «товар <id>, вкладка <tab>». Вкладка приходит из строки запроса, может оказаться массивом (тогда берём первое значение) или не прийти вовсе — тогда подставляем about.

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

Два независимых промиса ждут разом — Promise.all экономит один такт по сравнению с двумя await подряд. Дальше обычное сужение из части III.

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

Тернарный оператор здесь читается сносно, но при третьем варианте лучше вынести разбор в отдельную функцию — как firstValue из раздела про строку запроса.

Научить сопоставление съедающему сегменту

Функция fits знает про буквальные и обычные динамические сегменты, но не знает про [...rest]: она сравнивает количество кусков и потому отбрасывает длинные адреса. Доработайте её так, чтобы съедающий сегмент подходил под любой непустой остаток пути.

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

Меняется только проверка длины: у съедающего шаблона адрес может быть длиннее, но не короче — хотя бы один кусок ему нужен.

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

Порядок в массиве шаблонов при этом остаётся значимым: /blog/new стоит первым и выигрывает у [slug], а [...rest] — последним, как и в самом Next.