Changer le thème

Routes dynamiques Next.js et paramètres : guide complet de l'initiation au typage

Easton editorial illustration: server-client bridge

La semaine dernière, en refactorant un projet Next.js, j’ai eu un problème énervant : la route dynamique suivait la doc, mais un clic menait à une 404. Console silencieuse, aucune erreur. J’ai fini par comprendre que Next.js 14 App Router a changé la façon de récupérer les paramètres — j’utilisais encore l’ancienne syntaxe Pages Router.

Ce n’est pas la première fois que je trébuche sur le routage Next.js. De getStaticPaths (Pages Router) à generateStaticParams (App Router), chaque upgrade oblige à réapprendre. Quand utiliser une route dynamique, un catch-all, des paramètres optionnels ? Tout se mélange facilement.

Si vous êtes dans la même situation — perdu avec les routes dynamiques Next.js ou en migration Pages → App Router — cet article est pour vous. Des bases aux pratiques de typage sûr, avec beaucoup d’exemples concrets.

À la fin : une vision complète des routes dynamiques, le bon type selon le scénario, la récupération correcte des paramètres et TypeScript pour des params typés. Pas de blabla : du code et des solutions. C’est parti.

Chapitre 1 : Bases des routes dynamiques

Qu’est-ce qu’une route dynamique ?

Scénario courant : un blog où chaque article a l’URL /blog/ID. Avec des routes statiques, il faudrait un fichier par article — impossible. Entrent les routes dynamiques : un seul fichier pour tous les détails.

Dans Next.js App Router, on utilise des dossiers entre crochets :

app/
├── blog/
│   └── [slug]/
│       └── page.tsx    ← route dynamique

Cette structure correspond à tout /blog/* :

  • /blog/hello-worldslug = "hello-world"
  • /blog/nextjs-guideslug = "nextjs-guide"
  • /blog/123slug = "123"

Implémentation minimale

Créez app/blog/[slug]/page.tsx :

// app/blog/[slug]/page.tsx
export default function BlogPost({
  params
}: {
  params: { slug: string }
}) {
  return (
    <div>
      <h1>Article</h1>
      <p>Slug actuel : {params.slug}</p>
    </div>
  )
}

Sur /blog/hello-world, params.slug vaut "hello-world".

Erreurs fréquentes des débutants :

  1. ❌ Fichier [slug].tsx (App Router exige un dossier)
  2. ❌ Accéder à props.slug (passer par params)
  3. ❌ Oublier les crochets (sans crochets = route statique)

Pages Router vs App Router

CaractéristiquePages RouterApp Router
Emplacementpages/blog/[slug].tsxapp/blog/[slug]/page.tsx
Paramètresrouter.query.slug ou getStaticPropsparams.slug
Typesmanuelsvia props
Génération statiquegetStaticPathsgenerateStaticParams

En migration, le plus déroutant est la récupération des paramètres. Pages Router : hook useRouter. App Router Server Components : pas de hooks, uniquement la prop params — rendu serveur par défaut, pas d’objet router côté client.

Cas pratique : fiche produit e-commerce

URL /products/ID :

// app/products/[id]/page.tsx
interface Product {
  id: string
  name: string
  price: number
  description: string
}

async function getProduct(id: string): Promise<Product | null> {
  const products: Product[] = [
    { id: '1', name: 'Livre TypeScript', price: 99, description: 'Pour débutants' },
    { id: '2', name: 'Guide React', price: 129, description: 'De zéro à la prod' }
  ]
  return products.find(p => p.id === id) || null
}

export default async function ProductPage({
  params
}: {
  params: { id: string }
}) {
  const product = await getProduct(params.id)

  if (!product) {
    return <div>Produit introuvable</div>
  }

  return (
    <div>
      <h1>{product.name}</h1>
      <p className="price">¥{product.price}</p>
      <p>{product.description}</p>
    </div>
  )
}

Détails importants :

  1. Composant async (Server Components)
  2. Données d’abord, puis rendu
  3. Cas produit absent (404)

Pour une vraie page 404, utilisez notFound :

import { notFound } from 'next/navigation'

export default async function ProductPage({
  params
}: {
  params: { id: string }
}) {
  const product = await getProduct(params.id)

  if (!product) {
    notFound()
  }

  return (
    <div>
      <h1>{product.name}</h1>
      {/* ... */}
    </div>
  )
}

