Changer le thème

Pièges courants du Next.js App Router et solutions : 8 retours d'expérience pour éviter les faux pas

Easton editorial illustration: route-map drafting table

Au début avec le Next.js App Router, j’ai vraiment pris cher.

Fin d’année dernière, le projet en entreprise passait à Next.js 15 ; autant profiter pour migrer Pages Router → App Router. La doc officielle promettait « de meilleures perfs », « une meilleure DX », « l’architecture révolutionnaire des Server Components ». Résultat ? Dès le premier jour, une pile de comportements bizarres.

Données qui ne se rafraîchissent pas, pages qui tournent indéfiniment, cache qui semble ignorer la config, Server vs Client Components qu’on confond… Le pire : un bug que j’ai débogué 3 heures, pour découvrir qu’il manquait 'use client' dans error.tsx. Frustrant.

En interne, on a constaté que 80 % des problèmes se répètent. J’ai donc listé ces pièges pour vous faire gagner du temps.

Pas de théorie ici : retours terrain. Pour chaque piège : pourquoi ça arrive, comment on le repère, comment on le corrige. À la fin, vous saurez éviter la plupart des « trous » de l’App Router.

Pièges liés à la récupération de données

Piège 1 : refetch côté client en double

Contexte :

J’affichais le profil utilisateur comme à l’ancienne :

// app/profile/page.tsx
'use client'
import { useEffect, useState } from 'react'

export default function ProfilePage() {
  const [user, setUser] = useState(null)

  useEffect(() => {
    fetch('/api/user')
      .then(res => res.json())
      .then(data => setUser(data))
  }, [])

  if (!user) return <div>Loading...</div>
  return <div>Hello, {user.name}</div>
}

Ça a l’air correct, mais c’est un anti-pattern : base → Route Handler → client, un aller-retour réseau inutile.

Pourquoi on tombe dedans :

Sous Pages Router, useEffect + fetch était la norme. Avec l’App Router, les Server Components lisent les données directement côté serveur.

La bonne approche :

// app/profile/page.tsx (Server Component par défaut)
import { db } from '@/lib/db'

export default async function ProfilePage() {
  // requête BDD côté serveur
  const user = await db.user.findFirst()

  return <div>Hello, {user.name}</div>
}

Gains immédiats :

  • une requête API en moins
  • latence serveur → BDD souvent < 10 ms (client → serveur 100 ms+)
  • bundle JS client plus léger

À retenir :

Tout ce que le Server Component peut charger, ne le refetch pas côté client. Client fetch seulement pour interaction (recherche, filtres, temps réel).

Piège 2 : cache par défaut des Route Handlers

Contexte :

Une API renvoie l’heure courante ; après rafraîchissement, l’heure ne change pas :

// app/api/time/route.ts
export async function GET() {
  return Response.json({ time: new Date().toISOString() })
}

Dix rafraîchissements, même timestamp. J’ai cru que le code ne tournait pas.

Pourquoi :

Next.js met en cache les GET des Route Handlers par défaut — bien pour du statique, mauvais pour du dynamique.

Solution 1 : forcer le dynamique

// app/api/time/route.ts
export const dynamic = 'force-dynamic' // rendu dynamique forcé

export async function GET() {
  return Response.json({ time: new Date().toISOString() })
}

Solution 2 : Next.js 15

Bonne nouvelle : en Next.js 15, les GET Route Handler ne sont plus cachés par défaut. Sous Next.js 14 :

// app/api/time/route.ts
export async function GET() {
  return Response.json(
    { time: new Date().toISOString() },
    { headers: { 'Cache-Control': 'no-store' } }
  )
}

Mon habitude :

  • données statiques (config) : export const revalidate = 3600
  • données dynamiques (utilisateur, temps réel) : export const dynamic = 'force-dynamic'

Ne comptez pas sur les défauts : exprimez l’intention.

Piège 3 : oublier la revalidation après mutation

Contexte :

Todo app : après ajout, la liste ne bouge pas :

// app/todos/page.tsx
export default async function TodosPage() {
  const todos = await db.todo.findMany()
  return <TodoList todos={todos} />
}

