Journal / Développement de produits web

Comment configurer des routes dynamiques dans Next.js. Tour d'horizon complet de [[...slug]]

Comment configurer des routes dynamiques dans Next.js. Tour d'horizon complet de [[...slug]]

Le routage dynamique de l'App Router offre de la flexibilité pour construire un blog, un catalogue, une boutique ou un site de documentation. Les cas d'usage de base se gèrent avec [id] et [...slug], mais pour une section avec imbrication facultative, c'est [[...slug]] qui offre l'architecture la plus pratique.

Dans cet article, nous passerons en revue les trois schémas, mais en nous concentrant surtout sur [[...slug]] : comment il correspond aux URL, ce qui arrive dans params, comment le déclarer en toute sécurité, comment générer des chemins statiques, comment construire des fils d'Ariane et comment gérer le SEO.

Aide-mémoire rapide

  • [slug] : un seul segment. Exemple : /blog/my-post. params.slug: string
  • [...slug] : catch-all obligatoire (au moins un segment est nécessaire). Exemple : /docs/getting-started/install. params.slug: string[]
  • [[...slug]] : catch-all facultatif (correspond aussi bien à la racine qu'à n'importe quel nombre de segments). Exemples : /shop, /shop/men, /shop/men/t-shirts. params.slug: string[] | undefined

Quand utiliser [[...slug]]

  • Un seul composant et un seul layout pour la racine comme pour tous les niveaux imbriqués d'une section. Exemple : une documentation ou un catalogue où la page d'accueil de la section et les sous-pages ont la même structure.
  • Vous avez besoin d'une page racine sans segments, plus d'une imbrication illimitée en dessous. Exemple : /docs comme table des matières et des chemins plus profonds comme /docs/guides/setup/cloud.
  • Les fils d'Ariane et la navigation sont construits à partir du tableau de segments, mais la page racine doit aussi fonctionner.

Implémentation de base de [[...slug]]

Structure du répertoire :

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

Points clés :

  • À la racine, params.slug vaut undefined. Convertissez-le immédiatement en [].
  • La route correspond aussi bien à /docs qu'à toutes les imbrications.

Fils d'Ariane à partir de [[...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>
  );
}

Utilisation :

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

Bonnes pratiques :

  • Utilisez toujours encodeURIComponent pour générer les liens.
  • Pour l'affichage, vous pouvez décoder afin de montrer des noms lisibles par les humains.

Génération de chemins statiques pour [[...slug]]

Si vous connaissez certains chemins à l'avance et voulez les construire en amont, utilisez generateStaticParams. Avec le catch-all facultatif, vous pouvez générer aussi bien les chemins imbriqués que la racine.

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

Remarques :

  • Pour [[...slug]], il est commode de décrire le chemin racine par { slug: [] }. Cela indique clairement qu'il faut générer le chemin sans segments.
  • Pour restreindre strictement les chemins statiques valides, utilisez :

SEO et liens canoniques pour [[...slug]]

generateMetadata reçoit params et permet de générer les métadonnées à partir des segments.

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

Recommandations :

  • Soyez prudent avec les URL en double. Si vous utilisez trailingSlash, assurez-vous que le canonical correspond à la configuration réelle.
  • Pour les pages pilotées par les données, définissez un revalidate raisonnable afin que les métadonnées soient toujours fraîches.

Contrôle du cache et du rendu

  • Statique par défaut avec l'ISR : export const revalidate = 300;
  • Réponse entièrement dynamique :
await fetch(url, { cache: 'no-store' });

// ou au niveau du routage : export const dynamic = 'force-dynamic';

  • Approche mixte : mettez certaines données en cache, récupérez les autres à la volée. Pour les blocs mis à jour fréquemment, envisagez les Route Handlers avec leur propre cache.

[[...slug]] vs [...slug] et pièges courants

  • Gestion de la racine : [[...slug]] correspond à la fois à la racine et aux chemins imbriqués. À la racine, params.slug vaut undefined. Normalisez-le en []. [...slug] ne correspond jamais à la racine. Au moins un segment est requis.
  • Typage : typez toujours explicitement : params: { slug?: string[] }. Cela évite des erreurs lors de l'accès à params.slug.length.
  • Chemins statiques : ajoutez { slug: [] } si vous voulez pré-construire la racine. Avec dynamicParams = false, tout chemin non renvoyé par generateStaticParams donnera une 404. Liens : ne concaténez jamais les segments directement. Utilisez encodeURIComponent pour générer les href.
  • Conflits de routes : les groupes de routes (par ex. (marketing)) n'apparaissent pas dans l'URL mais influent sur la hiérarchie des layouts. Gardez
  • [[...slug]] isolé dans sa section si vous avez des schémas similaires. 404 : appelez toujours notFound() pour les chemins invalides. Personnalisez app/not-found.tsx pour votre UX.
  • **Exemple complet d'une section de documentation avec **[[...slug]] layout.tsx : page.tsx :

generateStaticParams.ts :

app/
  docs/
    layout.tsx
    [[...slug]]/
      page.tsx
      loading.tsx
      not-found.tsx

not-found.tsx :

export default function DocsLayout({ children }: { children: React.ReactNode }) {
  return (
    <div className="docs">
      <aside>{/* section menu */}</aside>
      <main>{children}</main>
    </div>
  );
}

Checklist avant mise en production/docs

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

ouvre la page racine. Les chemins profonds comme

/docs/a/b/c s'affichent correctement.

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

params.slug

  • est normalisé en[]
  • à la racine. Les Breadcrumbs génèrent des liens corrects et encodés. generateMetadata
  • renvoie un title et un canonical corrects pour la racine comme pour les pages imbriquées. notFound() fonctionne pour les nœuds invalides.` revalidate
  • et
  • les réglages dynamic correspondent aux exigences de fraîcheur.
  • À retenir [[...slug]]
  • offre une architecture propre pour les sections où la racine et les pages imbriquées partagent une seule page et un seul layout. Il simplifie la navigation, les fils d'Ariane, le SEO et la maintenance globale. Les points les plus importants : Typez params.slug

correctement et normalisez-le en

[] à la racine.

Construisez les liens en toute sécurité avec

  • encodeURIComponent . Pré-construisez les chemins avec { slug: [] } si nécessaire.
  • Gérez le SEO avec generateMetadata.
  • Renvoyez toujours notFound() pour les chemins invalides.
  • Routes dynamiques dans Next.js : tour d'horizon complet de [[...slug]] Guide détaillé des routes dynamiques dans Next.js : [slug], [...slug] et [[...slug]]. Explication du fonctionnement des paramètres, de la génération de chemins statiques, du SEO et de la gestion des 404..
  • Renvoyez toujours notFound() pour les chemins invalides.