L’utilisateur voit votre not-found.tsx personnalisé. Vous maîtrisez les bases ; passons aux chemins multi-niveaux.

Chapitre 2 : Catch-All et paramètres optionnels

Quand utiliser un catch-all ?

Site de documentation :

  • /docs/getting-started
  • /docs/api/authentication
  • /docs/api/database/queries
  • /docs/guides/deployment/vercel

Profondeur variable — une route dynamique simple ne suffit pas. Il faut un catch-all.

Catch-All : [...slug]

Nom de dossier [...slug] (trois points), profondeur quelconque :

app/
├── docs/
│   └── [...slug]/
│       └── page.tsx

Correspondances :

  • /docs/getting-startedslug = ["getting-started"]
  • /docs/api/authenticationslug = ["api", "authentication"]
  • /docs/guides/deployment/vercelslug = ["guides", "deployment", "vercel"]

Attention : slug est un tableau, pas une chaîne.

Implémentation : système de docs

// app/docs/[...slug]/page.tsx
interface Doc {
  title: string
  content: string
}

async function getDoc(slugArray: string[]): Promise<Doc | null> {
  const path = slugArray.join('/')

  const docs: Record<string, Doc> = {
    'getting-started': {
      title: 'Démarrage rapide',
      content: 'Bienvenue...'
    },
    'api/authentication': {
      title: 'Authentification API',
      content: 'Nous utilisons JWT...'
    },
    'api/database/queries': {
      title: 'Requêtes base de données',
      content: 'Requêtes avec Prisma...'
    }
  }

  return docs[path] || null
}

export default async function DocsPage({
  params
}: {
  params: { slug: string[] }
}) {
  const doc = await getDoc(params.slug)

  if (!doc) {
    return <div>Document introuvable</div>
  }

  return (
    <article>
      <h1>{doc.title}</h1>
      <div dangerouslySetInnerHTML={{ __html: doc.content }} />

      <nav>
        <a href="/docs">Docs</a>
        {params.slug.map((segment, i) => {
          const href = `/docs/${params.slug.slice(0, i + 1).join('/')}`
          return (
            <span key={i}>
              {' / '}
              <a href={href}>{segment}</a>
            </span>
          )
        })}
      </nav>
    </article>
  )
}

Points forts :

  1. slugArray.join('/') pour le chemin
  2. Fil d’Ariane avec slice
  3. Type params: { slug: string[] }

Catch-All optionnel : [[...slug]]

