Next.js 15 App Router : Guide de Migration Complet 2026 | Hulli Studio
Next.js 15 marque un tournant historique : l'App Router devient la norme, Turbopack est en stable, et les React Server Components transforment radicalement l'architecture des applications React.
Si votre projet tourne encore sur le Pages Router (Next.js 12 ou antérieur), ce guide vous accompagne dans une migration progressive, sécurisée et optimisée vers Next.js 15.
Pourquoi migrer vers l'App Router ?
Les gains mesurables
Performance :
- Build time : -70% avec Turbopack (15s → 4s sur nos projets)
- HMR (Hot Module Replacement) : 10x plus rapide (2s → 200ms)
- JavaScript bundle : -60% grâce aux Server Components
- Time to Interactive : -50% en moyenne
Developer Experience :
- Layouts partagés : Plus de duplication de code entre pages
- Loading states : UI natives avec
loading.tsx - Error handling : Gestion d'erreurs avec
error.tsx - Streaming : Rendu progressif avec Suspense
- Server Actions : Mutations sans API routes
SEO & Performance :
- Metadata API : SEO dynamique simplifié
- Parallel Routes : Modales, dashboards complexes
- Intercepting Routes : UX moderne sans rechargement
- Streaming SSR : TTFB réduit de 40%
Pages Router vs App Router : Comparaison technique
| Aspect | Pages Router | App Router |
|---|---|---|
| Routing | Basé fichiers (pages/) |
Basé dossiers (app/) |
| Data fetching | getServerSideProps, getStaticProps |
Server Components async |
| Layouts | _app.tsx, _document.tsx |
layout.tsx imbriqués |
| API routes | pages/api/ |
app/api/route.ts + Server Actions |
| Metadata | <Head> dans chaque page |
metadata export + generateMetadata() |
| Loading states | Manuel avec useState | loading.tsx automatique |
| Error handling | _error.tsx global |
error.tsx par route |
| Streaming | ❌ Non supporté | ✅ Natif avec Suspense |
| Server Components | ❌ Non supporté | ✅ Par défaut |
| Turbopack | ⚠️ Expérimental | ✅ Stable |
Stratégie de migration : Progressive vs Big Bang
Option 1 : Migration progressive (Recommandée)
Next.js 13+ supporte les deux routers simultanément :
my-app/
├── app/ # Nouvelles routes (App Router)
│ ├── layout.tsx
│ └── dashboard/ # Route migrée
│ └── page.tsx
└── pages/ # Anciennes routes (Pages Router)
├── _app.tsx
├── index.tsx # Homepage (pas encore migrée)
└── blog/ # Blog (pas encore migré)
└── [slug].tsx
Avantages :
- ✅ Migration sans stress, route par route
- ✅ Tests en production sur des routes isolées
- ✅ Équipe formée progressivement
- ✅ Rollback facile si problème
Inconvénients :
- ⚠️ Double configuration (
_app.tsx+layout.tsx) - ⚠️ Bundle size légèrement plus lourd temporairement
Option 2 : Big Bang (Projets < 20 pages)
Migration complète en une fois.
Avantages :
- ✅ Clean architecture immédiate
- ✅ Pas de duplication de configuration
Inconvénients :
- ❌ Risque élevé sur gros projets
- ❌ Testing complet nécessaire avant déploiement
Recommandation Hulli Studio :
Pour les projets < 20 pages : Big Bang le weekend
Pour les projets > 20 pages : Migration progressive (1-2 routes/semaine)
Migration étape par étape
Étape 1 : Mise à jour Next.js 15
# Mise à jour des dépendances
npm install next@latest react@latest react-dom@latest
# Vérifier la version
npx next info
# Next.js 15.1.0
# React 19.0.0
Changements breaking Next.js 15 :
// ❌ AVANT (Next.js 12)
import Image from 'next/legacy/image'
// ✅ APRÈS (Next.js 15)
import Image from 'next/image'
// ❌ AVANT : Link avec <a> enfant
<Link href="/about">
<a>About</a>
</Link>
// ✅ APRÈS : Link sans <a>
<Link href="/about">
About
</Link>
// ❌ AVANT : Script avec strategy
<Script src="/analytics.js" strategy="afterInteractive" />
// ✅ APRÈS : Renommé en onReady
<Script src="/analytics.js" strategy="lazyOnload" />
Étape 2 : Créer le root layout
// app/layout.tsx - Point d'entrée de l'App Router
import type { Metadata } from 'next'
import { Inter } from 'next/font/google'
import './globals.css'
const inter = Inter({ subsets: ['latin'] })
export const metadata: Metadata = {
title: {
default: 'Hulli Studio - Agence React & Next.js Paris',
template: '%s | Hulli Studio', // Template pour les sous-pages
},
description:
'Agence spécialisée React, Next.js et TypeScript. Développement web moderne, performant et scalable.',
openGraph: {
type: 'website',
locale: 'fr_FR',
url: 'https://hulli.studio',
siteName: 'Hulli Studio',
},
}
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="fr">
<body className={inter.className}>
{/* Providers (Client Components) */}
<Providers>
{/* Navigation commune */}
<Header />
{/* Contenu de la page */}
<main>{children}</main>
{/* Footer commun */}
<Footer />
</Providers>
</body>
</html>
)
}
Migration de _app.tsx :
// ❌ AVANT : pages/_app.tsx
import { SessionProvider } from 'next-auth/react'
import { QueryClientProvider } from '@tanstack/react-query'
export default function MyApp({ Component, pageProps }) {
return (
<SessionProvider session={pageProps.session}>
<QueryClientProvider client={queryClient}>
<Component {...pageProps} />
</QueryClientProvider>
</SessionProvider>
)
}
// ✅ APRÈS : app/providers.tsx (Client Component)
;('use client')
import { SessionProvider } from 'next-auth/react'
import { QueryClientProvider } from '@tanstack/react-query'
export function Providers({ children }: { children: React.ReactNode }) {
return (
<SessionProvider>
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
</SessionProvider>
)
}
// app/layout.tsx (Server Component)
import { Providers } from './providers'
export default function RootLayout({ children }) {
return (
<html>
<body>
<Providers>{children}</Providers>
</body>
</html>
)
}
Étape 3 : Migrer une page simple
Pages Router :
// pages/about.tsx
import Head from 'next/head'
export default function AboutPage() {
return (
<>
<Head>
<title>À propos - Hulli Studio</title>
<meta name="description" content="Notre histoire, notre équipe" />
</Head>
<div>
<h1>À propos de Hulli Studio</h1>
<p>Nous sommes une agence spécialisée React...</p>
</div>
</>
)
}
App Router :
// app/about/page.tsx
import type { Metadata } from 'next'
export const metadata: Metadata = {
title: 'À propos', // Utilise le template du layout
description: 'Notre histoire, notre équipe',
}
export default function AboutPage() {
return (
<div>
<h1>À propos de Hulli Studio</h1>
<p>Nous sommes une agence spécialisée React...</p>
</div>
)
}
Changements clés :
<Head>→export const metadatapages/about.tsx→app/about/page.tsx- Pas de
<>Fragment nécessaire - Page = Server Component par défaut
Étape 4 : Migrer getServerSideProps
Pages Router :
// pages/blog/[slug].tsx
import { GetServerSideProps } from 'next'
type Props = {
post: Post
}
export const getServerSideProps: GetServerSideProps<Props> = async (context) => {
const { slug } = context.params
const post = await db.post.findUnique({ where: { slug } })
if (!post) {
return { notFound: true }
}
return { props: { post } }
}
export default function BlogPostPage({ post }: Props) {
return (
<article>
<h1>{post.title}</h1>
<div dangerouslySetInnerHTML={{ __html: post.content }} />
</article>
)
}
App Router :
// app/blog/[slug]/page.tsx
import { notFound } from 'next/navigation'
import db from '@/lib/database'
type Props = {
params: { slug: string }
}
export async function generateMetadata({ params }: Props) {
const post = await db.post.findUnique({ where: { slug: params.slug } })
if (!post) return {}
return {
title: post.title,
description: post.excerpt,
openGraph: {
title: post.title,
description: post.excerpt,
images: [post.coverImage],
},
}
}
export default async function BlogPostPage({ params }: Props) {
const post = await db.post.findUnique({
where: { slug: params.slug },
})
if (!post) {
notFound() // Affiche le fichier not-found.tsx
}
return (
<article>
<h1>{post.title}</h1>
<div dangerouslySetInnerHTML={{ __html: post.content }} />
</article>
)
}
Changements clés :
getServerSideProps→ Composantasyncdirectcontext.params→{ params }return { notFound: true }→notFound()- Metadata dynamique via
generateMetadata() - Bonus : Accès direct à la DB, pas d'API route nécessaire
Étape 5 : Migrer getStaticProps + getStaticPaths
Pages Router :
// pages/blog/[slug].tsx
export async function getStaticPaths() {
const posts = await db.post.findMany({ select: { slug: true } })
return {
paths: posts.map((post) => ({ params: { slug: post.slug } })),
fallback: 'blocking',
}
}
export async function getStaticProps({ params }) {
const post = await db.post.findUnique({ where: { slug: params.slug } })
return { props: { post }, revalidate: 60 }
}
App Router :
// app/blog/[slug]/page.tsx
export async function generateStaticParams() {
const posts = await db.post.findMany({ select: { slug: true } })
return posts.map((post) => ({
slug: post.slug,
}))
}
export const revalidate = 60 // ISR : revalide toutes les 60s
// OU
export const dynamic = 'force-static' // SSG pur
export default async function BlogPostPage({ params }: Props) {
const post = await db.post.findUnique({ where: { slug: params.slug } })
if (!post) notFound()
return <article>...</article>
}
Changements clés :
getStaticPaths→generateStaticParams()fallback: géré automatiquement par Next.js 15revalidate: export au niveau page
Étape 6 : Migrer les API routes
Pages Router :
// pages/api/contact.ts
import type { NextApiRequest, NextApiResponse } from 'next'
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method !== 'POST') {
return res.status(405).json({ error: 'Method not allowed' })
}
const { email, message } = req.body
await sendEmail({ email, message })
res.status(200).json({ success: true })
}
App Router (Option 1 : Route Handler) :
// app/api/contact/route.ts
import { NextRequest, NextResponse } from 'next/server'
export async function POST(request: NextRequest) {
const { email, message } = await request.json()
await sendEmail({ email, message })
return NextResponse.json({ success: true })
}
App Router (Option 2 : Server Action - Recommandé) :
// app/actions/contact.ts
'use server'
import { z } from 'zod'
const contactSchema = z.object({
email: z.string().email(),
message: z.string().min(10),
})
export async function submitContact(formData: FormData) {
const data = contactSchema.parse({
email: formData.get('email'),
message: formData.get('message'),
})
await sendEmail(data)
return { success: true }
}
// app/contact/page.tsx
import { submitContact } from '@/app/actions/contact'
export default function ContactPage() {
return (
<form action={submitContact}>
<input name="email" type="email" required />
<textarea name="message" required />
<button type="submit">Envoyer</button>
</form>
)
}
Avantages Server Actions vs API routes :
- ✅ Pas de route
/api/séparée - ✅ Progressive Enhancement (fonctionne sans JS)
- ✅ Type-safety end-to-end
- ✅ Moins de code boilerplate
Étape 7 : Layouts imbriqués
L'une des killer features de l'App Router :
app/
├── layout.tsx # Layout global
├── page.tsx # Homepage
└── dashboard/
├── layout.tsx # Layout dashboard (barre latérale)
├── page.tsx # /dashboard
├── analytics/
│ └── page.tsx # /dashboard/analytics
└── settings/
└── page.tsx # /dashboard/settings
// app/dashboard/layout.tsx
export default function DashboardLayout({ children }) {
return (
<div className="flex">
{/* Sidebar commune à toutes les pages /dashboard/* */}
<aside className="w-64 bg-gray-100">
<nav>
<Link href="/dashboard">Overview</Link>
<Link href="/dashboard/analytics">Analytics</Link>
<Link href="/dashboard/settings">Settings</Link>
</nav>
</aside>
{/* Contenu spécifique à chaque page */}
<div className="flex-1 p-8">{children}</div>
</div>
)
}
// app/dashboard/analytics/page.tsx
export default function AnalyticsPage() {
return <h1>Analytics Dashboard</h1>
// La sidebar est automatiquement présente !
}
Avantages :
- ✅ Sidebar ne re-render JAMAIS lors de la navigation
- ✅ Code partagé isolé par section
- ✅ Loading states par layout
Patterns avancés App Router
1. Loading states automatiques
// app/dashboard/loading.tsx
export default function DashboardLoading() {
return (
<div className="animate-pulse">
<div className="h-8 bg-gray-200 rounded w-1/4 mb-4" />
<div className="h-64 bg-gray-200 rounded" />
</div>
)
}
// app/dashboard/page.tsx - Server Component async
export default async function DashboardPage() {
// Cette requête peut prendre 2s
const data = await fetch('https://api.example.com/slow-endpoint')
return <DashboardUI data={data} />
}
Comportement :
- User clique sur "/dashboard"
- Next.js affiche instantanément
loading.tsx - Pendant ce temps,
page.tsxcharge les données (streaming) - Dès que prêt,
page.tsxremplaceloading.tsx
Zéro code de loading manuel. Zéro useState(true).
2. Error boundaries automatiques
// app/dashboard/error.tsx
'use client' // Error boundaries MUST be Client Components
export default function DashboardError({
error,
reset,
}: {
error: Error & { digest?: string }
reset: () => void
}) {
return (
<div className="p-8">
<h2>Une erreur est survenue</h2>
<p>{error.message}</p>
<button onClick={reset}>Réessayer</button>
</div>
)
}
// app/dashboard/page.tsx
export default async function DashboardPage() {
const data = await fetch('https://api.example.com/data')
if (!data.ok) {
throw new Error('Failed to fetch data')
}
return <DashboardUI data={data} />
}
Si l'API échoue, error.tsx s'affiche automatiquement avec un bouton retry.
3. Parallel Routes (Routes parallèles)
Use case : Dashboard avec plusieurs sections chargées indépendamment
app/
└── dashboard/
├── @analytics/
│ └── page.tsx
├── @notifications/
│ └── page.tsx
├── layout.tsx
└── page.tsx
// app/dashboard/layout.tsx
export default function DashboardLayout({
children,
analytics, // @analytics/page.tsx
notifications, // @notifications/page.tsx
}) {
return (
<div className="grid grid-cols-3 gap-4">
<div className="col-span-2">{children}</div>
<div>
{analytics}
{notifications}
</div>
</div>
)
}
// app/dashboard/@analytics/page.tsx
export default async function Analytics() {
const data = await fetch('/api/analytics') // Chargement indépendant
return <AnalyticsWidget data={data} />
}
// app/dashboard/@notifications/page.tsx
export default async function Notifications() {
const data = await fetch('/api/notifications') // Chargement indépendant
return <NotificationsWidget data={data} />
}
Chaque slot charge ses données en parallèle, avec son propre loading.tsx et error.tsx.
4. Intercepting Routes (Modales modernes)
Use case : Galerie photos Instagram-like
app/
└── photos/
├── (.)[id]/
│ └── page.tsx # Modale (intercepte /photos/123)
├── [id]/
│ └── page.tsx # Page complète /photos/123
└── page.tsx # Liste des photos
// app/photos/page.tsx
export default async function PhotosPage() {
const photos = await db.photo.findMany()
return (
<div className="grid grid-cols-4 gap-4">
{photos.map((photo) => (
<Link key={photo.id} href={`/photos/${photo.id}`}>
<Image src={photo.url} alt={photo.title} />
</Link>
))}
</div>
)
}
// app/photos/(.)[id]/page.tsx - Modale interceptée
export default async function PhotoModal({ params }) {
const photo = await db.photo.findUnique({ where: { id: params.id } })
return (
<Modal>
<Image src={photo.url} alt={photo.title} fill />
</Modal>
)
}
// app/photos/[id]/page.tsx - Page complète (direct URL)
export default async function PhotoPage({ params }) {
const photo = await db.photo.findUnique({ where: { id: params.id } })
return (
<div className="container">
<Image src={photo.url} alt={photo.title} width={1200} height={800} />
<h1>{photo.title}</h1>
<p>{photo.description}</p>
</div>
)
}
Comportement :
- Clic sur une photo → Modale s'ouvre (soft navigation)
- Refresh page → Page complète s'affiche
- Partage URL → Page complète
- Back button → Retour à la galerie
Turbopack : -70% de build time
Next.js 15 active Turbopack stable par défaut.
Configuration
// next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
// Turbopack activé par défaut en dev
// Pour forcer Webpack (si besoin) :
// experimental: {
// turbo: false
// }
}
module.exports = nextConfig
Benchmarks réels (Projet e-commerce Hulli Studio)
Webpack (Next.js 12) :
- Cold start : 18s
- HMR : 2.5s
- Production build : 4min 30s
Turbopack (Next.js 15) :
- Cold start : 3s (-83%)
- HMR : 150ms (-94%)
- Production build : 1min 20s (-70%)
Turbopack est 10x plus rapide sur nos projets réels.
Migration checklist
✅ Pré-migration
- Backup complet du code
- Tests E2E couvrant les flows critiques
- Next.js 15 installé (
npm install next@latest) - ESLint configuré pour App Router
✅ Migration technique
-
app/layout.tsxcréé (root layout) -
app/providers.tsxcréé (Context providers) - Metadata API configurée
- 1 page migrée et testée (POC)
- API routes migrées vers Route Handlers ou Server Actions
-
getServerSideProps→asyncServer Components -
getStaticProps→generateStaticParams -
<Head>→export const metadata -
<Link><a>→<Link> - Layouts imbriqués configurés
✅ Testing & monitoring
- Lighthouse score vérifié (> 90 recommandé)
- Core Web Vitals mesurés
- Erreurs Sentry configurées
- Tests E2E passent (Playwright/Cypress)
- SEO vérifié (meta tags, sitemap, robots.txt)
✅ Optimisations
-
loading.tsxajoutés sur routes lentes -
error.tsxconfigurés - Images optimisées avec
next/image - Fonts optimisées avec
next/font - Bundle analyzed (
npm run build+@next/bundle-analyzer)
Pièges courants et solutions
1. "You're importing a component that needs createContext"
// ❌ ERREUR : Context dans un Server Component
import { AuthContext } from '@/contexts/auth'
export default function Layout({ children }) {
return <AuthContext.Provider value={user}>{children}</AuthContext.Provider>
}
// ✅ SOLUTION : Wrapper Client Component
// app/providers.tsx
;('use client')
export function Providers({ children }) {
return <AuthContext.Provider value={user}>{children}</AuthContext.Provider>
}
// app/layout.tsx
import { Providers } from './providers'
export default function Layout({ children }) {
return (
<html>
<body>
<Providers>{children}</Providers>
</body>
</html>
)
}
2. Middleware et redirects
// middleware.ts
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
export function middleware(request: NextRequest) {
// Authentification
const token = request.cookies.get('auth-token')
if (!token && request.nextUrl.pathname.startsWith('/dashboard')) {
return NextResponse.redirect(new URL('/login', request.url))
}
return NextResponse.next()
}
export const config = {
matcher: ['/dashboard/:path*'],
}
3. Caching agressif en production
// ⚠️ Par défaut, fetch() est caché indéfiniment
const data = await fetch('https://api.example.com/data')
// ✅ Revalidation toutes les 60s
const data = await fetch('https://api.example.com/data', {
next: { revalidate: 60 },
})
// ✅ Pas de cache (toujours frais)
const data = await fetch('https://api.example.com/data', {
cache: 'no-store',
})
// ✅ Au niveau page
export const revalidate = 3600 // 1h
export const dynamic = 'force-dynamic' // Pas de cache
Performance : Avant/Après Migration
E-commerce Hulli Studio (Next.js 12 → 15)
Avant (Pages Router) :
- Lighthouse Performance : 68
- FCP : 1.8s
- LCP : 3.2s
- TTI : 4.1s
- Bundle JS : 420 KB
- Build time : 4min 30s
Après (App Router + Turbopack) :
- Lighthouse Performance : 96 (+41%)
- FCP : 0.7s (-61%)
- LCP : 1.2s (-63%)
- TTI : 1.5s (-63%)
- Bundle JS : 140 KB (-67%)
- Build time : 1min 20s (-70%)
Impact business :
- Conversion rate : +18%
- Bounce rate : -22%
- SEO ranking : +12 positions (moyenne top 10 mots-clés)
Conclusion : Next.js 15, le futur est maintenant
L'App Router + Turbopack + Server Components = la stack React la plus performante en 2026.
Pourquoi migrer MAINTENANT :
- Pages Router sera deprecated (communiqué Vercel 2025)
- Performances : Gains mesurables de 60-70%
- DX : Code 2x plus simple
- SEO : Streaming SSR = meilleur ranking
- Coûts : Moins de serveur nécessaire (Server Components)
Chez Hulli Studio, nous avons migré 23 projets clients vers Next.js 15 avec 100% de réussite et des gains de performance systématiques > 50%.
FAQ - Migration Next.js 15
Peut-on utiliser Pages Router et App Router en même temps ?
Oui ! Next.js 13+ supporte les deux. Stratégie recommandée : migrer route par route.
Les API routes (pages/api) fonctionnent toujours ?
Oui, mais préférez les Server Actions pour les mutations et Route Handlers pour les APIs publiques.
Faut-il réécrire tout le code ?
Non. Migration progressive possible. Commencez par les pages statiques, puis les pages dynamiques, enfin les pages avec auth.
Turbopack est-il stable en production ?
Oui depuis Next.js 15.0. Nous l'utilisons en production sur tous nos projets.
Le SEO est-il impacté ?
✅ Amélioré. Metadata API + Streaming SSR = meilleur ranking Google.
Besoin d'aide pour migrer votre application Next.js ?
Chez Hulli Studio, nous avons développé une méthodologie de migration éprouvée :
- ✅ Audit technique (1-2 jours)
- ✅ Plan de migration (progressive ou big bang)
- ✅ Migration + tests (1-6 semaines selon taille)
- ✅ Formation équipe
- ✅ Support post-migration (3 mois)
Articles connexes :
- React Server Components : Le guide complet 2026
- TypeScript 5 Strict Mode : Configuration et patterns
- Optimisation performance Next.js : 20 techniques avancées
Technologies :