Журнал / Разработка веб-продуктов

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

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

Динамические маршруты в App Router обеспечивают гибкость для создания блога, каталога, магазина или сайта документации. Основные случаи использования можно обработать с помощью [id] и [...slug], но когда речь идет о разделах с необязательным вложением, наиболее удобная архитектура обеспечивается [[...slug]].

В этой статье мы рассмотрим все три шаблона, но в основном сосредоточимся на [[...slug]]: как он соответствует URL-адресам, что приходит в params, как безопасно его типизировать, как генерировать статические пути, как строить хлебные крошки и как обрабатывать SEO.

Быстрая карта маршрутов

  • [slug] — один сегмент Пример: /blog/my-post params.slug: строка
  • [...slug] — обязательный catch-all (нужен хотя бы один сегмент) Пример: /docs/getting-started/install params.slug: массив строк
  • [[...slug]] — необязательный catch-all (соответствует как корню, так и любому количеству сегментов) Примеры: /shop, /shop/men, /shop/men/t-shirts params.slug: массив строк | неопределенный

Когда использовать [[...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[]) {
  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 ?? []; // At the root, it's undefined, so convert to []
  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 function DocsPage({ params }: { params: { slug?: string[] } }) {
  const segments = params.slug ?? [];
  return (
    <>
      <Breadcrumbs segments={segments} />
      {/* content */}
    </>
  );
}

Лучшие практики:

  • Всегда используйте encodeURIComponent при создании ссылок.
  • Для отображения вы можете декодировать, чтобы показать читаемые имена.

Генерация статических путей для [[...slug]]

Если вы знаете некоторые пути заранее и хотите предварительно построить их, используйте generateStaticParams. С необязательным catch-all вы можете генерировать как вложенные пути, так и корень.

export const revalidate = 300;

export async function generateStaticParams() {
  return [
    { slug: [] },                    // corresponds to /docs
    { slug: ['getting-started'] },   // corresponds to /docs/getting-started
    { slug: ['guides', 'install'] }  // corresponds to /docs/guides/install
  ];
}

Примечания:

  • Для [[...slug]], удобно описать корневой путь как { slug: [] }. Это позволяет четко указать, что должен быть сгенерирован маршрут без сегментов.
  • Чтобы строго ограничить допустимые статические пути, используйте:

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: 'Documentation' };

  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';

  • Смешанный подход: кэшируйте некоторые данные, получайте другие данные свежими. Для часто обновляемых блоков рассмотрите маршруты собственных обработчиков.

[[...slug]] против [...slug] и общие ошибки

  • Обработка корня [[...slug]] соответствует и корню, и вложенным путям. На корне params.slug равен undefined. Приведите его к виду [][...slug] никогда не совпадает с корнем. Требуется хотя бы один сегмент.
  • Набор типов Всегда явно указывайте тип: params: { slug?: string[] }. Это предотвращает ошибки при доступе к params.slug.length.
  • Статические пути Добавьте { slug: [] } если хотите предварительно построить корень. При dynamicParams = false, любой путь, не возвращенный generateStaticParams , вызовет ошибку 404.
  • Ссылки Никогда не объединяйте сегменты напрямую. Используйте encodeURIComponent при создании href.
  • Конфликты маршрутов Группы маршрутов (например, (маркетинг)) не отображаются в URL, но влияют на иерархию макета. Держите [[...slug]] изолированным в своем разделе, если у вас есть похожие шаблоны.
  • 404 Всегда вызывайте notFound() для недопустимых путей. Настройте app/not-found.tsx для удобства пользовательского интерфейса.

Полный пример раздела документации с [[...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>{/* section menu */}</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:

not-found.tsx:

export default function NotFound() {
  return <div>Page not found</div>;
}

Чек-лист предварительного релиза

  • /docs открывает главную страницу.
  • Глубокие пути, такие как /docs/a/b/c корректно рендерятся.
  • params.slug приводится к виду [] на корне.
  • Хлебные крошки создают правильные закодированные ссылки.
  • generateMetadata возвращает правильный заголовок и каноническую ссылку как для корневых, так и для вложенных страниц.
  • notFound() работает для недопустимых узлов.
  • revalidate и dynamic настройки соответствуют требованиям к свежести.

Основные моменты

[[...slug]] обеспечивает чистую архитектуру для разделов, где корень и вложенные страницы разделяют одну страницу и макет. Он упрощает навигацию, хлебные крошки, SEO и общее обслуживание.

Самые важные моменты:

  • Правильно типизируйте params.slug и приведите его к виду [] на корне.
  • Безопасно создавайте ссылки с помощью encodeURIComponent.
  • Предварительно построить пути с { slug: [] } при необходимости.
  • Управляйте SEO с помощью generateMetadata.
  • Всегда возвращайте notFound() для недопустимых путей.