// app/actions.ts
'use server'
export async function addTodo(text: string) {
  await db.todo.create({ data: { text } })
  // revalidation oubliée !
}

Il faut un refresh manuel pour voir la nouvelle tâche.

Pourquoi :

Le cache App Router est agressif : même si la BDD change, la page ne se met pas à jour sans revalidatePath / revalidateTag.

Correction :

// app/actions.ts
'use server'
import { revalidatePath } from 'next/cache'

export async function addTodo(text: string) {
  await db.todo.create({ data: { text } })
  revalidatePath('/todos') // revalider le chemin /todos
}

Astuce :

Plusieurs pages affichent les todos ? revalidateTag :

// app/todos/page.tsx
export default async function TodosPage() {
  const todos = await fetch('http://localhost:3000/api/todos', {
    next: { tags: ['todos'] } // tag de cache
  })
  return <TodoList todos={todos} />
}

// app/actions.ts
'use server'
import { revalidateTag } from 'next/cache'

export async function addTodo(text: string) {
  await db.todo.create({ data: { text } })
  revalidateTag('todos') // revalider toutes les entrées taguées todos
}

À retenir :

Mutation : écriture → revalidatePath / revalidateTag → redirect (optionnel)

Server Components et Client Components

Piège 4 : Context dans un Server Component

Contexte :

ThemeProvider global :

// app/providers.tsx
import { createContext } from 'react'

export const ThemeContext = createContext('light')

export function Providers({ children }) {
  return (
    <ThemeContext.Provider value="dark">
      {children}
    </ThemeContext.Provider>
  )
}

// app/layout.tsx
export default function RootLayout({ children }) {
  return (
    <html>
      <body>
        <Providers>{children}</Providers>
      </body>
    </html>
  )
}

Erreur : You're importing a component that needs createContext. This only works in a Client Component.

Pourquoi :

Les Server Components ne supportent pas React Context (pas d’état client au rendu serveur).

Correction :

Le Provider doit être Client Component, dans un fichier dédié :

// app/providers.tsx
'use client' // marquer comme Client Component

import { createContext, useState } from 'react'

export const ThemeContext = createContext('light')

export function Providers({ children }: { children: React.ReactNode }) {
  const [theme, setTheme] = useState('light')

  return (
    <ThemeContext.Provider value={{ theme, setTheme }}>
      {children}
    </ThemeContext.Provider>
  )
}

// app/layout.tsx (reste Server Component)
import { Providers } from './providers'

export default function RootLayout({ children }) {
  return (
    <html>
      <body>
        <Providers>{children}</Providers>
      </body>
    </html>
  )
}

Erreur classique :

J’avais mis 'use client' sur layout.tsx → toute l’app en Client Component. Seul le Provider en Client ; le layout reste Server.

Piège 5 : mal comprendre le SSR des Client Components

Contexte :

localStorage dans un Client Component — OK en dev, crash en prod : localStorage is not defined.

// app/components/user-info.tsx
'use client'

export default function UserInfo() {
  const user = JSON.parse(localStorage.getItem('user') || '{}')
  return <div>{user.name}</div>
}

Pourquoi :

'use client' ≠ « uniquement navigateur ». Les Client Components sont pré-rendus côté serveur (SSR). localStorage n’existe pas sur le serveur.

Solution 1 : useEffect

'use client'
import { useEffect, useState } from 'react'

export default function UserInfo() {
  const [user, setUser] = useState(null)

  useEffect(() => {
    // useEffect : exécution client uniquement
    const userData = JSON.parse(localStorage.getItem('user') || '{}')
    setUser(userData)
  }, [])

  if (!user) return null
  return <div>{user.name}</div>
}

Solution 2 : garde d’environnement

'use client'

export default function UserInfo() {
  const user = typeof window !== 'undefined'
    ? JSON.parse(localStorage.getItem('user') || '{}')
    : null

  if (!user) return null
  return <div>{user.name}</div>
}

À retenir :

Client Component = interactif côté client, mais aussi SSR. API navigateur (localStorage, window, document) → useEffect ou test typeof window.

Piège 6 : abus de ‘use client’

Contexte :