Pour matcher /docs et /docs/* :

app/
├── docs/
│   └── [[...slug]]/
│       └── page.tsx

Correspondances :

  • /docsslug = undefined
  • /docs/getting-startedslug = ["getting-started"]
  • /docs/api/authslug = ["api", "auth"]

Gérer slug optionnel :

// app/docs/[[...slug]]/page.tsx
export default async function DocsPage({
  params
}: {
  params: { slug?: string[] }
}) {
  if (!params.slug) {
    return <div>Bienvenue au centre de documentation</div>
  }

  const doc = await getDoc(params.slug)
  // ...
}

Pièges fréquents

Piège 1 : oublier que slug est un tableau

// ❌ Incorrect
<h1>Chemin : {params.slug}</h1>

// ✅ Correct
<h1>Chemin : {params.slug.join('/')}</h1>

Piège 2 : mauvaise structure en génération statique

// ❌ Incorrect
export function generateStaticParams() {
  return [
    { slug: 'api/auth' }
  ]
}

// ✅ Correct
export function generateStaticParams() {
  return [
    { slug: ['api', 'auth'] }
  ]
}

Piège 3 : confondre les trois types

TypeDossierCorrespondanceType param
Dynamique[slug]/blog/123string
Catch-All[...slug]/docs/a/b/c (pas /docs)string[]
Catch-All opt.[[...slug]]/docs et /docs/a/b/cstring[] | undefined

J’ai mélangé les trois — routes instables jusqu’à correction du nommage.

Astuce : caractères spéciaux

Pour URL avec caractères spéciaux, encodez/décodez :

export default async function Page({
  params
}: {
  params: { slug: string[] }
}) {
  const decodedSlug = params.slug.map(s => decodeURIComponent(s))

  console.log(params.slug)
  console.log(decodedSlug)

  // ...
}

Vous gérez les chemins complexes. Reste : quand générer ces pages ? À la demande ou au build ? C’est le rôle de generateStaticParams.

Chapitre 3 : generateStaticParams en profondeur

Pourquoi generateStaticParams ?

100 articles sur /blog/[slug], sans optimisation : requête BDD, SSR, réponse — lent et coûteux. Next.js pré-rend au build via generateStaticParams.

Usage de base : blog statique

// app/blog/[slug]/page.tsx
interface Post {
  slug: string
  title: string
  content: string
}

export async function generateStaticParams() {
  const posts = await fetch('https://api.example.com/posts').then(r => r.json())

  return posts.map((post: Post) => ({
    slug: post.slug
  }))
}

export default async function BlogPost({
  params
}: {
  params: { slug: string }
}) {
  const post = await fetch(`https://api.example.com/posts/${params.slug}`)
    .then(r => r.json())

  return (
    <article>
      <h1>{post.title}</h1>
      <div dangerouslySetInnerHTML={{ __html: post.content }} />
    </article>
  )
}

Effet :

  1. generateStaticParams au build → tous les slugs
  2. HTML statique par slug
  3. Réponse instantanée à la visite

Artefacts build :

.next/server/app/blog/
├── hello-world.html
├── nextjs-guide.html
└── typescript-tips.html

Quand l’utiliser ?

Adapté :

  • Articles, actualités (contenu stable)
  • Fiches produit (< ~10 000)
  • Documentation, aide
  • Profils utilisateur (volume modéré)

Non adapté :

  • Recherche (combinaisons infinies)
  • Données temps réel (bourse, scores)
  • UGC massif
  • Contenu selon session/login

Catch-All en statique

Pour [...slug], retourner des tableaux :

// app/docs/[...slug]/page.tsx
export async function generateStaticParams() {
  const docPaths = [
    ['getting-started'],
    ['api', 'authentication'],
    ['api', 'database', 'queries'],
    ['guides', 'deployment', 'vercel']
  ]

  return docPaths.map(slug => ({ slug }))
}

export default async function DocsPage({
  params
}: {
  params: { slug: string[] }
}) {
  // ...
}

Format : { slug: ['api', 'auth'] }, pas une chaîne.

Multi-paramètres : /shop/[category]/[productId]

app/
├── shop/
│   └── [category]/
│       └── [productId]/
│           └── page.tsx
// app/shop/[category]/[productId]/page.tsx
export async function generateStaticParams() {
  const products = [
    { category: 'electronics', productId: 'iphone-15' },
    { category: 'electronics', productId: 'macbook-pro' },
    { category: 'books', productId: 'clean-code' },
    { category: 'books', productId: 'refactoring' }
  ]

  return products.map(p => ({
    category: p.category,
    productId: p.productId
  }))
}

export default async function ProductPage({
  params
}: {
  params: { category: string; productId: string }
}) {
  return (
    <div>
      <h1>Catégorie : {params.category}</h1>
      <p>ID produit : {params.productId}</p>
    </div>
  )
}

Génération à la demande (fallback)

Contenu massif (100 000 articles) : pré-rendre seulement le populaire :

// app/blog/[slug]/page.tsx
export const dynamicParams = true

export async function generateStaticParams() {
  const topPosts = await fetchTopPosts(100)

  return topPosts.map(post => ({
    slug: post.slug
  }))
}

export default async function BlogPost({
  params
}: {
  params: { slug: string }
}) {
  const post = await fetchPost(params.slug)

  if (!post) {
    notFound()
  }

  return <article>{/* ... */}</article>
}

