Skip to main content

Динамічні маршрути та динамічні сегменти в Next.js

Динамічні маршрути (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 несподівано.

Коротка відповідь

Для співбесіди
Premium

Коротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.

Дочитали статтю?