

Server Components, streaming, layouts imbriqués, Server Actions, parallel routes : ce qui marche vraiment et ce qui pique en production sur une plateforme fintech.
Six mois de production sur Next.js 14 avec l'App Router et les Server Components. Ce qui a marché, ce qui a fait mal, ce que je referais et ce que je ne referais plus.
Sur Nexus (plateforme fintech), j'ai migré une stack Pages Router de Next.js 13 vers App Router en 14. Les promesses : Server Components, streaming, layouts imbriqués, data fetching simplifié, parallèles routes. Six mois plus tard, le bilan est nuancé. Énormément de valeur, mais aussi quelques pièges qui coûtent cher en production.
Cet article condense les apprentissages. Pas de "Next.js c'est génial" générique : ce qui fonctionne vraiment, ce qui dérape, et comment s'en sortir.
L'avantage est énorme. Avant, on avait un _app.tsx global et des composants Layout partagés à la main. Maintenant chaque segment a son layout.tsx, qui ne re-render pas quand on navigue dans ses enfants.
| 1 | // app/dashboard/layout.tsx |
| 2 | export default function DashboardLayout({ children }: { children: React.ReactNode }) { |
| 3 | return ( |
| 4 | <div className="dashboard"> |
| 5 | <DashboardSidebar /> |
| 6 | <main>{children}</main> |
| 7 | </div> |
| 8 | ); |
| 9 | } |
Le sidebar reste statique entre les routes /dashboard/portfolio et /dashboard/transactions. Gain perçu énorme côté utilisateur.
Pouvoir streamer une page section par section change le ressenti de chargement. Le hero s'affiche immédiatement, les blocs de données arrivent progressivement. Sur Nexus, le LCP a chuté de 3,2 s à 1,4 s en moyenne juste avec ça.
| 1 | // app/dashboard/page.tsx |
| 2 | export default async function DashboardPage() { |
| 3 | return ( |
| 4 | <> |
| 5 | <PortfolioHeader /> |
| 6 | <Suspense fallback={<TransactionsSkeleton />}> |
| 7 | <RecentTransactions /> |
| 8 | </Suspense> |
| 9 | <Suspense fallback={<ChartSkeleton />}> |
| 10 | <MarketChart /> |
| 11 | </Suspense> |
| 12 | </> |
| 13 | ); |
| 14 | } |
La métadonnée par page est devenue trivial. Plus de next/head à orchestrer manuellement.
| 1 | // app/projects/[slug]/page.tsx |
| 2 | export async function generateMetadata({ params }: { params: { slug: string } }): Promise<Metadata> { |
| 3 | const project = await fetchProject(params.slug); |
| 4 | return { |
| 5 | title: `${project.title} — Portfolio`, |
| 6 | description: project.excerpt, |
| 7 | openGraph: { images: [project.image] }, |
| 8 | }; |
| 9 | } |
Si ce sujet ressemble à un problème réel dans votre produit, je peux intervenir sur le diagnostic, l'architecture, le backend, l'interface et les automatisations qui rendent une plateforme exploitable.
"use client" au sommet d'un fichier marque tout l'arbre comme client. Une lib qui dépend de window importée transitivement casse silencieusement. Le premier mois j'ai eu plusieurs builds qui passaient en dev et plantaient en prod.
La discipline : isoler les composants client dans leurs propres fichiers, garder les pages et layouts en server par défaut.
| 1 | // app/dashboard/page.tsx — Server Component |
| 2 | import { InteractiveChart } from "@/components/InteractiveChart"; |
| 3 | |
| 4 | export default async function DashboardPage() { |
| 5 | const data = await fetchData(); // serveur |
| 6 | return <InteractiveChart data={data} />; // composant client, fichier séparé |
| 7 | } |
| 8 | |
| 9 | // components/InteractiveChart.tsx |
| 10 | "use client"; |
| 11 | import { useState } from "react"; |
| 12 | // ... composant interactif |
fetchPar défaut, fetch dans un Server Component est mis en cache "force-cache". Personne ne s'y attend la première fois. Sur Nexus, le dashboard affichait un solde gelé pendant 60 minutes.
| 1 | // ❌ Mauvais — solde figé à la première requête |
| 2 | const balance = await fetch("/api/balance").then(r => r.json()); |
| 3 | |
| 4 | // ✅ Bon — refetch à chaque requête |
| 5 | const balance = await fetch("/api/balance", { cache: "no-store" }).then(r => r.json()); |
| 6 | |
| 7 | // ✅ Aussi bon — revalidate régulier |
| 8 | const balance = await fetch("/api/balance", { next: { revalidate: 30 } }).then(r => r.json()); |
Règle de pouce : pour toute donnée utilisateur ou financière, cache: "no-store". Pour les contenus marketing/blog : revalidate selon la fraîcheur acceptable.
Quand un Server Component renvoie du contenu différent de ce que le client recalcule (typiquement à cause d'un Date.now() ou d'un localStorage), on a un mismatch d'hydratation. Erreur console + flash visuel.
Patterns qui ont marché :
window ou localStorage → useEffect après mount, jamais en rendu initialLes routes parallèles (@modal, @notifications) sont une bombe d'expressivité. Mais la doc est mince et les patterns peu documentés. J'ai mis du temps à comprendre comment intercepter une route pour afficher une modale au-dessus d'une page sans perdre l'URL.
| 1 | app/ |
| 2 | layout.tsx |
| 3 | page.tsx ← page d'accueil |
| 4 | @modal/ |
| 5 | default.tsx ← rien par défaut |
| 6 | (.)projects/[slug]/page.tsx ← intercepté : ouvre une modale |
| 7 | projects/ |
| 8 | [slug]/page.tsx ← navigation directe : page complète |
Quand on clique sur une carte projet depuis la home, ça ouvre une modale (route interceptée). Quand on partage l'URL, ça ouvre la page complète. Le même routeur sert les deux. Gain UX énorme une fois maîtrisé.
Pouvoir écrire une mutation côté serveur sans construire d'API REST, c'est ce qui m'a fait le plus gagner de temps.
| 1 | // app/(auth)/login/actions.ts |
| 2 | "use server"; |
| 3 | import { redirect } from "next/navigation"; |
| 4 | |
| 5 | export async function loginAction(formData: FormData) { |
| 6 | const email = formData.get("email") as string; |
| 7 | const password = formData.get("password") as string; |
| 8 | |
| 9 | const session = await authService.login(email, password); |
| 10 | if (!session) { |
| 11 | return { error: "Identifiants invalides" }; |
| 12 | } |
| 13 | |
| 14 | cookies().set("session", session.token, { httpOnly: true, secure: true }); |
| 15 | redirect("/dashboard"); |
| 16 | } |
| 17 | |
| 18 | // app/(auth)/login/page.tsx |
| 19 | import { loginAction } from "./actions"; |
| 20 | |
| 21 | export default function LoginPage() { |
| 22 | return ( |
| 23 | <form action={loginAction}> |
| 24 | <input name="email" type="email" required /> |
| 25 | <input name="password" type="password" required /> |
| 26 | <button type="submit">Connexion</button> |
| 27 | </form> |
| 28 | ); |
| 29 | } |
Avantages :
Limites :
Le marketing parle de "JavaScript bundle minimal". C'est vrai et c'est faux.
Vrai : un Server Component pur ne shippe aucun JS. Faux : dès qu'on a des composants client (formulaires, interactions), le bundle grandit. Sur Nexus, après 4 mois, le bundle JS first-party était à 280 ko gzippé — plus que ce qu'on avait avec Pages Router et un peu d'optimisation manuelle.
La leçon : Server Components réduit le JS par défaut, mais ne dispense pas de surveiller le bundle. next-bundle-analyzer reste indispensable.
Next.js 14 sur Vercel : tout marche. ISR, Edge Functions, streaming, cache distribué.
Next.js 14 self-hosted : la moitié des features demande un setup spécifique. Pour Nexus qui tourne sur infrastructure dédiée (souveraineté des données fintech) :
revalidate : OK avec un cache filesystem partagé entre instancesX-Accel-Buffering: no)Le coût "self-hosted" est sous-estimé dans la doc officielle. Compter 1 à 2 semaines de setup et de tuning si vous ne voulez pas Vercel.
Pour un blog ou un site contenu, Server Components + ISR est imbattable. Pour Nexus blog (sous-domaine, ~200 articles), la migration depuis Gatsby a divisé le LCP par 2 et amélioré les Core Web Vitals.
Quelques patterns :
generateStaticParams pour pré-générer les slugs au buildrevalidate: 3600 pour rafraîchir l'heure passéeapp/sitemap.tsapp/feed.xml/route.ts| 1 | // app/blog/[slug]/page.tsx |
| 2 | export async function generateStaticParams() { |
| 3 | const posts = await fetchAllSlugs(); |
| 4 | return posts.map((slug) => ({ slug })); |
| 5 | } |
| 6 | |
| 7 | export const revalidate = 3600; |
getServerSideProps → Server ComponentMécanique mais souvent piégeux. La signature change, le pattern de redirection aussi.
| 1 | // Avant (Pages Router) |
| 2 | export async function getServerSideProps(ctx) { |
| 3 | const session = await getSession(ctx); |
| 4 | if (!session) return { redirect: { destination: "/login", permanent: false } }; |
| 5 | return { props: { user: session.user } }; |
| 6 | } |
| 7 | |
| 8 | // Après (App Router) |
| 9 | export default async function Page() { |
| 10 | const session = await getSession(); |
| 11 | if (!session) redirect("/login"); |
| 12 | return <Dashboard user={session.user} />; |
| 13 | } |
next/router → next/navigationLes hooks changent : useRouter, usePathname, useSearchParams au lieu d'un seul useRouter. Refactor systématique mais simple.
pages/api/foo.ts devient app/api/foo/route.ts avec des fonctions exportées GET, POST, etc. La signature change, le typage devient plus strict.
| 1 | // app/api/users/route.ts |
| 2 | import { NextResponse } from "next/server"; |
| 3 | |
| 4 | export async function GET() { |
| 5 | const users = await db.user.findMany(); |
| 6 | return NextResponse.json(users); |
| 7 | } |
| 8 | |
| 9 | export async function POST(req: Request) { |
| 10 | const body = await req.json(); |
| 11 | const created = await db.user.create({ data: body }); |
| 12 | return NextResponse.json(created, { status: 201 }); |
| 13 | } |
| Piège | Symptôme | Correction |
|---|---|---|
"use client" trop large | Bundle JS gonfle | Isoler les composants client dans des fichiers dédiés |
| Cache fetch implicite | Données figées | cache: "no-store" ou revalidate |
| Hydratation mismatch | Console warnings + flash | Calculs runtime via useEffect |
| ISR sur données utilisateur | Données d'un user montrées à un autre | Jamais d'ISR sur du contenu personnalisé |
| Self-hosted sans tuning | Streaming buffer, ISR cassée | Config Nginx + cache filesystem partagé |
| Server Actions sans validation | Risque bypass client | Zod ou validateur côté serveur systématique |
| Routes parallèles non documentées | Équipe perdue | README + diagrammes explicites |
App Router est une vraie avancée pour les apps contenu et les portails à structure claire. Pour les apps très interactives, le gain est modéré et les pièges réels.
Si vous démarrez aujourd'hui : foncez sur App Router. Si vous avez une grosse base Pages Router : migrez section par section, jamais en big bang.
L'investissement de 4 à 8 semaines de montée en compétence sur les patterns Server Components paie ensuite chaque mois. Mais il faut accepter la courbe d'apprentissage.
Réactions des lecteurs
Aucun commentaire pour l'instant
Soyez le premier à réagir à cet article.