Next.js 15 Partial Prerendering (PPR) : Construire un Dashboard Ultra-Rapide avec le Rendu Hybride

Vitesse statique. Puissance dynamique. Une seule page. Le Partial Prerendering (PPR) de Next.js 15 est la plus grande innovation de rendu depuis les Server Components. Dans ce tutoriel, vous construirez un dashboard analytique qui se charge instantanément avec un shell statique tout en diffusant du contenu personnalisé en temps réel — le tout sans cascades JavaScript côté client.
Ce que vous apprendrez
À la fin de ce tutoriel, vous saurez :
- Comprendre ce qu'est le PPR et en quoi il diffère du SSR, SSG et ISR
- Activer le PPR dans un projet Next.js 15 avec le flag expérimental
- Construire un shell statique qui se charge en millisecondes depuis le CDN edge
- Utiliser les frontières React Suspense pour définir des "trous" dynamiques dans les pages statiques
- Diffuser du contenu personnalisé (données utilisateur, métriques en direct) dans ces trous
- Implémenter des états de chargement de repli pour chaque section dynamique
- Mesurer les gains de performance réels avec les Core Web Vitals
- Déployer une application PPR sur Vercel avec mise en cache edge
Prérequis
Avant de commencer, assurez-vous d'avoir :
- Node.js 20+ installé (
node --version) - Familiarité avec Next.js 15 (App Router, Server Components, layouts)
- Bases de React 19 (Suspense, composants asynchrones)
- Expérience en TypeScript
- Un éditeur de code — VS Code ou Cursor recommandé
Comprendre le Partial Prerendering
Le problème du rendu
Les stratégies de rendu traditionnelles vous forcent à choisir entre vitesse et dynamisme pour une page entière :
| Stratégie | Vitesse | Données dynamiques | Personnalisation |
|---|---|---|---|
| SSG (Statique) | Instantané | Non | Non |
| SSR (Serveur) | Plus lent | Oui | Oui |
| ISR (Incrémental) | Rapide | Données périmées | Non |
| CSR (Client) | Lent au départ | Oui | Oui |
La plupart des pages réelles ont à la fois des parties statiques et dynamiques. Un dashboard a une barre de navigation statique, un layout statique et des labels statiques — mais les graphiques, le message de bienvenue et les métriques en direct sont dynamiques. Avant le PPR, il fallait rendre la page entière de manière dynamique juste parce qu'une section avait besoin de données fraîches.
Comment le PPR résout ce problème
Le Partial Prerendering vous permet de pré-rendre les parties statiques de la page au moment du build tout en laissant des "trous" dynamiques qui sont remplis au moment de la requête via le streaming.
Voici ce qui se passe quand un utilisateur demande une page PPR :
- Le CDN sert instantanément le shell HTML statique (navigation, layout, titres, états de chargement)
- Le navigateur affiche ce shell immédiatement — l'utilisateur voit du contenu en millisecondes
- Le serveur diffuse le contenu dynamique dans les frontières Suspense au fur et à mesure que chaque partie se résout
- La page se remplit progressivement sans aucun état de chargement pleine page
Le résultat : le TTFB d'une page statique avec la fraîcheur d'une page dynamique.
PPR vs Streaming SSR traditionnel
Vous pourriez vous demander : "En quoi est-ce différent du streaming classique avec Suspense ?"
Avec le Streaming SSR traditionnel, la page entière est rendue à la demande au moment de la requête. Le serveur envoie le shell et diffuse les morceaux, mais rien n'est pré-rendu — le TTFB dépend toujours du temps de réponse du serveur.
Avec le PPR, le shell statique est pré-rendu et mis en cache en edge. Seuls les trous dynamiques nécessitent un calcul serveur. Cela signifie :
- Le shell statique vient du CDN (5-20ms) au lieu du serveur d'origine (50-300ms)
- Les parties statiques sont garanties cohérentes — pas de variance de calcul serveur
- Les parties dynamiques se diffusent indépendamment, donc une requête base de données lente ne bloque pas toute la page
Étape 1 : Créer le projet
Commencez par créer un nouveau projet Next.js 15 :
npx create-next-app@latest ppr-dashboard --typescript --tailwind --eslint --app --src-dir --import-alias "@/*"
cd ppr-dashboardVérifiez votre version de Next.js :
npx next --versionÉtape 2 : Activer le Partial Prerendering
Le PPR est une fonctionnalité expérimentale dans Next.js 15. Activez-la dans votre next.config.ts :
// next.config.ts
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
experimental: {
ppr: true,
},
};
export default nextConfig;C'est tout ce qu'il faut pour activer le PPR globalement. Une fois activé, Next.js pré-rendra automatiquement les parties statiques de chaque page et laissera les frontières Suspense comme trous dynamiques.
Important : Le PPR nécessite l'App Router. Il ne fonctionne pas avec le Pages Router. Assurez-vous que toutes vos pages utilisent le répertoire app/.
Étape 3 : Comprendre la frontière statique/dynamique
Statique par défaut
Dans Next.js 15 avec PPR, tout est statique sauf si le composant opte pour le rendu dynamique. Un composant devient dynamique quand il :
- Appelle
cookies()ouheaders() - Utilise
searchParams - Appelle
fetch()aveccache: "no-store"ounext: { revalidate: 0 } - Utilise
connection()(remplaçant deunstable_noStore()dans Next.js 15)
La règle des frontières Suspense
Un composant dynamique doit être enveloppé dans une frontière <Suspense>. Cette frontière dit au PPR : "Pré-rends tout ce qui est en dehors de cette frontière, et diffuse cette partie au moment de la requête."
// Ce layout est STATIQUE — pré-rendu au moment du build
export default function Layout({ children }: { children: React.ReactNode }) {
return (
<div className="flex">
<Sidebar /> {/* Statique — pré-rendu */}
<main>{children}</main>
</div>
);
}
// Cette page a des parties STATIQUES et DYNAMIQUES
export default function Page() {
return (
<div>
<h1>Dashboard</h1> {/* Statique — pré-rendu */}
<StaticChart /> {/* Statique — pré-rendu */}
<Suspense fallback={<MetricsSkeleton />}>
<LiveMetrics /> {/* Dynamique — diffusé */}
</Suspense>
</div>
);
}Étape 4 : Construire la couche de données
Créez une couche de données simulée qui imite des appels API réels avec des délais réalistes. En production, ce seraient vos requêtes base de données ou appels API.
// src/lib/data.ts
// Simuler un délai réseau
function delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
export type Metric = {
label: string;
value: string;
change: number;
trend: "up" | "down" | "flat";
};
export type ChartDataPoint = {
date: string;
visitors: number;
pageViews: number;
bounceRate: number;
};
export type Activity = {
id: string;
user: string;
action: string;
timestamp: string;
avatar: string;
};
// STATIQUE : Ne change pas entre les requêtes
export function getStaticMetadata() {
return {
dashboardName: "Vue d'ensemble analytique",
version: "2.4.1",
lastDeployment: "2026-03-20",
sections: ["Métriques", "Trafic", "Activité", "Performance"],
};
}
// DYNAMIQUE : Simule la récupération de métriques KPI en direct (rapide : ~200ms)
export async function getLiveMetrics(): Promise<Metric[]> {
await delay(200);
return [
{
label: "Visiteurs totaux",
value: (Math.floor(Math.random() * 50000) + 10000).toLocaleString(),
change: +(Math.random() * 20 - 5).toFixed(1),
trend: Math.random() > 0.3 ? "up" : "down",
},
{
label: "Pages vues",
value: (Math.floor(Math.random() * 150000) + 30000).toLocaleString(),
change: +(Math.random() * 15 - 3).toFixed(1),
trend: Math.random() > 0.4 ? "up" : "down",
},
{
label: "Taux de rebond",
value: (Math.random() * 30 + 20).toFixed(1) + "%",
change: +(Math.random() * 5 - 8).toFixed(1),
trend: Math.random() > 0.5 ? "down" : "up",
},
{
label: "Session moyenne",
value: Math.floor(Math.random() * 5 + 2) + "m " + Math.floor(Math.random() * 59) + "s",
change: +(Math.random() * 10 - 2).toFixed(1),
trend: "up",
},
];
}
// DYNAMIQUE : Simule la récupération de données graphiques (moyen : ~500ms)
export async function getTrafficData(): Promise<ChartDataPoint[]> {
await delay(500);
const days = 14;
const data: ChartDataPoint[] = [];
for (let i = days; i >= 0; i--) {
const date = new Date();
date.setDate(date.getDate() - i);
data.push({
date: date.toISOString().split("T")[0],
visitors: Math.floor(Math.random() * 3000) + 1000,
pageViews: Math.floor(Math.random() * 8000) + 2000,
bounceRate: +(Math.random() * 20 + 25).toFixed(1),
});
}
return data;
}
// DYNAMIQUE : Simule la récupération de l'activité récente (lent : ~800ms)
export async function getRecentActivity(): Promise<Activity[]> {
await delay(800);
const actions = [
"s'est inscrit",
"a acheté le plan Pro",
"a soumis un formulaire",
"a laissé un avis",
"a mis à jour son compte",
"a invité un membre",
"a exporté des données",
"a créé un projet",
];
const names = [
"Sarah Chen", "Marcus Johnson", "Aisha Patel",
"Carlos Rivera", "Emma Wilson", "Omar Hassan",
"Yuki Tanaka", "Fatima Al-Rashid",
];
return Array.from({ length: 8 }, (_, i) => ({
id: `act-${i}`,
user: names[i],
action: actions[i],
timestamp: `il y a ${Math.floor(Math.random() * 59) + 1} min`,
avatar: `https://api.dicebear.com/7.x/avataaars/svg?seed=${names[i]}`,
}));
}
// DYNAMIQUE : Simule un message de bienvenue personnalisé (rapide : ~100ms)
export async function getUserGreeting(): Promise<{
name: string;
role: string;
notifications: number;
}> {
await delay(100);
return {
name: "Alex",
role: "Admin",
notifications: Math.floor(Math.random() * 12),
};
}Notez que chaque fonction a un délai différent. C'est intentionnel — dans une vraie application, différentes sources de données ont des latences différentes. Le PPR gère cela élégamment car chaque frontière Suspense se résout indépendamment.
Étape 5 : Créer les composants squelettes
Construisez des squelettes de chargement qui s'affichent pendant que le contenu dynamique est diffusé. Ce sont les replis que les utilisateurs voient dans le shell statique.
// src/components/skeletons.tsx
export function MetricsSkeleton() {
return (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
{Array.from({ length: 4 }).map((_, i) => (
<div
key={i}
className="rounded-xl border border-gray-200 bg-white p-6 animate-pulse"
>
<div className="h-4 w-24 rounded bg-gray-200 mb-3" />
<div className="h-8 w-32 rounded bg-gray-200 mb-2" />
<div className="h-3 w-16 rounded bg-gray-200" />
</div>
))}
</div>
);
}
export function ChartSkeleton() {
return (
<div className="rounded-xl border border-gray-200 bg-white p-6 animate-pulse">
<div className="h-5 w-40 rounded bg-gray-200 mb-6" />
<div className="flex items-end gap-2 h-64">
{Array.from({ length: 14 }).map((_, i) => (
<div
key={i}
className="flex-1 rounded-t bg-gray-200"
style={{ height: `${Math.random() * 80 + 20}%` }}
/>
))}
</div>
</div>
);
}
export function ActivitySkeleton() {
return (
<div className="rounded-xl border border-gray-200 bg-white p-6 animate-pulse">
<div className="h-5 w-36 rounded bg-gray-200 mb-6" />
<div className="space-y-4">
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="flex items-center gap-3">
<div className="h-10 w-10 rounded-full bg-gray-200" />
<div className="flex-1">
<div className="h-4 w-48 rounded bg-gray-200 mb-1" />
<div className="h-3 w-20 rounded bg-gray-200" />
</div>
</div>
))}
</div>
</div>
);
}
export function GreetingSkeleton() {
return (
<div className="flex items-center gap-3 animate-pulse">
<div className="h-10 w-10 rounded-full bg-gray-200" />
<div>
<div className="h-5 w-32 rounded bg-gray-200 mb-1" />
<div className="h-3 w-20 rounded bg-gray-200" />
</div>
</div>
);
}Conseil design : Les bons squelettes correspondent exactement au layout du contenu qu'ils remplacent. Cela empêche le décalage de mise en page (CLS) quand le contenu dynamique arrive, ce qui est crucial pour les Core Web Vitals.
Étape 6 : Construire les composants dynamiques
Créez maintenant les Server Components asynchrones qui récupèrent les données dynamiques. Ces composants seront enveloppés dans des frontières Suspense.
Message de bienvenue
// src/components/user-greeting.tsx
import { getUserGreeting } from "@/lib/data";
export async function UserGreeting() {
const user = await getUserGreeting();
return (
<div className="flex items-center gap-3">
<div className="h-10 w-10 rounded-full bg-gradient-to-br from-blue-500 to-purple-600 flex items-center justify-center text-white font-semibold">
{user.name[0]}
</div>
<div>
<p className="font-semibold text-gray-900">
Bon retour, {user.name}
</p>
<p className="text-sm text-gray-500">{user.role}</p>
</div>
{user.notifications > 0 && (
<span className="ml-2 inline-flex items-center justify-center h-6 w-6 rounded-full bg-red-500 text-white text-xs font-bold">
{user.notifications}
</span>
)}
</div>
);
}Cartes de métriques en direct
// src/components/live-metrics.tsx
import { getLiveMetrics } from "@/lib/data";
import type { Metric } from "@/lib/data";
function MetricCard({ metric }: { metric: Metric }) {
const isPositive =
(metric.trend === "up" && metric.label !== "Taux de rebond") ||
(metric.trend === "down" && metric.label === "Taux de rebond");
return (
<div className="rounded-xl border border-gray-200 bg-white p-6 hover:shadow-md transition-shadow">
<p className="text-sm font-medium text-gray-500">{metric.label}</p>
<p className="mt-2 text-3xl font-bold text-gray-900">{metric.value}</p>
<div className="mt-2 flex items-center gap-1">
<span
className={`text-sm font-medium ${
isPositive ? "text-green-600" : "text-red-600"
}`}
>
{metric.trend === "up" ? "↑" : "↓"} {Math.abs(metric.change)}%
</span>
<span className="text-sm text-gray-400">vs semaine dernière</span>
</div>
</div>
);
}
export async function LiveMetrics() {
const metrics = await getLiveMetrics();
return (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
{metrics.map((metric) => (
<MetricCard key={metric.label} metric={metric} />
))}
</div>
);
}Graphique de trafic
// src/components/traffic-chart.tsx
import { getTrafficData } from "@/lib/data";
export async function TrafficChart() {
const data = await getTrafficData();
const maxVisitors = Math.max(...data.map((d) => d.visitors));
return (
<div className="rounded-xl border border-gray-200 bg-white p-6">
<div className="flex items-center justify-between mb-6">
<h3 className="text-lg font-semibold text-gray-900">
Aperçu du trafic
</h3>
<span className="text-sm text-gray-500">14 derniers jours</span>
</div>
<div className="flex items-end gap-1.5 h-64">
{data.map((point) => {
const height = (point.visitors / maxVisitors) * 100;
return (
<div
key={point.date}
className="group relative flex-1 flex flex-col items-center"
>
<div className="absolute -top-10 hidden group-hover:block bg-gray-900 text-white text-xs rounded px-2 py-1 whitespace-nowrap">
{point.visitors.toLocaleString()} visiteurs
</div>
<div
className="w-full rounded-t bg-gradient-to-t from-blue-600 to-blue-400 hover:from-blue-700 hover:to-blue-500 transition-colors cursor-pointer"
style={{ height: `${height}%` }}
/>
<span className="mt-2 text-[10px] text-gray-400">
{new Date(point.date).getDate()}
</span>
</div>
);
})}
</div>
</div>
);
}Fil d'activité récente
// src/components/recent-activity.tsx
import { getRecentActivity } from "@/lib/data";
export async function RecentActivity() {
const activities = await getRecentActivity();
return (
<div className="rounded-xl border border-gray-200 bg-white p-6">
<h3 className="text-lg font-semibold text-gray-900 mb-6">
Activité récente
</h3>
<div className="space-y-4">
{activities.map((activity) => (
<div key={activity.id} className="flex items-center gap-3">
<img
src={activity.avatar}
alt={activity.user}
className="h-10 w-10 rounded-full bg-gray-100"
/>
<div className="flex-1 min-w-0">
<p className="text-sm text-gray-900">
<span className="font-medium">{activity.user}</span>{" "}
{activity.action}
</p>
<p className="text-xs text-gray-500">{activity.timestamp}</p>
</div>
</div>
))}
</div>
</div>
);
}Étape 7 : Assembler la page du dashboard
Maintenant, rassemblez tout. C'est ici que la magie du PPR opère — vous combinez des éléments statiques avec des composants dynamiques enveloppés dans Suspense dans une seule page.
// src/app/page.tsx
import { Suspense } from "react";
import { getStaticMetadata } from "@/lib/data";
import { UserGreeting } from "@/components/user-greeting";
import { LiveMetrics } from "@/components/live-metrics";
import { TrafficChart } from "@/components/traffic-chart";
import { RecentActivity } from "@/components/recent-activity";
import {
MetricsSkeleton,
ChartSkeleton,
ActivitySkeleton,
GreetingSkeleton,
} from "@/components/skeletons";
export default function DashboardPage() {
// Exécuté au moment du BUILD — complètement statique
const metadata = getStaticMetadata();
return (
<div className="min-h-screen bg-gray-50">
{/* STATIQUE : Barre de navigation — pré-rendu au build */}
<header className="bg-white border-b border-gray-200">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4 flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="h-8 w-8 rounded-lg bg-gradient-to-br from-blue-600 to-purple-600" />
<h1 className="text-xl font-bold text-gray-900">
{metadata.dashboardName}
</h1>
<span className="text-xs text-gray-400 bg-gray-100 px-2 py-0.5 rounded-full">
v{metadata.version}
</span>
</div>
{/* DYNAMIQUE : Message de bienvenue — diffusé à la requête */}
<Suspense fallback={<GreetingSkeleton />}>
<UserGreeting />
</Suspense>
</div>
</header>
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8 space-y-8">
{/* STATIQUE : Onglets de section — pré-rendus */}
<nav className="flex gap-1 bg-gray-100 p-1 rounded-lg w-fit">
{metadata.sections.map((section) => (
<button
key={section}
className={`px-4 py-2 rounded-md text-sm font-medium transition-colors ${
section === "Métriques"
? "bg-white text-gray-900 shadow-sm"
: "text-gray-500 hover:text-gray-900"
}`}
>
{section}
</button>
))}
</nav>
{/* DYNAMIQUE : Cartes de métriques en direct — diffusées (~200ms) */}
<section>
<h2 className="text-lg font-semibold text-gray-900 mb-4">
Métriques clés
</h2>
<Suspense fallback={<MetricsSkeleton />}>
<LiveMetrics />
</Suspense>
</section>
{/* Layout avec graphique et activité côte à côte */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
{/* DYNAMIQUE : Graphique de trafic — diffusé (~500ms) */}
<div className="lg:col-span-2">
<Suspense fallback={<ChartSkeleton />}>
<TrafficChart />
</Suspense>
</div>
{/* DYNAMIQUE : Activité récente — diffusée (~800ms) */}
<div>
<Suspense fallback={<ActivitySkeleton />}>
<RecentActivity />
</Suspense>
</div>
</div>
{/* STATIQUE : Pied de page — pré-rendu */}
<footer className="text-center text-sm text-gray-400 pt-8 border-t border-gray-200">
Dernier déploiement : {metadata.lastDeployment} · Construit avec Next.js 15 PPR
</footer>
</main>
</div>
);
}Ce qui se passe au moment du build
Quand vous lancez next build, Next.js analyse cette page et produit :
- HTML statique pour : le header, les onglets de navigation, les titres de section, le footer et tous les squelettes de repli
- Trous dynamiques pour :
UserGreeting,LiveMetrics,TrafficChart,RecentActivity
Le HTML statique est mis en cache sur le CDN. Quand un utilisateur visite la page :
- 0ms : Le CDN sert le shell statique — l'utilisateur voit immédiatement un dashboard entièrement mis en page avec des squelettes de chargement
- ~100ms : Le message de bienvenue est diffusé et remplace
GreetingSkeleton - ~200ms : Les cartes de métriques sont diffusées et remplacent
MetricsSkeleton - ~500ms : Le graphique de trafic est diffusé et remplace
ChartSkeleton - ~800ms : Le fil d'activité est diffusé et remplace
ActivitySkeleton
Chaque section apparaît indépendamment au fur et à mesure que ses données se résolvent. Pas de cascades. Pas de blocage.
Étape 8 : Configuration PPR par route
Parfois vous voulez le PPR sur des routes spécifiques plutôt que globalement. Next.js 15 supporte la configuration par route avec experimental_ppr :
// src/app/settings/page.tsx
// Activer le PPR pour cette page spécifiquement
export const experimental_ppr = true;
export default function SettingsPage() {
return (
<div>
<h1>Paramètres</h1>
<Suspense fallback={<div>Chargement des préférences...</div>}>
<UserPreferences />
</Suspense>
</div>
);
}Étape 9 : Gérer les patterns de données dynamiques
Utiliser connection() pour le rendu dynamique
Dans Next.js 15, la méthode recommandée pour marquer un composant comme dynamique est la fonction connection() :
// src/components/server-time.tsx
import { connection } from "next/server";
export async function ServerTime() {
// Ceci dit à Next.js : "Ce composant a besoin de données fraîches à chaque requête"
await connection();
const now = new Date();
return (
<p className="text-sm text-gray-500">
Heure serveur : {now.toLocaleTimeString("fr-FR")}
</p>
);
}Lire les cookies et les en-têtes
Les composants qui lisent les cookies ou les en-têtes deviennent automatiquement dynamiques :
// src/components/theme-aware-panel.tsx
import { cookies } from "next/headers";
export async function ThemeAwarePanel() {
const cookieStore = await cookies();
const theme = cookieStore.get("theme")?.value ?? "light";
return (
<div className={theme === "dark" ? "bg-gray-900 text-white" : "bg-white text-gray-900"}>
<p>Votre thème préféré : {theme === "dark" ? "sombre" : "clair"}</p>
</div>
);
}Étape 10 : Suspense imbriqué pour un streaming granulaire
Un pattern PPR puissant est les frontières Suspense imbriquées. Cela permet aux données rapides d'apparaître en premier pendant que les données plus lentes continuent de charger :
// src/components/analytics-panel.tsx
import { Suspense } from "react";
export function AnalyticsPanel() {
return (
<div className="space-y-6">
{/* Les données rapides chargent en premier */}
<Suspense fallback={<div className="h-20 animate-pulse bg-gray-100 rounded" />}>
<QuickStats /> {/* ~100ms */}
</Suspense>
{/* Les données moyennes chargent ensuite */}
<Suspense fallback={<div className="h-64 animate-pulse bg-gray-100 rounded" />}>
<DetailedChart /> {/* ~400ms */}
{/* Imbriqué : les données lentes chargent en dernier, sans bloquer le graphique */}
<Suspense fallback={<div className="h-32 animate-pulse bg-gray-100 rounded" />}>
<DeepAnalysis /> {/* ~1200ms */}
</Suspense>
</Suspense>
</div>
);
}Étape 11 : Gestion des erreurs avec PPR
Les composants dynamiques peuvent échouer. Utilisez les error boundaries React avec Suspense pour gérer les erreurs élégamment :
// src/app/error-boundary.tsx
"use client";
import { Component, type ReactNode } from "react";
interface Props {
children: ReactNode;
fallback: ReactNode;
}
interface State {
hasError: boolean;
}
export class ErrorBoundary extends Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError(): State {
return { hasError: true };
}
render() {
if (this.state.hasError) {
return this.props.fallback;
}
return this.props.children;
}
}Utilisez-le avec Suspense :
<ErrorBoundary
fallback={
<div className="rounded-xl border border-red-200 bg-red-50 p-6 text-red-700">
Échec du chargement des métriques. Veuillez rafraîchir la page.
</div>
}
>
<Suspense fallback={<MetricsSkeleton />}>
<LiveMetrics />
</Suspense>
</ErrorBoundary>Étape 12 : Mesurer la performance
Lancez un build de production pour voir l'optimisation PPR en action :
npm run buildDans la sortie du build, vous verrez des symboles à côté de chaque route :
Route (app) Size First Load JS
┌ ◐ / 5.2 kB 92 kB
├ ○ /about 1.1 kB 88 kB
└ ƒ /api/webhook 0 B 0 B
○ (Static) pré-rendu comme contenu statique
◐ (Partial) pré-rendu comme HTML statique avec contenu dynamique diffusé par le serveur
ƒ (Dynamic) rendu serveur à la demande
Le symbole ◐ indique que le PPR est actif sur cette route.
Comparaison des performances
Lancez le serveur de production et mesurez :
npm run startOuvrez les DevTools Chrome, allez dans l'onglet Performance, et enregistrez un chargement de page. Vous devriez observer :
- FCP : Quasi-instantané, car le shell statique vient du cache
- LCP : Dépend du plus grand élément — s'il est statique, c'est instantané ; s'il est dynamique, c'est quand sa frontière Suspense se résout
- CLS : Proche de zéro si vos squelettes correspondent aux dimensions finales du layout
- TTFB : Dramatiquement plus bas qu'un SSR complet
Bonnes pratiques PPR
Faites : Placez les frontières Suspense stratégiquement
Chaque frontière Suspense est un point d'entrée de streaming. Placez-les autour de sections UI logiques, pas d'éléments individuels :
// Bien : une frontière par section logique
<Suspense fallback={<MetricsSkeleton />}>
<MetricsGrid /> {/* Contient 4 cartes de métriques */}
</Suspense>
// À éviter : trop de petites frontières (surcharge)
<Suspense fallback={<CardSkeleton />}>
<MetricCard label="Visiteurs" />
</Suspense>
<Suspense fallback={<CardSkeleton />}>
<MetricCard label="Revenus" />
</Suspense>Faites : Correspondez les dimensions des squelettes
Assurez-vous que vos composants squelettes ont exactement les mêmes dimensions que le contenu chargé. Cela prévient le décalage de layout.
Ne faites pas : Imbriquer des composants dynamiques sans Suspense
Si un composant dynamique n'est pas enveloppé dans Suspense, toute la page devient dynamique — ce qui annule l'intérêt du PPR :
// Mauvais : pas de Suspense — toute la page devient dynamique
export default function Page() {
return (
<div>
<StaticHeader />
<DynamicContent /> {/* Pas de Suspense ! */}
</div>
);
}
// Bien : contenu dynamique isolé dans Suspense
export default function Page() {
return (
<div>
<StaticHeader />
<Suspense fallback={<Loading />}>
<DynamicContent />
</Suspense>
</div>
);
}Dépannage
"La page est entièrement dynamique malgré l'activation du PPR"
Cela signifie généralement qu'une fonction dynamique est appelée en dehors d'une frontière Suspense. Vérifiez :
cookies()ouheaders()appelé dans le composant page lui-même (pas dans un enfant enveloppé dans Suspense)searchParamsdéstructuré au niveau de la page sans Suspense autour du composant consommateur- Un
fetch()aveccache: "no-store"en dehors de Suspense
"Le repli Suspense ne disparaît jamais"
Cela signifie que le composant asynchrone dans Suspense échoue silencieusement. Enveloppez-le dans un error boundary pour faire surface l'erreur.
"Décalage de layout quand le contenu dynamique se charge"
Votre squelette ne correspond pas aux dimensions du contenu final. Mesurez le composant rendu et mettez à jour votre squelette.
Prochaines étapes
Maintenant que vous avez construit un dashboard propulsé par PPR, voici comment l'étendre :
- Ajoutez des sources de données réelles : Remplacez les données simulées par des requêtes base de données avec Drizzle ou Prisma
- Implémentez l'authentification : Utilisez
cookies()dans une frontière Suspense pour afficher des données spécifiques à l'utilisateur - Ajoutez de l'interactivité client : Combinez le PPR avec des Client Components pour les graphiques en utilisant Recharts ou Chart.js
- Implémentez l'ISR hybride : Utilisez
revalidatesur certaines sections statiques pour un rafraîchissement périodique - Surveillez en production : Utilisez Vercel Analytics ou OpenTelemetry pour suivre les performances PPR réelles
Conclusion
Le Partial Prerendering dans Next.js 15 élimine le plus ancien compromis du développement web : choisir entre des pages statiques rapides et du contenu dynamique personnalisé. Avec le PPR, vous obtenez les deux — un shell statique mis en cache CDN qui se charge instantanément, avec du contenu dynamique qui se diffuse progressivement.
Les concepts clés à retenir :
- Tout est statique par défaut — le comportement dynamique est opt-in
- Les frontières Suspense définissent la séparation statique/dynamique — c'est votre architecture de rendu
- Chaque trou dynamique se résout indépendamment — les requêtes lentes ne bloquent pas les rapides
- Les squelettes font partie du shell statique — ils sont pré-rendus et mis en cache, donnant aux utilisateurs un retour visuel immédiat
Le PPR n'est pas qu'une optimisation de performance — c'est une architecture fondamentalement meilleure pour construire des applications web. Commencez à l'utiliser dès aujourd'hui dans vos projets Next.js 15.
Discutez de votre projet avec nous
Nous sommes ici pour vous aider avec vos besoins en développement Web. Planifiez un appel pour discuter de votre projet et comment nous pouvons vous aider.
Trouvons les meilleures solutions pour vos besoins.
Articles connexes

Authentifier votre application Next.js 15 avec Auth.js v5 : Email, OAuth et contrôle des rôles
Apprenez à ajouter une authentification prête pour la production à votre application Next.js 15 avec Auth.js v5. Ce guide complet couvre Google OAuth, les identifiants email/mot de passe, les routes protégées, le middleware et le contrôle d'accès basé sur les rôles.

Construire un Chatbot IA Local avec Ollama et Next.js : Guide Complet
Construisez un chatbot IA privé fonctionnant entièrement sur votre machine locale avec Ollama et Next.js. Ce tutoriel pratique couvre l'installation, le streaming des réponses, la sélection de modèles et le déploiement d'une interface de chat prête pour la production — le tout sans envoyer de données dans le cloud.

Construire un site web axé sur le contenu avec Payload CMS 3 et Next.js
Apprenez à construire un site web complet avec Payload CMS 3, qui fonctionne nativement dans Next.js App Router. Ce tutoriel couvre les collections, le rich text, les uploads, l'authentification et le déploiement en production.