← Retour au blog

Next.js 14 App Router : ce que personne ne te dit sur la migration

Les pièges réels de la migration vers le App Router, les patterns qui fonctionnent et ceux qui cassent en production.

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

Layouts imbriqués

Streaming & Suspense natif

❌ Ce qui est cassé (ou très différent)

Le caching est un cauchemar

use client partout

Middleware limité

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

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 :

❌ Ne migrez PAS si :

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.