Динамические сегменты и асинхронные params
Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.
Товаров тысяча, а файл нужен один. Квадратные скобки в имени папки превращают сегмент адреса в переменную: app/products/[id]/page.tsx отвечает и на /products/42, и на /products/kettle, а само значение приезжает в проп params. С Next.js 15 этот проп стал промисом, и просто прочитать params.id больше нельзя — компилятор сообщит об этом первым.
Тысячу папок под тысячу товаров никто не заводит. Вместо этого папку называют [id]: квадратные скобки говорят маршрутизатору, что сюда подойдёт любое значение сегмента, а какое именно стояло в адресе — узнает страница из пропа params.
Песочница учебника Next не запускает: там нет ни сервера, ни файловой системы. Сопоставление адреса с шаблоном воспроизводим руками — правило короткое, и в коде оно видно лучше, чем в пересказе.
Ключ в 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] | /products | 404: сегменту нечем заполниться |
app/products/[id] | /products/42/reviews | 404: лишний сегмент |
app/shop/[category]/[id] | /shop/tea/42 | { category: "tea", id: "42" } |
А если рядом с [id] завести обычную папку new? Под адрес /products/new подойдут оба шаблона, и спор надо как-то решать. Next решает его в пользу буквального сегмента — попробуйте предсказать вывод.
Правило запоминается легко: чем меньше в шаблоне скобок, тем он точнее. Поэтому форма создания живёт в app/products/new/page.tsx и спокойно соседствует с [id] — до динамической папки очередь дойдёт только тогда, когда буквального совпадения не нашлось.
Динамических сегментов на пути может быть сколько угодно: каждый добавляет свой ключ. Одинаковые имена в одной ветке дерева Next запрещает — второй ключ затёр бы первый, и сборка останавливается с ошибкой.
Скобки — часть имени папки, а не какая-то особая запись в конфиге. В терминале такое имя иногда приходится брать в кавычки: mkdir "[id]" — иначе оболочка попробует прочитать скобки по-своему.
До Next.js 15 params был обычным объектом: params.id — и всё. В 15-й версии этот проп (вместе с searchParams) стал асинхронным, и старая привычка ломается сразу. Хорошая новость: ломается она громко.
Диагностика описывает случившееся дословно: Свойство "id" не существует в типе "Promise<{ id: string; }>". И всё же код выполнился, напечатав undefined: ошибки типов запуск не отменяют — типы живут до сборки, а в браузер уезжает обычный JavaScript. Об этом подробно говорилось в части III.
Лечение видно из самой ошибки: промис надо дождаться. Компонент страницы для этого объявляют асинхронной функцией — серверные компоненты так умеют. Почему асинхронным бывает только серверный компонент, разбирает статья «Данные прямо в компоненте: await вместо useEffect» в следующей главе.
Вторая строка вывода — 421, а не 43. Адрес состоит из строк, и [id] не превращает 42 в число: это по-прежнему текст, который вы вытащили из URL. Проверка и разбор — ваша работа, к ней вернёмся в последнем разделе.
export default перед функцией страницы здесь снова опущен — почему, разбиралось в статье про App Router.
Зачем вообще было делать проп промисом? Чтобы отрисовка могла начаться раньше, чем известны детали запроса: общий каркас страницы уходит в браузер сразу, а место, которому нужен конкретный адрес, дожидается своего значения отдельно. Обещание вместо готового объекта — способ отодвинуть этот момент, и чем позже странице действительно понадобится адрес, тем больше успевает уехать до него.
В клиентском компоненте await не поставишь: функция с "use client" асинхронной быть не может. Зато есть use() из статьи про use() и Suspense — он разворачивает промис прямо в теле компонента.
Строк «вызов компонента» две, а «параметр прочитан» — одна. Первый раз функция дошла до use(), промис ещё не был выполнен, и React приостановил отрисовку, показав запасную разметку из Suspense. Когда промис выполнился, React вызвал компонент заново — и на этот раз use() вернул значение. Это ровно то поведение, которое разбиралось в статье про use() и Suspense: никакого особого «получения параметров» в React не завелось.
Документация, файловое хранилище, старые адреса из чужой CMS — там, где глубина пути заранее неизвестна, одного [id] мало. Многоточие перед именем делает сегмент «съедающим»: [...slug] забирает всё, что осталось от адреса, и отдаёт массивом.
Последняя строка — то самое место, где спотыкаются: [...slug] требует хотя бы один сегмент, поэтому сам /docs под него не подходит и отвечает 404. Чтобы папка ловила и родительский адрес, скобки удваивают.
Разница не только в адресах, но и в типе: у [...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 }.
Три варианта — потому что ключ в адресе может встретиться ноль раз, один раз или несколько. Соберём такой объект сами, и станет видно, откуда берётся массив.
Именно поэтому нельзя просто передать значение туда, где ждут строку: компилятор помнит про все три случая, даже если вы про них забыли.
Выполнение при этом прошло и напечатало «Метка: react,next» — массив тихо склеился в строку. Такие места и есть причина, по которой в части III столько внимания уделено сужению: разбирать варианты приходится руками.
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 честно объявлен строкой, потому что строкой и приходит.
Дальше на выбор: показать сообщение, отдать 404 или увести на другой адрес. Настоящая страница целиком выглядит так — этот пример компилируется, но не запускается: notFound() живёт в пакете next/navigation, а импортов песочница не знает.
Файлы not-found.tsx и error.tsx, которые подхватывают такой обрыв, разбирает последняя статья главы. Здесь важнее другое: решение о том, что считать несуществующим товаром, принимает страница, а не маршрутизатор.
Есть и обратный ход — заранее перечислить допустимые значения. Функция generateStaticParams рядом со страницей отдаёт список сегментов, для которых Next соберёт готовые страницы на сборке.
Форма ответа та же, что у 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 собирает путь из сегментов, но на корне ломается — и компилятор об этом честно предупреждает. Почините её так, чтобы для корня возвращалось слово «Документация».
Тип string[] | undefined надо разобрать до обращения к массиву. Пустой список — это и есть корень раздела.
После правки диагностика исчезает и падения нет: ?? подставляет пустой массив, а проверка длины отделяет корень от вложенных страниц.
Прочитать оба пропа страницы
Компонент получает params и searchParams — оба промисами. Напишите тело describe: она должна вернуть строку «товар <id>, вкладка <tab>». Вкладка приходит из строки запроса, может оказаться массивом (тогда берём первое значение) или не прийти вовсе — тогда подставляем about.
Два независимых промиса ждут разом — Promise.all экономит один такт по сравнению с двумя await подряд. Дальше обычное сужение из части III.
Тернарный оператор здесь читается сносно, но при третьем варианте лучше вынести разбор в отдельную функцию — как firstValue из раздела про строку запроса.
Научить сопоставление съедающему сегменту
Функция fits знает про буквальные и обычные динамические сегменты, но не знает про [...rest]: она сравнивает количество кусков и потому отбрасывает длинные адреса. Доработайте её так, чтобы съедающий сегмент подходил под любой непустой остаток пути.
Меняется только проверка длины: у съедающего шаблона адрес может быть длиннее, но не короче — хотя бы один кусок ему нужен.
Порядок в массиве шаблонов при этом остаётся значимым: /blog/new стоит первым и выигрывает у [slug], а [...rest] — последним, как и в самом Next.