Accueil / Articles

Migrer de next/router : Paramètres, URLs peu profondes et erreurs 404 dans App Router

Abandonner Next/router : comment les paramètres, useParams, les mises à jour superficielles d’URL, la navigation programmée et les pages 404 réelles fonctionnent dans le App Router de Next.js.

Éditeur : REACTAPP.TOP6 min de lecture1340 mots

Sources: Next.js routing: the four next/router habits I had to unlearn

Les développeurs venant du Pages Router ont souvent encore tendance à utiliser import { useRouter } from 'next/router'. Dans un projet App Router, cette importation est trompeuse : le module existe toujours et peut même permettre des vérifications de type, mais router.query renvoie une valeur vide et le bug n’est pas évident. La navigation dans l’App Router (qui est la configuration par défaut depuis Next.js 13) se trouve dans next/navigation, et plusieurs expressions typiques du Pages Router n’existent tout simplement plus. Ce guide couvre les quatre changements auxquels vous serez confrontés lors du passage d’un petit blog au App Router, avec des exemples de code fonctionnels sous Next.js 16.3.1 et React 19 au moment de la rédaction.

Les paramètres dynamiques arrivent en tant que prop, et non à partir d’un hook

Dans le Pages Router, l’approche habituelle consistait à faire de la page un composant client et à lire router.query.id. Dans le App Router, une page est un composant serveur sauf indication contraire, et Next.js lui transmet une propriété params. Depuis Next.js 15, params est un Promise, ce qui oblige le composant à être async et à utiliser await avant de lire toute valeur.

La page importe notFound afin de pouvoir s’arrêter immédiatement lorsque le slug ne correspond à aucune entrée :

import { notFound } from 'next/navigation'

Le composant ci-dessous recherche l’entrée dans un tableau en mémoire et appelle notFound() pour les IDs inconnus.

const POSTS: Record<string, { title: string; body: string }> = {
  'hello-world': { title: 'Hello World', body: 'The first post.' },
}export default async function BlogPost({
  params,
}: {
  params: Promise<{ id: string }>
}) {
  const { id } = await params
  const post = POSTS[id]
  if (!post) notFound()  return (
    <article>
      <h1>{post.title}</h1>
      <p>{post.body}</p>
    </article>
  )
}

Il n’y a ni crochet ni directive 'use client' ici, et la recherche s’effectue sur le serveur avant que tout HTML ne soit envoyé. Une requête vers /blog/hello-world sur la version de production renvoie le titre et le corps de l’article avec un statut 200. Omettre await params est la plus fréquente erreur de migration dans Next.js 15 et versions ultérieures : la valeur que vous déstructurez est une promesse, et non l’ID.

Lecture du segment côté client avec useParams

Le rendu prioritaire au serveur ne signifie pas que chaque composant s’exécute sur le serveur. Un formulaire de commentaire a besoin de l’ID de l’article dans le navigateur. Pour cela, vous utilisez useParams provenant de next/navigation au lieu de router.query. Le fichier commence par cette directive :

'use client'

Ensuite, le composant lit ce segment. En passant un argument de type à useParams, vous obtenez un résultat typé :

import { useParams } from 'next/navigation'export default function CommentBox() {
  const { id } = useParams<{ id: string }>()
  return <form data-post={id}>Commenting on {id}</form>
}

Contrairement à la version Pages Router, il ne renvoie jamais null ici : le segment est résolu avant même que n’apparaisse tout composant client situé en dessous. Affiché à l’intérieur de la page post, le formulaire contient data-post="hello-world" dès la première mise à jour de l’écran, ce qui rend inutile tout état de chargement.

Le routage superficiel devient une simple appel à l’API History

Pages Router permet d’utiliser router.push(url, as, { shallow: true }) pour modifier l’URL sans recharger les données. App Router ne dispose pas d’option shallow. Pour mettre à jour l’URL en fonction du tri, des filtres ou de la tab active sans réexécuter les composants serveur, il faut appeler soi-même window.history.pushState et lire la valeur à l’aide de useSearchParams. Le fichier correspondant est toujours un composant client :

'use client'

Le contrôle copie les paramètres actuels, définit la valeur de sort et ajoute la nouvelle chaîne de requête ; newest sert en cas d’échec :

