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

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) →useEffectou testtypeof 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' :
- hooks React (useState, useEffect, useContext…)
- événements navigateur (onClick, onChange…)
- 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 :
- Production :
npm run build
npm run start
-
Type de page : sortie build →
○ Staticou● SSG. Siλ Dynamic, la page est dynamique. -
Causes fréquentes de rendu dynamique :
cookies()ouheaders()searchParams- Route Handler sans
revalidateexplicite
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, seulerror.tsxexige'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.js→not-found.tsx500.js→error.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 :
- redémarrer le serveur
- 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'ourevalidateexplicite - ☑ après mutation :
revalidatePath/revalidateTag
Server / Client Components
- ☑ Provider en Client ; layout en Server
- ☑ API navigateur dans
useEffectou gardewindow - ☑
'use client'seulement sur les composants interactifs
Gestion d’erreurs
- ☑
error.tsxavec'use client' - ☑
redirecthors dutry/catch - ☑ erreur racine layout →
global-error.tsx
Cache
- ☑ Next.js 15 pour des défauts plus raisonnables
- ☑
revalidatetesté en production uniquement - ☑ pages dynamiques :
dynamic = 'force-dynamic', pas revalidate
Migration
- ☑
404.js→not-found.tsx,500.js→error.tsx - ☑
next-seo→generateMetadata - ☑
getServerSideProps→ fetch Server Component - ☑
useRouter:next/router→next/navigation
Performance
- ☑ Suspense pour séparer rapide / lent
- ☑
Promise.allpour 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 :
- flux de données d’abord : rendu serveur ou interaction client ?
- sortie de build : Static ou Dynamic — pourquoi ?
- DevTools : Network (nombre de requêtes), Console (stack)
- 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 ?
• 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 ?
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 ?
• 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 ?
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 ?
• 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 ?
• 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 ?
• 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
Guide complet Next.js
Si vous arrivez depuis la recherche, le plus rapide est de passer à l’article précédent ou suivant de cette série.
Précédent
Routes dynamiques Next.js et paramètres : guide complet de l'initiation au typage
Maîtrisez pas à pas le système de routes dynamiques de Next.js 14+. Paramètres dynamiques, routes catch-all, paramètres optionnels, quand utiliser generateStaticParams et pratiques TypeScript pour un typage sûr. Levez la confusion sur la récupération des paramètres, avec de nombreux exemples de code.
Partie 6 sur 51
Suivant
Guide complet de récupération de données dans les Server Components Next.js : fetch, requêtes BDD et bonnes pratiques
Guide complet pour récupérer des données dans les Server Components Next.js : choix fetch vs requête BDD, syntaxe async/await, stratégies de cache et bonnes pratiques de gestion d'erreurs pour éviter les pièges courants.
Partie 8 sur 51



Commentaires
Connectez-vous avec GitHub pour laisser un commentaire