Avec dynamicParams = true :

  • Pré-rendu : réponse immédiate
  • Non pré-rendu : généré à la 1re visite, puis cache
  • Inexistant : 404

Questions fréquentes

Q1 : quand s’exécute generateStaticParams ?

Au build (npm run build) uniquement. En dev (npm run dev), effet limité — builder pour voir les fichiers statiques.

Q2 : données mises à jour ?

Contenu figé après build. Solutions :

  • ISR (Incremental Static Regeneration)
  • dynamicParams = true
  • revalidate
export const revalidate = 60

export default async function Page() {
  // ...
}

Q3 : build trop long ?

Plus de chemins = build plus long. Réduire le pré-rendu, build incrémental (Vercel/Netlify), ou dynamicParams = true.

Dernière étape : typage TypeScript des paramètres de route.

Chapitre 4 : Typage sûr des paramètres

Pourquoi le typage ?

export default async function Page({
  params
}: {
  params: { slug: string }
}) {
  const id = parseInt(params.slug)

  if (isNaN(id)) {
    return <div>ID invalide</div>
  }

  // ...
}

params.slug est string, besoin d’un nombre — erreur à l’exécution, pas à la compilation.

Contraintes de base

Par défaut, params est string ou string[]. Personnalisez :

// app/blog/[slug]/page.tsx
interface BlogParams {
  slug: string
}

export default async function BlogPost({
  params
}: {
  params: BlogParams
}) {
  const post = await fetchPost(params.slug)
  // ...
}

Utile avec plusieurs paramètres :

// app/shop/[category]/[productId]/page.tsx
interface ShopParams {
  category: 'electronics' | 'books' | 'clothing'
  productId: string
}

export default async function ProductPage({
  params
}: {
  params: ShopParams
}) {
  if (params.category === 'toys') {  // ❌ erreur de compilation
    // ...
  }
}

Validation runtime avec Zod

Les types ne couvrent que la compilation. Zod pour le runtime :

npm install zod
// app/products/[id]/page.tsx
import { z } from 'zod'
import { notFound } from 'next/navigation'

const paramsSchema = z.object({
  id: z.string().regex(/^\d+$/, 'ID numérique requis')
})

export default async function ProductPage({
  params
}: {
  params: { id: string }
}) {
  const result = paramsSchema.safeParse(params)

  if (!result.success) {
    notFound()
  }

  const { id } = result.data
  const product = await fetchProduct(parseInt(id))
  // ...
}

Avantages : compile-time + runtime, 404 sur requêtes invalides.

generateStaticParams typé

// app/blog/[slug]/page.tsx
interface BlogParams {
  slug: string
}

export async function generateStaticParams(): Promise<BlogParams[]> {
  const posts = await fetchAllPosts()

  return posts.map(post => ({
    slug: post.slug
  }))
}

export default async function BlogPost({
  params
}: {
  params: BlogParams
}) {
  // ...
}

Cas pratique : blog multilingue /[locale]/blog/[slug]

// app/[locale]/blog/[slug]/page.tsx
import { z } from 'zod'
import { notFound } from 'next/navigation'

const locales = ['zh', 'en', 'ja'] as const
type Locale = typeof locales[number]

interface PageParams {
  locale: Locale
  slug: string
}

const paramsSchema = z.object({
  locale: z.enum(locales),
  slug: z.string().min(1)
})

export async function generateStaticParams(): Promise<PageParams[]> {
  const posts = await fetchAllPosts()

  return locales.flatMap(locale =>
    posts.map(post => ({
      locale,
      slug: post.slug
    }))
  )
}

export default async function BlogPost({
  params
}: {
  params: PageParams
}) {
  const result = paramsSchema.safeParse(params)
  if (!result.success) {
    notFound()
  }

  const { locale, slug } = result.data

  const post = await fetchPost(slug, locale)

  if (!post) {
    notFound()
  }

  return (
    <article>
      <h1>{post.title}</h1>
      <div>{post.content}</div>
    </article>
  )
}