import { useSearchParams } from 'next/navigation'export default function SortControls() {
  const searchParams = useSearchParams()
  const active = searchParams.get('sort') ?? 'newest'  function setSort(sort: string) {
    const next = new URLSearchParams(searchParams.toString())
    next.set('sort', sort)
    window.history.pushState(null, '', `?${next.toString()}`)
  }  return (
    <div>
      <button aria-pressed={active === 'newest'} onClick={() => setSort('newest')}>
        Newest
      </button>
      <button aria-pressed={active === 'top'} onClick={() => setSort('top')}>
        Top
      </button>
    </div>
  )
}

Next.js synchronise les appels natifs pushState et replaceState avec son routeur, de sorte que useSearchParams perçoit la nouvelle valeur et que les boutons se mettent à jour sans avoir besoin d’un aller-retour vers le serveur. Deux pièges importants méritent d’être connus :

  • Un composant qui utilise useSearchParams doit se trouver à l’intérieur d’un élément <Suspense>. Sans cela, la page passe par le rendu côté client jusqu’au prochain élément <Suspense> au lieu d’être rendue statiquement, et le résultat de la compilation vous en avertit.
  • pushState ajoute une entrée dans l’historique à chaque clic. Si revenir en arrière après plusieurs modifications de tri dérange les utilisateurs, utilisez plutôt replaceState.

La navigation programmée utilise toujours useRouter depuis un nouveau chemin

Lorsque la navigation doit passer par le routeur, comme pour renvoyer l’utilisateur à la page d’accueil après soumission d’un formulaire, useRouter reste l’outil approprié. Seul le chemin d’importation change. Il nécessite également la directive suivante :

'use client'

Et l’appel lui-même reste inchangé :

import { useRouter } from 'next/navigation'export default function GoHome() {
  const router = useRouter()
  return <button onClick={() => router.push('/')}>Home</button>
}

router.push('/') fonctionne comme avant ; le problème réside uniquement dans la ligne d’importation.

Les pages 404 personnalisées sont un fichier qui envoie réellement le statut 404

La vieille convention pages/404.js est remplacée par un fichier app/not-found.tsx, qui s’affiche chaque fois que notFound() est appelé, y compris depuis le composant serveur mentionné dans la première section. La page redirige vers l’index, c’est pourquoi elle importe Link :

import Link from 'next/link'

Le composant lui-même n’est qu’un marquage HTML ordinaire :

export default function NotFound() {
  return (
    <main>
      <h1>404 — no such post</h1>
      <Link href="/">Back to the index</Link>
    </main>
  )
}

Le code d’état est important : un 404 « doux », une page ressemblant à une erreur servie avec un 200, peut nuire à l’indexation des moteurs de recherche car les crawlers la traitent comme du contenu réel. La demande d’une publication manquante telle que /blog/nope renvoie un HTTP 404 accompagné du message « no such post ». La méthode notFound() définit cet état, de sorte que ce que voient les crawlers correspond à ce que voient les utilisateurs.

Laissez le résultat de la compilation confirmer la migration

Lorsque la migration est terminée, le tableau des routes affiché par next build est le moyen le plus rapide de vérifier le résultat :

Route (app)
┌ ○ /
├ ○ /_not-found
└ ƒ /blog/[id]

ƒ indique que /blog/[id] est rendu dynamiquement car il lit des paramètres, tandis que ○ marque les routes d’index statiques et celles qui ne sont pas trouvées. Aucun code ne fait appel à next/router, router.query ou shallow: true. Pour savoir ce qui s’exécute où, consultez où tracer la ligne serveur-client dans une page App Router.

Points clés

  • Recherchez d’abord dans le codebase from 'next/router' ; chaque occurrence correspond à l’un de ces quatre changements.
  • Lisez les segments dynamiques depuis la propriété params dans les composants serveur, et depuis useParams dans les composants client.
  • Remplacez shallow: true par history.pushState ou replaceState accompagné de useSearchParams, encadrés dans <Suspense>.
  • Gardez useRouter pour les navigations réelles, importé depuis next/navigation.
  • Utilisez app/not-found.tsx avec notFound() pour obtenir un véritable statut 404 plutôt qu’un 404 atténué.
  • Lectures complémentaires