À chaque erreur, j’ajoutais 'use client'. Résultat : presque tout le projet en Client Components — avantages Server perdus.

Pourquoi :

Parfois c’est un problème d’architecture, pas de besoin client.

Mauvais exemple :

// app/dashboard/page.tsx
'use client' // à éviter ici

import { useState } from 'react'

export default function Dashboard() {
  const [count, setCount] = useState(0)

  return (
    <div>
      <Header /> {/* statique */}
      <Stats /> {/* données serveur */}
      <Counter count={count} setCount={setCount} /> {/* interaction */}
    </div>
  )
}

Toute la page devient client ; Stats part en fetch client.

Bon exemple :

// app/dashboard/page.tsx(Server Component)
import { db } from '@/lib/db'
import { Counter } from './counter'

export default async function Dashboard() {
  const stats = await db.stats.findFirst() // données côté serveur

  return (
    <div>
      <Header /> {/* Server Component */}
      <Stats data={stats} /> {/* Server Component */}
      <Counter /> {/* Client Component */}
    </div>
  )
}

// app/dashboard/counter.tsx
'use client' // seul ce composant est Client

import { useState } from 'react'

export function Counter() {
  const [count, setCount] = useState(0)
  return <button onClick={() => setCount(count + 1)}>{count}</button>
}

Mes trois critères pour 'use client' :

  1. hooks React (useState, useEffect, useContext…)
  2. événements navigateur (onClick, onChange…)
  3. API navigateur (localStorage, window…)

Sinon → restez en Server Component.

Pièges du cache

Piège 7 : Client Router Cache

Contexte :

Édition d’un article sur /posts/1, retour à /posts : le titre reste ancien jusqu’au refresh complet.

Pourquoi :

Le Client Router Cache garde les pages visitées ; même après mise à jour des données, la navigation peut afficher l’ancienne version.

Solution 1 : revalider à la redirection

// app/posts/[id]/edit/page.tsx
'use server'
import { redirect } from 'next/navigation'
import { revalidatePath } from 'next/cache'

export async function updatePost(id: string, title: string) {
  await db.post.update({ where: { id }, data: { title } })

  revalidatePath('/posts') // revalider la liste
  revalidatePath(`/posts/${id}`) // revalider le détail

  redirect('/posts') // retour à la liste
}

Solution 2 : router.refresh()

'use client'
import { useRouter } from 'next/navigation'

export function EditForm() {
  const router = useRouter()

  async function handleSubmit() {
    await updatePost(...)
    router.refresh() // rafraîchir les données de la route
    router.push('/posts')
  }
}

Next.js 15 : le Router Cache client ne cache plus par défaut — sous Next.js 14, gérez-le explicitement.

Piège 8 : revalidate qui ne marche pas

Contexte :

revalidate = 60 sur une page news — en prod, la liste ne change pas de la journée.

// app/news/page.tsx
export const revalidate = 60 // régénération toutes les 60 s

export default async function NewsPage() {
  const news = await fetch('https://api.example.com/news')
  return <NewsList news={news} />
}

Pourquoi :

revalidate ne s’applique qu’en production ; en npm run dev, pas de cache ISR. Et seulement si la page est statique — si elle est dynamique, revalidate est ignoré.

Diagnostic :

  1. Production :
npm run build
npm run start
  1. Type de page : sortie build → ○ Static ou ● SSG. Si λ Dynamic, la page est dynamique.

  2. Causes fréquentes de rendu dynamique :

  • cookies() ou headers()
  • searchParams
  • Route Handler sans revalidate explicite

Correction :

// app/news/page.tsx
export const revalidate = 60

export default async function NewsPage() {
  const news = await fetch('https://api.example.com/news', {
    next: { revalidate: 60 } // revalidate au niveau fetch
  })

  return <NewsList news={news} />
}

Mon habitude :

  • contenu purement statique : generateStaticParams + revalidate
  • paramètres dynamiques : ISR
  • temps réel : dynamic = 'force-dynamic', pas de revalidate

Pièges de gestion d’erreurs

Piège 9 : error.tsx sans ‘use client’

Contexte :

error.tsx créé pour gérer les erreurs → ReactServerComponentsError: Client Component must be used in a Client Component boundary.

