![Как настроить динамические маршруты в Next.js. Полный разбор [[...slug]]](/images/uploads/next-routing-slug.webp)
Как настроить динамические маршруты в Next.js. Полный разбор [[...slug]]
Динамические маршруты в App Router дают гибкость для блога, каталога, магазина или документации. Базовые кейсы закрываются [id]
и [...slug]
, но по-настоящему удобную архитектуру для разделов с необязательными вложениями дает именно [[...slug]]
. В этой статье разберем все три варианта, а глубже всего пройдемся по [[...slug]]
: как он матчит URL, что приходит в params
, как типизировать, как генерировать статические пути, как строить хлебные крошки и SEO.
Короткая карта маршрутов
[slug]
- один сегмент. Пример:/blog/my-post
params.slug: string
[...slug]
- обязательный catch-all. Нужен хотя бы один сегмент. Пример:/docs/getting-started/install
params.slug: string[]
[[...slug]]
- опциональный catch-all. Совпадает и с корнем, и с любым числом сегментов. Примеры:/shop
,/shop/men
,/shop/men/t-shirts
params.slug: string[] | undefined
Когда выбирать [[...slug]]
- Один компонент и макет для корня и всех подуровней раздела. Пример: раздел документации или каталога, где главная страница раздела и вложенные страницы визуально и структурно схожи.
- Нужна страница раздела по адресу без сегментов, а ниже бесконечная вложенность. Пример:
/docs
как оглавление, далее любые глубины вроде/docs/guides/setup/cloud
. - Хлебные крошки и навигация строятся из массива сегментов, но корень тоже должен работать.
Базовая реализация [[...slug]]
Структура:
app/
docs/
[[...slug]]/
page.tsx
layout.tsx
page.tsx
:
import { notFound } from 'next/navigation';
type PageProps = {
params: { slug?: string[] };
};
async function getNodeByPath(slug: string[]) {
// Пример: дергаем API или читаем оглавление
const path = '/' + slug.join('/');
const res = await fetch(`${process.env.API_URL}/docs?path=${encodeURIComponent(path)}`, {
cache: 'force-cache'
});
if (!res.ok) return null;
return res.json() as Promise<{ title: string; html: string } | null>;
}
export default async function DocsPage({ params }: PageProps) {
const segments = params.slug ?? []; // В корне undefined, превращаем в []
const node = await getNodeByPath(segments);
if (!node) notFound();
return (
<article>
<h1>{node.title}</h1>
<div dangerouslySetInnerHTML={{ __html: node.html }} />
</article>
);
}
Ключевые моменты:
- В корне
params.slug
будетundefined
. Превратите в[]
, чтобы не ветвить код. - Маршрут совпадает как с
/docs
, так и с любыми вложенными путями.
Хлебные крошки из [[...slug]]
import Link from 'next/link';
type CrumbsProps = { segments: string[] };
export function Breadcrumbs({ segments }: CrumbsProps) {
const items = [{ label: 'Docs', href: '/docs' }, ...segments.map((s, i) => ({
label: decodeURIComponent(s),
href: '/docs/' + segments.slice(0, i + 1).map(encodeURIComponent).join('/')
}))];
return (
<nav aria-label="breadcrumb">
<ol>
{items.map(item => (
<li key={item.href}>
<Link href={item.href}>{item.label}</Link>
</li>
))}
</ol>
</nav>
);
}
Использование:
export default async function DocsPage({ params }: { params: { slug?: string[] } }) {
const segments = params.slug ?? [];
// ...
return (
<>
<Breadcrumbs segments={segments} />
{/* контент */}
</>
);
}
Правила безопасности:
- При генерации ссылок всегда используйте
encodeURIComponent
. - При отображении можно декодировать, если хотите показывать читабельные названия.
Генерация статических путей для [[...slug]]
Если вы заранее знаете часть путей и хотите их предсобрать, используйте generateStaticParams
. Для опционального catch-all можно сгенерировать как вложенные пути, так и корень.
export const revalidate = 300;
export async function generateStaticParams() {
// Пример: возвращаем набор известных путей
// Варианты для [[...slug]]:
// - корень раздела можно представить как пустой массив сегментов
// - вложенные пути как массивы строк
return [
{ slug: [] }, // соответствует /docs
{ slug: ['getting-started'] }, // соответствует /docs/getting-started
{ slug: ['guides', 'install'] } // соответствует /docs/guides/install
];
}
Замечания:
- Для
[[...slug]]
корневой путь удобно описывать как{ slug: [] }
. Это явно указывает генератору, что нужен маршрут без сегментов. - Если хотите строго ограничить набор допустимых статических путей, используйте
export const dynamicParams = false
. Тогда неизвестные пути будут отдавать 404.
export const dynamicParams = false; // все не перечисленное в generateStaticParams вернет 404
SEO и канонические ссылки для [[...slug]]
generateMetadata
получает params
и позволяет описывать мета на основе сегментов.
import type { Metadata } from 'next';
type Props = { params: { slug?: string[] } };
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const segments = params.slug ?? [];
const path = '/docs' + (segments.length ? '/' + segments.join('/') : '');
// Пример получения заголовка и описания
const res = await fetch(`${process.env.API_URL}/docs/meta?path=${encodeURIComponent(path)}`, {
cache: 'force-cache'
});
if (!res.ok) return { title: 'Документация' };
const meta = await res.json() as { title: string; description?: string; canonical?: string };
return {
title: meta.title,
description: meta.description,
alternates: {
canonical: meta.canonical ?? path
},
openGraph: {
title: meta.title,
description: meta.description
}
};
}
Рекомендации:
- Следите за дублями URL. Если у вас включен trailingSlash, убедитесь, что каноникал совпадает с реальной конфигурацией, чтобы избежать дублирования.
- Для страниц, зависящих от данных, задайте разумный
revalidate
, чтобы мета не устаревала.
Управление кешем и рендерингом
- Статическая отдача по умолчанию плюс ISR:export const revalidate = 300;
- Полностью динамический ответ:
await fetch(url, { cache: 'no-store' });
// или на уровне сегмента export const dynamic = 'force-dynamic';
- Смешанный подход: часть данных кешируете, часть берете свежей. Для часто меняющихся блоков рассматривайте Route Handlers с собственным кешем.
Поведение [[...slug]]
по сравнению с [...slug]
и частые подводные камни
- Корень раздела
[[...slug]]
матчит и корень, и вложенные пути. В корнеparams.slug
будетundefined
. Преобразуйте в[]
сразу.[...slug]
не матчит корень никогда. Минимум один сегмент.
- Типизация
- Определяйте тип явно:
params: { slug?: string[] }
. Это убережет от ошибок при доступе кparams.slug.length
.
- Генерация статических путей
- Для
[[...slug]]
добавляйте{ slug: [] }
, если хотите предсобрать корень. - Если используете
dynamicParams = false
, любой путь не изgenerateStaticParams
вернет 404. Это удобно для закрытых оглавлений, но ломает свободную навигацию, если забыли путь.
- Построение ссылок
- Никогда не конкатенируйте сегменты без кодирования. Используйте
encodeURIComponent
при формировании href. - При чтении из
params
помните, что элементы массива еще закодированы.
- Конфликты маршрутов
- Группы маршрутов
(marketing)
не участвуют в URL, но влияют на иерархию макетов. Держите[[...slug]]
изолированным внутри своей группы, если у вас несколько разделов с похожими паттернами.
- 404 и notFound
- Для любых несуществующих комбинаций сегментов вызывайте
notFound()
. Так вы не получите пустых страниц. - Кастомизируйте
app/not-found.tsx
под свой UX.
Полный пример раздела документации на [[...slug]]
app/
docs/
layout.tsx
[[...slug]]/
page.tsx
loading.tsx
not-found.tsx
layout.tsx
:
export default function DocsLayout({ children }: { children: React.ReactNode }) {
return (
<div className="docs">
<aside>{/* меню раздела */}</aside>
<main>{children}</main>
</div>
);
}
page.tsx
:
import { notFound } from 'next/navigation';
import { Breadcrumbs } from './_components/Breadcrumbs';
type PageProps = { params: { slug?: string[] } };
async function getDoc(path: string) {
const res = await fetch(`${process.env.API_URL}/docs?path=${encodeURIComponent(path)}`, {
next: { revalidate: 300 }
});
if (!res.ok) return null;
return res.json() as Promise<{ title: string; html: string } | null>;
}
export default async function DocsPage({ params }: PageProps) {
const segments = params.slug ?? [];
const path = '/docs' + (segments.length ? '/' + segments.join('/') : '');
const doc = await getDoc(path);
if (!doc) notFound();
return (
<article>
<Breadcrumbs segments={segments} />
<h1>{doc.title}</h1>
<div dangerouslySetInnerHTML={{ __html: doc.html }} />
</article>
);
}
generateStaticParams.ts
в той же папке:
export const revalidate = 300;
export const dynamicParams = true; // разрешаем и не перечисленные пути
export async function generateStaticParams() {
// Если хотите предсобрать оглавление и несколько популярных страниц
return [
{ slug: [] }, // /docs
{ slug: ['getting-started'] }, // /docs/getting-started
{ slug: ['guides', 'install'] } // /docs/guides/install
];
}
not-found.tsx
:
export default function NotFound() {
return <div>Страница не найдена</div>;
}
Тестовый чеклист перед релизом
- Корень раздела открывается по
/docs
. - Глубокий путь вроде
/docs/a/b/c
не падает. params.slug
корректно обрабатывается как[]
в корне.- Хлебные крошки строятся из массива сегментов и корректно кодируют ссылки.
generateMetadata
отдает корректный заголовок и canonical для корня и вложенных страниц.- 404 срабатывает на несуществующие узлы.
- Конфиги
revalidate
иdynamic
соответствуют требованиям к свежести данных.
Что важно помнить
[[...slug]]
дает чистую архитектуру разделов, где корень и вложенные уровни обслуживаются одной страницей и одним макетом. Это упрощает навигацию, хлебные крошки, SEO и поддержку. Критично правильно типизировать params.slug
, нормализовать его к []
в корне, аккуратно строить ссылки и по необходимости перечислять пути в generateStaticParams
с { slug: [] }
для корня.
Если нужно, упакую это в готовый Markdown файл или минимальный репозиторий с рабочим примером app/docs/[[...slug]]
, хлебными крошками и generateMetadata
.