Atouts :

  1. Locale = "zh" | "en" | "ja"
  2. generateStaticParamsPageParams[]
  3. Validation Zod runtime
  4. Chaîne compile + runtime stricte

Dépannage types

Q1 : params est Promise<...> ?

Next.js 15+ : params asynchrone :

export default async function Page({
  params
}: {
  params: Promise<{ slug: string }>
}) {
  const { slug } = await params
  // ...
}

Next.js 14 (sync) :

export default async function Page({
  params
}: {
  params: { slug: string }
}) {
  // ...
}

Q2 : params en any ?

Vérifier : strict mode tsconfig.json, types Next.js, nom page.tsx.

Q3 : détails erreur Zod

const result = paramsSchema.safeParse(params)

if (!result.success) {
  console.error('Validation échouée :', result.error.format())
  notFound()
}

Checklist typage

  • Type params sur chaque route dynamique
  • Retour generateStaticParams aligné avec params
  • Zod sur routes sensibles
  • Strict mode TypeScript
  • Unions / littéraux pour paramètres complexes

Conclusion

Vous maîtrisez désormais les routes dynamiques Next.js :

Dynamique simple : [slug], récupération via params
Catch-All : [...slug], paramètres optionnels [[...slug]]
generateStaticParams : quand, comment, génération à la demande
Typage : contraintes compile-time + validation runtime

Vous distinguez App Router et Pages Router, savez quand pré-rendre ou générer à la demande.

Prochaines étapes

Pratique immédiate :

  • Créer une route dynamique et tester params
  • Essayer catch-all si chemins multi-niveaux
  • Ajouter types TypeScript et validation Zod

Approfondir :

  • Routes parallèles (@folder)
  • Routes interceptées ((.)folder)
  • Groupes de routes (folder)
  • Middleware pour auth et redirections

Ressources :

Aide rapide :

ProblèmeVérifierSolution
404 sur route dynamiqueNom dossier, generateStaticParamsCrochets, config statique
params en anyConfig TSStrict mode, types params
Build trop longNombre de cheminsMoins de pré-rendu, dynamicParams
Données figéesCacherevalidate ou dynamicParams

Le passage Pages → App Router fait mal, mais une fois l’esprit App Router acquis, tout devient plus clair. Les routes dynamiques sont la base — données, cache, middleware suivront plus facilement.

En cas de blocage : doc officielle Troubleshooting, Issues GitHub Next.js, Discord Next.js.

Ouvrez l’éditeur et construisez vos routes dynamiques ! 🚀

Configuration complète des routes dynamiques Next.js

Étapes complètes de la création de routes dynamiques aux pratiques de typage sûr

⏱️ Estimated time: 2 hr

  1. 1

    Step 1: Créer le dossier de route dynamique

    Choisir le type selon le besoin :
    • Paramètre unique : app/posts/[id]/page.tsx
    • Multi-paramètres : app/posts/[category]/[id]/page.tsx
    • Catch-all : app/posts/[...slug]/page.tsx
    • Catch-all optionnel : app/posts/[[...slug]]/page.tsx

    Règles de nommage :
    • [id] : paramètre obligatoire
    • [...slug] : capture tous les segments
    • [[...slug]] : capture optionnelle de tous les segments
  2. 2

    Step 2: Récupérer les paramètres de route

    Dans page.tsx :
    • App Router utilise l'objet params
    • params est une Promise, il faut await
    • Utiliser la déstructuration pour chaque paramètre

    Exemple :
    export default async function Page({ params }) {
    const { id } = await params
    return <div>Post {id}</div>
    }

    Attention : params doit être await, sinon erreur
  3. 3

    Step 3: Configurer le typage sûr

    Définir les types TypeScript :
    • Interface pour params
    • Type Promise<{ params }>
    • Type de retour de generateStaticParams

    Exemple :
    interface PageProps {
    params: Promise<{ id: string }>
    }

    export default async function Page({ params }: PageProps) {
    const { id } = await params
    // ...
    }
  4. 4

    Step 4: Implémenter la génération statique (optionnel)

    Utiliser generateStaticParams :
    • Retourner toutes les combinaisons possibles
    • Fonction async pour récupérer les données
    • Génère statiquement toutes les pages

    Exemple :
    export async function generateStaticParams() {
    const posts = await getPosts()
    return posts.map(post => ({ id: post.id }))
    }

    Note : uniquement pour la génération statique, pas pour les routes dynamiques pures
  5. 5

    Step 5: Gérer les paramètres optionnels

    Route catch-all optionnelle :
    • Syntaxe [[...slug]]
    • params.slug peut être undefined
    • Vérifier l'existence du paramètre

    Exemple :
    export default async function Page({ params }) {
    const { slug } = await params
    if (!slug) {
    return <div>All posts</div>
    }
    return <div>Category: {slug.join('/')}</div>
    }
  6. 6

    Step 6: Tester et valider

    Points de test :
    • Toutes les routes répondent
    • Paramètres récupérés correctement
    • Infos de type correctes
    • Génération statique OK

    Checklist :
    • Toutes les routes dynamiques accessibles
    • Types params corrects
    • generateStaticParams retourne les bonnes données
    • Erreurs 404 gérées

