Динамічні маршрути та динамічні сегменти в 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, щоб зібрати відомі шляхи під час білду
Базовий приклад
// 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
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, щоб зібрати ці сторінки при білді. Невідомі шляхи все одно потраплять на сервер.
// 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 для кожного поля.
// Неправильно
const { slug } = params; // undefined
// Правильно
const { slug } = await params;Найпоширеніша проблема після міграції з Next.js 12 на 13+.
2. useRouter() з next/router в App Router
// Неправильно - патерн Pages Router
import { useRouter } from 'next/router';
const { slug } = useRouter().query;
// Правильно - App Router
const { slug } = await params; // Server Component3. Статичний експорт з динамічними маршрутами
Якщо в next.config.js встановлено output: 'export', всі динамічні маршрути без generateStaticParams повертають 404. Next.js не може згенерувати HTML-файли для шляхів, яких не знає при білді.
4. Припущення, що params - це числа
Всі значення з params приходять як рядки. Навіть /users/42 дає id = "42", а не 42.
// Неправильно
const id = params.id + 1; // "421" (конкатенація рядків)
// Правильно
const id = Number(await params.id);5. Catch-all без обробки порожнього випадку
[[...slug]] дає categories = undefined на кореневому шляху. Якщо компонент викликає categories.length, він впаде з помилкою.
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. Так отримуєш свіжі дані за секунди, не чекаючи наступної запланованої ревалідації.
Приклади
Базовий: сторінка поста блогу
// 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 для сайту документації
// 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 одразу впаде з помилкою.
Розширений: статична генерація з динамічним фолбеком
// 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 несподівано.
Коротка відповідь
Для співбесідиКоротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.