Pourquoi j’ai migré (et pourquoi vous devriez réfléchir à deux fois)
En septembre 2024, j’ai migré une application SaaS Next.js 13 (Pages Router) vers le App Router pour un client fintech. 42,000 lignes de code, 340 routes, 18 développeurs dans l’équipe.
Temps estimé : 3 semaines
Temps réel : 7 semaines
Bugs critiques en production : 2 (avec downtime)
Voici tout ce qui n’est pas dans la documentation officielle.
Les promesses vs la réalité
✅ Ce qui marche vraiment bien
Server Components par défaut
- Bundles JS client réduits de 40%
- Temps de chargement initial : -28%
- SEO amélioré (vraiment)
Layouts imbriqués
- Code plus DRY
- Moins de prop drilling
- UX fluide entre pages
Streaming & Suspense natif
- Progressive rendering qui fonctionne
- Meilleure perception de rapidité
❌ Ce qui est cassé (ou très différent)
Le caching est un cauchemar
- Comportement non-intuitif
- Invalidation manuelle nécessaire partout
- Cache qui persiste entre builds (!!)
use client partout
- Dès que vous utilisez
useState,useEffect, event handlers - Contagieux : un composant client rend tous ses enfants clients
- Perte des bénéfices Server Components
Middleware limité
- Pas d’accès aux requêtes/réponses dans les layouts
- Auth complexe à mettre en place
- Redirections moins évidentes
Les pièges concrets (avec solutions)
Piège #1 : Data Fetching déroutant
Dans Pages Router (l’ancien)
// pages/dashboard.tsx
export async function getServerSideProps() {"{"}
const data = await fetchUserData();
return {"{"} props: {"{"} data } };
}
Dans App Router (le nouveau)
// app/dashboard/page.tsx
async function Dashboard() {"{"}
const data = await fetchUserData(); // Direct dans le composant
return <div>{"{"}data}</div>;
}
Le problème : ce fetch se rejoue à chaque rendu. En dev, ça veut dire 2-3 fois par chargement.
Solution : caching explicite
import {"{"} cache } from 'react';
const getUserData = cache(async (userId: string) => {"{"}
return await db.user.findUnique({"{"} where: {"{"} id: userId } });
});
// Maintenant, multiples appels = 1 seule requête DB
Piège #2 : Le caching automatique trop agressif
Scenario réel : page de dashboard qui affiche le solde utilisateur.
// app/dashboard/page.tsx
async function Dashboard() {"{"}
const balance = await fetch('https://api.bank.com/balance');
return <div>Solde : {"{"}balance}</div>;
}
Problème : Next.js cache cette réponse indéfiniment par défaut. Le solde ne se met jamais à jour.
Solution : opt-out explicite
const balance = await fetch('https://api.bank.com/balance', {"{"}
cache: 'no-store' // Désactive complètement le cache
});
// OU avec revalidation
const balance = await fetch('https://api.bank.com/balance', {"{"}
next: {"{"} revalidate: 60 } // Revalide toutes les 60s
});
Mon conseil : cache: 'no-store' par défaut, optimiser ensuite.
Piège #3 : useRouter n’est plus le même
// Pages Router
import {"{"} useRouter } from 'next/router';
const router = useRouter();
const {"{"} id } = router.query; // ✅ Fonctionne
// App Router
import {"{"} useRouter } from 'next/navigation'; // ⚠️ Import différent !
const router = useRouter();
const {"{"} id } = router.query; // ❌ ERREUR : query n'existe pas
// ✅ Solution App Router
import {"{"} useParams } from 'next/navigation';
const params = useParams();
const id = params.id;
Piège #4 : Les formulaires server actions
La promesse : plus besoin d’API routes pour les mutations.
// app/actions/createUser.ts
'use server';
export async function createUser(formData: FormData) {"{"}
const name = formData.get('name');
await db.user.create({"{"} data: {"{"} name } });
}
// app/form/page.tsx
import {"{"} createUser } from './actions/createUser';
export default function Form() {"{"}
return (
<form action={"{"}createUser}>
<input name="name" />
<button>Submit</button>
</form>
);
}
Le problème : gestion d’erreurs complexe, loading states manuels, pas de typage fort FormData → DB.
Pattern qui marche réellement
'use server';
import {"{"} z } from 'zod';
const schema = z.object({"{"}
name: z.string().min(2),
email: z.string().email(),
});
export async function createUser(prevState: any, formData: FormData) {"{"}
// Validation
const validatedFields = schema.safeParse({"{"}
name: formData.get('name'),
email: formData.get('email'),
});
if (!validatedFields.success) {"{"}
return {"{"}
errors: validatedFields.error.flatten().fieldErrors,
};
}
try {"{"}
await db.user.create({"{"} data: validatedFields.data });
return {"{"} success: true };
} catch (error) {"{"}
return {"{"} error: 'Database error' };
}
}
Côté client :
'use client';
import {"{"} useFormState, useFormStatus } from 'react-dom';
import {"{"} createUser } from './actions';
function SubmitButton() {"{"}
const {"{"} pending } = useFormStatus();
return (
<button disabled={"{"}pending}>
{"{"}pending ? 'Envoi...' : 'Envoyer'}
</button>
);
}
export default function Form() {"{"}
const [state, formAction] = useFormState(createUser, null);
return (
<form action={"{"}formAction}>
<input name="name" />
{"{"}state?.errors?.name && <p>{"{"}state.errors.name}</p>}
<input name="email" />
{"{"}state?.errors?.email && <p>{"{"}state.errors.email}</p>}
<SubmitButton />
</form>
);
}
Oui, c’est verbeux. Non, ce n’est pas plus simple qu’une API route classique.
Piège #5 : Metadata dynamique mal documentée
Pages Router
export const getServerSideProps = () => {"{"}
return {"{"}
props: {"{"}
title: userTitle,
description: userBio,
}
};
};
App Router
// ❌ Ne fonctionne PAS
export const metadata = {"{"}
title: await getUserTitle(), // ERREUR : pas d'async ici
};
// ✅ Solution
export async function generateMetadata({"{"} params }) {"{"}
const user = await getUser(params.id);
return {"{"}
title: user.name,
description: user.bio,
openGraph: {"{"}
images: [user.avatar],
},
};
}
Piège #6 : Auth (NextAuth / Auth.js)
NextAuth v4 sur App Router est bancal. Vraiment.
Ce qui ne marche pas bien
- Middleware auth qui bloque toutes les requêtes (perf -)
- Session pas accessible dans Server Components sans contorsions
- Types TypeScript approximatifs
Solution temporaire
// middleware.ts
export {"{"} default } from 'next-auth/middleware';
export const config = {"{"}
matcher: ['/dashboard/:path*', '/api/:path*'],
};
// app/dashboard/layout.tsx
import {"{"} getServerSession } from 'next-auth';
import {"{"} authOptions } from '@/lib/auth';
export default async function DashboardLayout({"{"} children }) {"{"}
const session = await getServerSession(authOptions);
if (!session) {"{"}
redirect('/login');
}
return <>{"{"}children}</>;
}
Mieux : attendre NextAuth v5 (en beta depuis 8 mois…) ou passer à Clerk/Supabase.
Patterns qui marchent en production
Pattern #1 : Layouts pour l’auth
app/
(auth)/
login/
page.tsx
register/
page.tsx
(dashboard)/
layout.tsx ← Auth check ici
page.tsx
settings/
page.tsx
Le layout.tsx du groupe (dashboard) :
import {"{"} redirect } from 'next/navigation';
import {"{"} getUser } from '@/lib/auth';
export default async function DashboardLayout({"{"} children }) {"{"}
const user = await getUser();
if (!user) redirect('/login');
return <>{"{"}children}</>;
}
Pattern #2 : Loading UI progressif
// app/dashboard/loading.tsx
export default function Loading() {"{"}
return <DashboardSkeleton />;
}
// app/dashboard/page.tsx
import {"{"} Suspense } from 'react';
export default function Dashboard() {"{"}
return (
<>
<Header /> {"{"}/* Instant */}
<Suspense fallback={"{"}<ChartsSkeleton />}>
<Charts /> {"{"}/* Fetches data */}
</Suspense>
<Suspense fallback={"{"}<TableSkeleton />}>
<DataTable /> {"{"}/* Fetches different data */}
</Suspense>
</>
);
}
Pattern #3 : Parallel Routes pour dashboards
app/
dashboard/
@analytics/
page.tsx
@notifications/
page.tsx
layout.tsx
page.tsx
// layout.tsx
export default function Layout({"{"}
children,
analytics,
notifications,
}) {"{"}
return (
<div className="grid">
<aside>{"{"}notifications}</aside>
<main>{"{"}children}</main>
<section>{"{"}analytics}</section>
</div>
);
}
Charge les 3 zones en parallèle. Vraiment puissant.
Quand migrer (et quand ne PAS migrer)
✅ Migrez si :
- Nouveau projet (évidemment)
- App content-heavy (blog, docs, marketing)
- Vous maîtrisez React Server Components
- Équipe > 3 dev (ROI sur la structure)
❌ Ne migrez PAS si :
- App hautement interactive (admin, dashboards temps réel)
- Nombreuses libs qui nécessitent
window/document - Deadline serrée (multiply estimations ×2.5)
- NextAuth et auth complexe (attendez v5)
Checklist migration
[ ] Auditer les dépendances incompatibles
[ ] Identifier tous les useEffect/useState
[ ] Mapper les getServerSideProps → Server Components
[ ] Prévoir opt-out du cache partout
[ ] Tester 3x plus que d'habitude
[ ] Avoir un plan de rollback
[ ] Migrer route par route (pas tout d'un coup)
Verdict après 2 mois en production
Performance : +25% de score Lighthouse
Developer Experience : -15% (courbe d’apprentissage)
Bugs : +40% les 3 premières semaines, puis -10%
Regrets : 0 (mais c’était proche)
Le App Router est l’avenir de Next.js, mais il est encore jeune. Si vous migrez, prévoyez du temps et des tests approfondis.
Besoin d’aide pour migrer votre app Next.js ou former votre équipe ? Contactez-moi pour un accompagnement technique sur mesure.