FAQ

Comment récupérer les paramètres de route dynamique ?
App Router utilise l'objet params.

Points clés :
• params est une Promise, il faut await
• Déstructurer pour chaque paramètre
• Définir les types

Exemple :
export default async function Page({ params }) {
const { id } = await params
return <div>{id}</div>
}
Pourquoi une route dynamique renvoie 404 ?
Causes possibles :
• Nom de dossier incorrect ( [id] et non {id} )
• Chemin non correspondant (URL vs arborescence)
• Données generateStaticParams incomplètes
• Fichier page.tsx manquant

Solutions :
• Vérifier le nommage des dossiers
• Confirmer que l'URL correspond à l'arborescence
• Contrôler le retour de generateStaticParams
Différence entre catch-all et catch-all optionnel ?
Catch-all [...slug] :
• Au moins un segment requis
• /posts/[...slug] correspond à /posts/a, pas à /posts

Catch-all optionnel [[...slug]] :
• 0 segment ou plus
• /posts/[[...slug]] correspond à /posts et /posts/a/b

Usage :
• catch-all : au moins un paramètre
• catch-all optionnel : paramètre facultatif
Comment typer sûrement une route dynamique ?
Étapes :
1) Interface pour params
2) Type Promise<{ params }>
3) Type de retour de generateStaticParams

Exemple :
interface PageProps {
params: Promise<{ id: string }>
}

export default async function Page({ params }: PageProps) {
const { id } = await params
// ...
}
Quand utiliser generateStaticParams ?
Pour générer statiquement toutes les pages possibles.

Adapté :
• Valeurs de paramètres connues
• Génération statique de toutes les pages
• Performance et SEO

Non adapté :
• Paramètres changeants
• Trop de valeurs à énumérer
• Données en temps réel

Note : génération statique uniquement
Comment migrer les routes dynamiques depuis Pages Router ?
Changements principaux :
• getStaticPaths → generateStaticParams
• context.params → params (avec await)
• Format { paths, fallback } → tableau

Migration :
1) Remplacer getStaticPaths par generateStaticParams
2) Adapter la récupération (await params)
3) Mettre à jour les types
4) Tester toutes les routes
Comment gérer les routes multi-paramètres ?
Dossiers imbriqués :
app/posts/[category]/[id]/page.tsx

Récupération :
export default async function Page({ params }) {
const { category, id } = await params
return <div>{category} - {id}</div>
}

generateStaticParams retourne toutes les combinaisons :
export async function generateStaticParams() {
return [
{ category: 'tech', id: '1' },
{ category: 'tech', id: '2' },
// ...
]
}

10 min de lecture · Publié le: 25 déc. 2025 · Mis à jour le: 27 juil. 2026

Commentaires

Connectez-vous avec GitHub pour laisser un commentaire

Easton BlogEaston Blog