// app/error.tsx (incorrect)
export default function Error({ error, reset }) {
  return (
    <div>
      <h2>Erreur !</h2>
      <button onClick={reset}>Réessayer</button>
    </div>
  )
}

Pourquoi :

error.tsx doit être Client Component (Error Boundary React, côté client).

Correction :

// app/error.tsx
'use client' // obligatoire

export default function Error({
  error,
  reset,
}: {
  error: Error & { digest?: string }
  reset: () => void
}) {
  return (
    <div>
      <h2>Erreur !</h2>
      <p>{error.message}</p>
      <button onClick={reset}>Réessayer</button>
    </div>
  )
}

À retenir :

Parmi error.tsx, loading.tsx, not-found.tsx, seul error.tsx exige 'use client'.

Piège 10 : redirect dans try/catch

Contexte :

Server Action : après validation, redirect dans le try — capturé par catch, pas de redirection.

// app/actions.ts (incorrect)
'use server'
import { redirect } from 'next/navigation'

export async function createUser(data: FormData) {
  try {
    const user = await db.user.create({ data })
    redirect(`/users/${user.id}`) // capturé par catch !
  } catch (error) {
    console.error(error)
    return { error: 'Failed to create user' }
  }
}

Pourquoi :

redirect() lance une erreur spéciale que Next.js intercepte. Votre catch l’avale → pas de redirect.

Correction :

// app/actions.ts
'use server'
import { redirect } from 'next/navigation'

export async function createUser(data: FormData) {
  try {
    const user = await db.user.create({ data })
    // pas de redirect ici
    return { success: true, userId: user.id }
  } catch (error) {
    console.error(error)
    return { error: 'Failed to create user' }
  }
}

// redirect à l'appelant
export async function handleSubmit(data: FormData) {
  const result = await createUser(data)
  if (result.success) {
    redirect(`/users/${result.userId}`) // hors try/catch
  }
}

Ou :

'use server'
import { redirect } from 'next/navigation'

export async function createUser(data: FormData) {
  try {
    const user = await db.user.create({ data })
  } catch (error) {
    console.error(error)
    return { error: 'Failed to create user' }
  }

  redirect(`/users/${user.id}`) // après try/catch
}

Pièges à la migration

Piège 11 : 404.js et 500.js obsolètes

Contexte :

Migration en gardant pages/404.js et pages/500.js — pages jamais affichées.

Pourquoi :

App Router change tout :

  • 404.jsnot-found.tsx
  • 500.jserror.tsx
  • erreur racine → global-error.tsx

Correction :

// app/not-found.tsx
export default function NotFound() {
  return (
    <div>
      <h2>404 - Page introuvable</h2>
      <Link href="/">Retour à l'accueil</Link>
    </div>
  )
}

// app/error.tsx
'use client'

export default function Error({ error, reset }) {
  return (
    <div>
      <h2>500 - Erreur serveur</h2>
      <p>{error.message}</p>
      <button onClick={reset}>Réessayer</button>
    </div>
  )
}

// app/global-error.tsx (erreurs du layout racine)
'use client'

export default function GlobalError({ error, reset }) {
  return (
    <html>
      <body>
        <h2>Erreur globale</h2>
        <p>{error.message}</p>
        <button onClick={reset}>Réessayer</button>
      </body>
    </html>
  )
}

Piège 12 : next-seo incompatible

Contexte :

Projet basé sur next-seo — plus d’effet après migration App Router.

// pages/blog/[slug].tsx (ère Pages Router)
import { NextSeo } from 'next-seo'

export default function BlogPost({ post }) {
  return (
    <>
      <NextSeo
        title={post.title}
        description={post.excerpt}
        openGraph={{
          title: post.title,
          description: post.excerpt,
          images: [{ url: post.coverImage }],
        }}
      />
      <article>{post.content}</article>
    </>
  )
}

Pourquoi :

App Router fournit generateMetadata ; next-seo n’est plus recommandé.

Migration :

// app/blog/[slug]/page.tsx
import { Metadata } from 'next'

