Jak skonfigurować dynamiczny routing w Next.js. Pełne omówienie [[...slug]]

Jak skonfigurować dynamiczny routing w Next.js. Pełne omówienie [[...slug]]

Dynamiczny routing w App Router daje dużą elastyczność przy budowie bloga, katalogu, sklepu czy dokumentacji. Podstawowe przypadki można obsłużyć za pomocą [id] oraz [...slug], ale gdy chodzi o sekcje z opcjonalnym zagnieżdżeniem, najwygodniejszą architekturę zapewnia [[...slug]].

W tym artykule omówimy wszystkie trzy warianty, a największą uwagę poświęcimy [[...slug]]: jak dopasowuje adresy URL, co trafia do params, jak poprawnie go typować, jak generować statyczne ścieżki, budować breadcrumbs i zadbać o SEO.

Krótka mapa routingu

  • [slug] — jeden segment Przykład: /blog/my-post params.slug: string
  • [...slug] — wymagany catch-all (musi być przynajmniej jeden segment) Przykład: /docs/getting-started/install params.slug: string[]
  • [[...slug]] — opcjonalny catch-all (pasuje zarówno do korzenia, jak i do dowolnej liczby segmentów) Przykłady: /shop, /shop/men, /shop/men/t-shirts params.slug: string[] | undefined

Kiedy używać [[...slug]]

  • Jeden komponent i layout zarówno dla strony głównej sekcji, jak i wszystkich podstron. Przykład: dokumentacja lub katalog, gdzie /docs i /docs/setup/installation mają tę samą strukturę.
  • Potrzebujesz strony głównej bez segmentów oraz nieograniczonego zagnieżdżania niżej. Przykład: /docs jako spis treści i dalsze ścieżki jak /docs/guides/setup/cloud.
  • Breadcrumbs i nawigacja są budowane z tablicy segmentów, a strona główna sekcji też musi działać.

Podstawowa implementacja [[...slug]]

Struktura katalogów:

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 ?? []; // w korzeniu będzie undefined, więc zamieniamy na []
  const node = await getNodeByPath(segments);
  if (!node) notFound();

  return (
    <article>
      <h1>{node.title}</h1>
      <div dangerouslySetInnerHTML={{ __html: node.html }} />
    </article>
  );
}

Kluczowe punkty:

  • W korzeniu params.slug jest undefined. Zamień na [].
  • Ten sam routing obsługuje zarówno /docs, jak i dowolne zagnieżdżone ścieżki.

Breadcrumbs z [[...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>
  );
}

Użycie:

export default function DocsPage({ params }: { params: { slug?: string[] } }) {
  const segments = params.slug ?? [];
  return (
    <>
      <Breadcrumbs segments={segments} />
      {/* zawartość */}
    </>
  );
}

Dobre praktyki:

  • Przy budowie linków zawsze używaj encodeURIComponent.
  • Do wyświetlania możesz dekodować, aby pokazać czytelne nazwy.

Generowanie statycznych ścieżek dla [[...slug]]

Jeśli znasz część ścieżek i chcesz je zbudować statycznie, użyj generateStaticParams.

export const revalidate = 300;

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

Uwagi:

  • Dla [[...slug]] ścieżkę korzenia opisujesz jako { slug: [] }.
  • Jeśli chcesz ograniczyć routing tylko do podanych ścieżek:
export const dynamicParams = false; // wszystko inne zwróci 404

SEO i linki kanoniczne dla [[...slug]]

generateMetadata pozwala opisać metadane na podstawie segmentów:

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

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

Rekomendacje:

  • Uważaj na duplikaty adresów URL. Jeśli masz włączone trailingSlash, zadbaj o zgodność z canonical.
  • Dla stron dynamicznych ustaw rozsądny revalidate.

Kontrola cache i renderowania

  • Domyślnie routing jest statyczny + ISR:export const revalidate = 300;
  • Całkowicie dynamiczna odpowiedź:
await fetch(url, { cache: 'no-store' });
  • Hybryda: część danych z cache, część świeżo pobierana.

[[...slug]] vs [...slug] i typowe pułapki

  • Korzeń sekcji [[...slug]] pasuje i do korzenia, i do zagnieżdżeń. [...slug] nigdy nie pasuje do korzenia.
  • Typowanie Zawsze określaj typ: params: { slug?: string[] }.
  • Ścieżki statyczne Dodaj { slug: [] } jeśli chcesz zbudować korzeń.
  • Linki Nigdy nie konkatenatuj segmentów ręcznie, używaj encodeURIComponent.
  • Konflikty routingu Grupy (marketing) nie są w URL, ale wpływają na layout.
  • 404 Zawsze wywołuj notFound() dla nieistniejących kombinacji.

Kompletny przykład sekcji dokumentacji z [[...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>{/* menu sekcji */}</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; // pozwól na ścieżki nieopisane

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>Strona nie znaleziona</div>;
}

Checklist przed publikacją

  • /docs otwiera stronę główną.
  • /docs/a/b/c działa poprawnie.
  • params.slug w korzeniu jest normalizowany do [].
  • Breadcrumbs budują poprawne linki.
  • generateMetadata zwraca właściwe title i canonical.
  • notFound() działa dla złych ścieżek.
  • Ustawienia revalidate i dynamic odpowiadają wymaganiom.

Co warto zapamiętać

[[...slug]] daje czystą architekturę, gdzie zarówno korzeń, jak i wszystkie poziomy zagnieżdżenia są obsługiwane jednym routingiem i layoutem. Upraszcza to nawigację, breadcrumbs, SEO i utrzymanie projektu.

Najważniejsze:

  • Typuj params.slug i normalizuj do [].
  • Buduj linki z encodeURIComponent.
  • Dodaj { slug: [] } do statycznych ścieżek, jeśli chcesz prebuildować korzeń.
  • Generuj SEO w generateMetadata.
  • Zwracaj notFound() dla niepoprawnych adresów.