Запропонувати правкуПокращити цю статтюДопрацюйте відповідь до «Динамічні маршрути та динамічні сегменти в Next.js». Ваші зміни проходять модерацію перед публікацією.Потрібне підтвердженняКонтентЩо ви змінюєте🇺🇸EN🇺🇦UAПереглядЗаголовок (UA)Коротка відповідь (UA)**Динамічні маршрути (dynamic routes) в Next.js** використовують нотацію в дужках у назвах файлів, щоб відповідати змінним сегментам URL. Один файл обробляє будь-яку кількість URL через `params`. ```tsx // app/blog/[slug]/page.tsx export default async function BlogPost({ params }) { const { slug } = await params; // /blog/hello → slug = "hello" return <h1>{slug}</h1>; } ``` **Ключове:** `params` - це Promise в App Router. Завжди `await` його.Показується над повною відповіддю для швидкого нагадування.Відповідь (UA)Зображення**Динамічні маршрути (dynamic routes) в Next.js** використовують нотацію в дужках у назвах файлів, щоб відповідати змінним сегментам URL, збираючи їх у `params` для рендерингу сторінок без окремого файлу на кожен шлях. ## Теорія ### TL;DR - Файл `app/blog/[slug]/page.tsx` обробляє `/blog/будь-що` з одного місця - Сегмент URL потрапляє в `params` як рядок: `/blog/hello` дає `slug = "hello"` - `[...slug]` захоплює один або більше сегментів масивом; `[[...slug]]` - нуль або більше - Завжди `await params` в App Router (Next.js 13+) - це Promise, а не звичайний об'єкт - Поєднуй з `generateStaticParams`, щоб зібрати відомі шляхи під час білду ### Базовий приклад ```tsx // app/blog/[slug]/page.tsx export default async function BlogPost({ params, }: { params: Promise<{ slug: string }>; }) { const { slug } = await params; // /blog/hello-world → slug = "hello-world" // /blog/my-post → slug = "my-post" return <h1>Пост: {slug}</h1>; } ``` Один файл. Необмежена кількість URL. У цьому вся суть. ### Коли використовувати - **Фіксовані відомі шляхи** (`/about`, `/contact`) - статичні файли. Швидше збираються, краще кешуються. - **Контент від користувачів** (slug посту, хендл продукту) - використовуй `[slug]`. Масштабується на будь-яку кількість елементів. - **Вкладені невідомі шляхи** (дерево документації, ієрархія категорій) - використовуй `[...slug]`, щоб захопити все масивом. - **Опціональні сегменти**, де `/shop` і `/shop/clothes/men` обидва мають працювати - використовуй `[[...slug]]`. - **Пагінація** - `[page]/page.tsx` дає `/posts/1`, `/posts/2` з одного файлу. ### Шаблони сегментів Три шаблони покривають всі випадки, з якими зіткнешся. **`[slug]` - один сегмент** Відповідає рівно одній частині URL. `/blog/hello` працює. `/blog/hello/world` дає 404. ``` app/blog/[slug]/page.tsx → /blog/hello-world ✓ → /blog/hello/world ✗ (404) ``` **`[...slug]` - catch-all** Відповідає одному або більше сегментам, повертає масив. Нуль сегментів (просто `/docs`) дає 404. ``` app/docs/[...slug]/page.tsx → /docs/react ✓ slug = ["react"] → /docs/react/hooks ✓ slug = ["react", "hooks"] → /docs ✗ (404) ``` **`[[...slug]]` - опціональний catch-all** Те саме, що catch-all, але також відповідає нулю сегментів. Кореневий шлях теж працює. ``` app/shop/[[...categories]]/page.tsx → /shop ✓ categories = undefined → /shop/clothes ✓ categories = ["clothes"] → /shop/clothes/men ✓ categories = ["clothes", "men"] ``` ### Кілька динамічних сегментів Папки в дужках можна вкладати. Кожна додає ключ до `params`. ``` app/[locale]/blog/[slug]/page.tsx → /en/blog/hello, /ua/blog/hello ``` ```tsx export default async function BlogPost({ params, }: { params: Promise<{ locale: string; slug: string }>; }) { const { locale, slug } = await params; // /en/blog/hello → { locale: "en", slug: "hello" } const post = await getPost(slug, locale); return <article>{post.title}</article>; } ``` ### generateStaticParams За замовчуванням динамічні маршрути рендеряться на вимогу під час кожного запиту. Якщо slug-и відомі заздалегідь, експортуй `generateStaticParams`, щоб зібрати ці сторінки при білді. Невідомі шляхи все одно потраплять на сервер. ```tsx // app/blog/[slug]/page.tsx export async function generateStaticParams() { const posts = await getAllPosts(); return posts.map((post) => ({ slug: post.slug, })); } // Будує /blog/post-1, /blog/post-2 під час білду // Нові slug-и все одно працюють через сервер ``` Це аналог `getStaticPaths` з Pages Router, тільки з чистішим API. ### Як Next.js розв'язує динамічні маршрути Файловий роутер сканує `app/` при старті і перетворює папки в дужках на regex-шаблони всередині. `[slug]` стає `/([^/]+)`, `[...slug]` - `/(.*+)`. На кожен запит роутер знаходить збіг, витягує захоплені частини в Promise `params` і передає їх у `page.tsx` як пропс Server Component. Важливий момент: якщо є і `app/blog/featured/page.tsx`, і `app/blog/[slug]/page.tsx`, статичний маршрут завжди перемагає. Next.js надає перевагу точним збігам над динамічними. ### Типові помилки **1. Забули `await params`** `params` - це Promise в App Router. Деструктуризація без `await` дає `undefined` для кожного поля. ```tsx // Неправильно const { slug } = params; // undefined // Правильно const { slug } = await params; ``` Найпоширеніша проблема після міграції з Next.js 12 на 13+. **2. `useRouter()` з `next/router` в App Router** ```tsx // Неправильно - патерн Pages Router import { useRouter } from 'next/router'; const { slug } = useRouter().query; // Правильно - App Router const { slug } = await params; // Server Component ``` **3. Статичний експорт з динамічними маршрутами** Якщо в `next.config.js` встановлено `output: 'export'`, всі динамічні маршрути без `generateStaticParams` повертають 404. Next.js не може згенерувати HTML-файли для шляхів, яких не знає при білді. **4. Припущення, що params - це числа** Всі значення з `params` приходять як рядки. Навіть `/users/42` дає `id = "42"`, а не `42`. ```tsx // Неправильно const id = params.id + 1; // "421" (конкатенація рядків) // Правильно const id = Number(await params.id); ``` **5. Catch-all без обробки порожнього випадку** `[[...slug]]` дає `categories = undefined` на кореневому шляху. Якщо компонент викликає `categories.length`, він впаде з помилкою. ```tsx const { categories = [] } = await params; // за замовчуванням порожній масив ``` ### Де використовується - Vercel Commerce: `app/products/[handle]/page.tsx` для slug-ів продуктів Shopify - Nextra: `[[...slug]]/page.tsx` для дерева MDX документації - Supabase Starter: `app/users/[id]/page.tsx` для сторінок профілів користувачів - T3 Stack: `app/posts/[id]/page.tsx` із запитами через tRPC ### Питання на співбесіді **Q:** Яка файлова структура обробляє `/blog/2024/01/post-1`? **A:** `app/blog/[year]/[month]/[slug]/page.tsx`. Об'єкт `params` дасть `{ year: "2024", month: "01", slug: "post-1" }`. Всі значення - рядки, навіть числові на вигляд. **Q:** У чому різниця між `[...slug]` і `[[...slug]]`? **A:** `[...slug]` вимагає мінімум один сегмент, тому `/docs` із тільки catch-all файлом дає 404. `[[...slug]]` відповідає і нулю сегментів, тому `/docs` працює і дає `slug = undefined`. **Q:** Як повернути 404, якщо slug відсутній у базі даних? **A:** Додай `not-found.tsx` поряд із `page.tsx`, а в самій сторінці виклич `notFound()` з `next/navigation`, коли запит до даних повертає нічого. Next.js автоматично відрендерить UI з not-found. **Q:** Чи можуть `generateStaticParams` і серверний рендеринг працювати разом для одного маршруту? **A:** Так. Шляхи з `generateStaticParams` збираються при білді. Будь-який slug поза цим списком рендериться на сервері при першому запиті. Встанови `export const dynamicParams = false`, щоб явно повертати 404 для невідомих slug-ів. **Q:** (Senior) Як налаштувати кешування для динамічного маршруту, що отримує дані з CMS? **A:** Використовуй `fetch` із `next: { revalidate: 3600 }` для ISR - кеш оновлюється кожну годину. Для точного контролю тегуй запити через `next: { tags: ['posts'] }` і виклич `revalidateTag('posts')` із Server Action при оновленні контенту в CMS. Так отримуєш свіжі дані за секунди, не чекаючи наступної запланованої ревалідації. ## Приклади ### Базовий: сторінка поста блогу ```tsx // app/blog/[slug]/page.tsx import { notFound } from 'next/navigation'; async function getPost(slug: string) { const res = await fetch(`https://api.example.com/posts/${slug}`, { next: { revalidate: 3600 }, }); if (!res.ok) return null; return res.json(); } export default async function BlogPost({ params, }: { params: Promise<{ slug: string }>; }) { const { slug } = await params; const post = await getPost(slug); if (!post) notFound(); return ( <article> <h1>{post.title}</h1> <div dangerouslySetInnerHTML={{ __html: post.content }} /> </article> ); } // /blog/nextjs-dynamic-routes → завантажує і рендерить конкретний пост ``` Виклик `notFound()` перетворює відсутній slug на справжню 404-сторінку, а не на порожній рендер. ### Середній: опціональний catch-all для сайту документації ```tsx // app/docs/[[...slug]]/page.tsx export default async function Docs({ params, }: { params: Promise<{ slug?: string[] }>; }) { const { slug = [] } = await params; // /docs → slug = [] (головна документації) // /docs/nextjs → slug = ["nextjs"] // /docs/nextjs/app-router → slug = ["nextjs", "app-router"] const content = await getDocContent(slug); return ( <div> <h1>{slug.length ? slug.join(' / ') : 'Документація'}</h1> <div>{content}</div> </div> ); } ``` Дефолт `= []` при деструктуризації важливий. Без нього `slug` буде `undefined` на `/docs` і `slug.length` одразу впаде з помилкою. ### Розширений: статична генерація з динамічним фолбеком ```tsx // app/blog/[slug]/page.tsx export async function generateStaticParams() { const posts = await getAllPosts(); // Збираємо 20 найновіших постів при білді return posts.slice(0, 20).map((post) => ({ slug: post.slug })); } // Старіші пости все одно доступні через сервер export const dynamicParams = true; // поведінка за замовчуванням export default async function BlogPost({ params, }: { params: Promise<{ slug: string }>; }) { const { slug } = await params; const post = await getPost(slug); if (!post) notFound(); return <article><h1>{post.title}</h1></article>; } ``` Збирати найпопулярніші сторінки при білді, а решту рендерити динамічно - це підхід, який я використовував на кількох контентних проєктах на Next.js. Час білду залишається коротким і жоден маршрут не повертає 404 несподівано.Для рев’юераПримітка для модератора (необов’язково)Бачить лише модератор. Прискорює рев’ю.