Как настроить динамические маршруты в Next.js. Полный разбор [[...slug]]

Как настроить динамические маршруты в 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] и частые подводные камни

  1. Корень раздела
  • [[...slug]] матчит и корень, и вложенные пути. В корне params.slug будет undefined. Преобразуйте в [] сразу.
  • [...slug] не матчит корень никогда. Минимум один сегмент.
  1. Типизация
  • Определяйте тип явно: params: { slug?: string[] }. Это убережет от ошибок при доступе к params.slug.length.
  1. Генерация статических путей
  • Для [[...slug]] добавляйте { slug: [] }, если хотите предсобрать корень.
  • Если используете dynamicParams = false, любой путь не из generateStaticParams вернет 404. Это удобно для закрытых оглавлений, но ломает свободную навигацию, если забыли путь.
  1. Построение ссылок
  • Никогда не конкатенируйте сегменты без кодирования. Используйте encodeURIComponent при формировании href.
  • При чтении из params помните, что элементы массива еще закодированы.
  1. Конфликты маршрутов
  • Группы маршрутов (marketing) не участвуют в URL, но влияют на иерархию макетов. Держите [[...slug]] изолированным внутри своей группы, если у вас несколько разделов с похожими паттернами.
  1. 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.