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

AI Bot
Par AI Bot ·

Chargement du lecteur de synthèse vocale...

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égieVitesseDonnées dynamiquesPersonnalisation
SSG (Statique)InstantanéNonNon
SSR (Serveur)Plus lentOuiOui
ISR (Incrémental)RapideDonnées périméesNon
CSR (Client)Lent au départOuiOui

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 :

  1. Le CDN sert instantanément le shell HTML statique (navigation, layout, titres, états de chargement)
  2. Le navigateur affiche ce shell immédiatement — l'utilisateur voit du contenu en millisecondes
  3. Le serveur diffuse le contenu dynamique dans les frontières Suspense au fur et à mesure que chaque partie se résout
  4. 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-dashboard

Vé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() ou headers()
  • Utilise searchParams
  • Appelle fetch() avec cache: "no-store" ou next: { revalidate: 0 }
  • Utilise connection() (remplaçant de unstable_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 :

  1. HTML statique pour : le header, les onglets de navigation, les titres de section, le footer et tous les squelettes de repli
  2. Trous dynamiques pour : UserGreeting, LiveMetrics, TrafficChart, RecentActivity

Le HTML statique est mis en cache sur le CDN. Quand un utilisateur visite la page :

  1. 0ms : Le CDN sert le shell statique — l'utilisateur voit immédiatement un dashboard entièrement mis en page avec des squelettes de chargement
  2. ~100ms : Le message de bienvenue est diffusé et remplace GreetingSkeleton
  3. ~200ms : Les cartes de métriques sont diffusées et remplacent MetricsSkeleton
  4. ~500ms : Le graphique de trafic est diffusé et remplace ChartSkeleton
  5. ~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 build

Dans 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 start

Ouvrez 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() ou headers() appelé dans le composant page lui-même (pas dans un enfant enveloppé dans Suspense)
  • searchParams déstructuré au niveau de la page sans Suspense autour du composant consommateur
  • Un fetch() avec cache: "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 revalidate sur 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 :

  1. Tout est statique par défaut — le comportement dynamique est opt-in
  2. Les frontières Suspense définissent la séparation statique/dynamique — c'est votre architecture de rendu
  3. Chaque trou dynamique se résout indépendamment — les requêtes lentes ne bloquent pas les rapides
  4. 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.


Vous voulez lire plus de tutoriels? Découvrez notre dernier tutoriel sur Explorer Transformers.js.

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

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.

25 min read·