export async function generateMetadata({ params }): Promise<Metadata> {
  const post = await getPost(params.slug)

  return {
    title: post.title,
    description: post.excerpt,
    openGraph: {
      title: post.title,
      description: post.excerpt,
      images: [post.coverImage],
    },
  }
}

export default async function BlogPost({ params }) {
  const post = await getPost(params.slug)
  return <article>{post.content}</article>
}

Avantages : typage TypeScript, async/await natif, meilleures perfs SSR.

Conseils de performance

Éviter les Client Components inutiles

Problème : toute la page en Client → perte des Server Components.

Stratégie « feuilles client » :

// ❌ mauvaise approche
// app/dashboard/page.tsx
'use client'
export default function Dashboard() {
  return (
    <div>
      <Header />
      <Sidebar />
      <MainContent />
      <Footer />
    </div>
  )
}

// ✅ bonne approche
// app/dashboard/page.tsx(Server Component)
import { Header } from './header'
import { Sidebar } from './sidebar'
import { MainContent } from './main-content'
import { Footer } from './footer'

export default function Dashboard() {
  return (
    <div>
      <Header /> {/* Server Component */}
      <Sidebar /> {/* Client Component (interaction) */}
      <MainContent /> {/* Server Component */}
      <Footer /> {/* Server Component */}
    </div>
  )
}

// app/dashboard/sidebar.tsx
'use client' // seul composant Client ici
export function Sidebar() {
  const [collapsed, setCollapsed] = useState(false)
  return <aside>...</aside>
}

Optimiser les frontières Suspense

Problème : toute la page attend les données lentes → long écran blanc.

Solution :

// app/dashboard/page.tsx
import { Suspense } from 'react'
import { FastComponent } from './fast'
import { SlowComponent } from './slow'

export default function Dashboard() {
  return (
    <div>
      {/* données rapides tout de suite */}
      <FastComponent />

      {/* skeleton pour données lentes */}
      <Suspense fallback={<div>Chargement...</div>}>
        <SlowComponent />
      </Suspense>
    </div>
  )
}

Fetch en parallèle

Problème : séquentiel → temps total = somme des latences.

Solution :

// ❌ séquentiel (lent)
export default async function Page() {
  const user = await getUser() // 100ms
  const posts = await getPosts() // 200ms
  const comments = await getComments() // 150ms
  // total : 450ms
}

// ✅ parallèle (rapide)
export default async function Page() {
  const [user, posts, comments] = await Promise.all([
    getUser(),
    getPosts(),
    getComments(),
  ])
  // total : 200ms (le plus lent)
}

Pièges en développement

Piège 13 : fuites de connexion au hot reload

Contexte :

Après un moment en dev : too many connections sur la BDD.

Pourquoi :

Le hot reload réexécute les modules ; une connexion BDD créée au top-level en recrée une à chaque reload sans fermer l’ancienne.

Solution :

// lib/db.ts
import { PrismaClient } from '@prisma/client'

const globalForPrisma = global as unknown as { prisma: PrismaClient }

export const prisma =
  globalForPrisma.prisma ||
  new PrismaClient({
    log: ['query'],
  })

if (process.env.NODE_ENV !== 'production') {
  globalForPrisma.prisma = prisma
}

En dev, une seule instance Prisma réutilisée.

Piège 14 : serveur de dev qui ralentit

Contexte :

npm run dev après 30 min : HMR très lent, parfois blocage.

Pourquoi :

L’App Router en dev consomme beaucoup de mémoire avec beaucoup de routes dynamiques.

Contournements :

  1. redémarrer le serveur
  2. réduire le file watching :
// next.config.js
module.exports = {
  webpack: (config) => {
    config.watchOptions = {
      poll: 1000, // polling moins fréquent
      aggregateTimeout: 300,
      ignored: /node_modules/,
    }
    return config
  },
}

Long terme : Next.js 15 + Turbopack :

npm run dev --turbo

HMR bien plus rapide sur les gros projets.

Synthèse : checklist anti-pièges

Checklist rapide avant un nouveau projet — évite 90 % des problèmes :

Récupération de données

  • ☑ données via Server Component plutôt que Client + useEffect
  • ☑ Route Handler : dynamic = 'force-dynamic' ou revalidate explicite
  • ☑ après mutation : revalidatePath / revalidateTag

Server / Client Components

  • ☑ Provider en Client ; layout en Server
  • ☑ API navigateur dans useEffect ou garde window
  • 'use client' seulement sur les composants interactifs

Gestion d’erreurs

  • error.tsx avec 'use client'
  • redirect hors du try/catch
  • ☑ erreur racine layout → global-error.tsx

Cache

  • ☑ Next.js 15 pour des défauts plus raisonnables
  • revalidate testé en production uniquement
  • ☑ pages dynamiques : dynamic = 'force-dynamic', pas revalidate

Migration

  • 404.jsnot-found.tsx, 500.jserror.tsx
  • next-seogenerateMetadata
  • getServerSideProps → fetch Server Component
  • useRouter : next/routernext/navigation

Performance

  • ☑ Suspense pour séparer rapide / lent
  • Promise.all pour paralléliser
  • ☑ singleton BDD en dev
  • ☑ Turbopack (npm run dev --turbo)

Pour finir

L’App Router a une courbe d’apprentissage ; les premiers pièges sont normaux. Une fois les réflexes acquis, la productivité monte nettement.

Mes habitudes :

  1. flux de données d’abord : rendu serveur ou interaction client ?
  2. sortie de build : Static ou Dynamic — pourquoi ?
  3. DevTools : Network (nombre de requêtes), Console (stack)
  4. pas de défauts implicites : cache, rendu, revalidation — tout explicite

Ne vous laissez pas décourager : testez, chaque piège une fois suffit. La doc Next.js couvre la plupart des cas.

Si cet article vous a aidé, partagez-le. D’autres pièges en commentaire — je mettrai la liste à jour.

Bonne route sur l’App Router : moins de faux pas, plus de code élégant !

FAQ

Comment distinguer Server Component et Client Component ?
Server Component (par défaut) :
• s'exécute côté serveur, n'est pas envoyé au client
• ne peut pas utiliser useState, useEffect, etc.
• ne peut pas utiliser les API navigateur

Client Component (à marquer) :
• directive 'use client'
• peut utiliser tous les hooks React
• peut utiliser les API navigateur

Règle : interaction ou API navigateur → Client Component.
Pourquoi les données ne se mettent-elles pas à jour ?
fetch est mis en cache par défaut dans Next.js.

Solutions :
• cache: 'no-store' (données fraîches à chaque requête)
• next: { revalidate: 60 } (revalidation après 60 s)
• router.refresh() dans un Client Component

Vérification : sortie du build — page Dynamic ou Static ?
La page tourne en boucle, que faire ?
Causes possibles :
• Server Component async sans état de chargement
• frontière Suspense mal configurée
• échec de fetch sans gestion d'erreur

Solutions :
• ajouter loading.tsx
• envelopper les composants async avec Suspense
• ajouter error.tsx
error.tsx ne fonctionne pas ?
error.tsx doit être un Client Component.

Ajoutez 'use client' :
'use client'

export default function Error({ error, reset }) {
return <div>Erreur : {error.message}</div>
}

Note : error.tsx ne capture que les erreurs des composants enfants, pas la sienne.
Comment migrer de Pages Router vers App Router ?
Changements principaux :
• getServerSideProps → Server Component async
• getStaticProps → génération statique (par défaut)
• next/router → next/navigation
• _app.js → layout.tsx
• _document.js → plus nécessaire (layout.tsx)

Conseil : pilotez 1-2 pages, puis généralisez.
Comment comprendre le cache ?
Niveaux de cache Next.js :
• Request Memoization : même fetch une fois par requête
• Data Cache : réponses fetch mises en cache
• Full Route Cache : page entière (statique)
• Router Cache : cache de navigation client

Contrôle : cache: 'no-store', next: { revalidate }, etc.
Comment déboguer l'App Router ?
Méthodes :
• npm run build — type de page
• DevTools Network — requêtes
• Console — erreurs
• sortie terminal Next.js

Problèmes fréquents :
• Static au lieu de Dynamic → config fetch/cache
• données figées → cache et revalidate
• chargement infini → loading.tsx et Suspense

14 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