Next.jsPartial PrerenderingCache ComponentsPerformanceApp Router

Partial Prerendering dans Next.js 16 : la fin du choix entre statique et dynamique

PPR et Cache Components dans Next.js 16 : servez un shell statique instantané tout en streamant le dynamique. Guide pratique avec du code.

AMAlexis Mouchon5 min de lecture

Pendant des années, on a dû trancher page par page : soit du statique ultra-rapide mais figé, soit du dynamique frais mais plus lent au premier affichage. Next.js 16 fait sauter ce dilemme avec le Partial Prerendering (PPR), désormais stable et intégré à la nouvelle architecture Cache Components. L'idée est simple : une même page sert un shell statique instantané et streame les parties dynamiques dans la même réponse HTTP.

Le vrai problème : static vs dynamic, page par page

Avant, le choix du mode de rendu se faisait au niveau de la route entière. Une page produit avec un en-tête, une navigation et une fiche statiques mais un bloc « recommandations personnalisées » dynamique devait basculer toute la page en rendu dynamique. Résultat : on perdait la rapidité du statique pour une seule portion réellement dynamique.

Le PPR change la granularité. Le rendu n'est plus décidé pour la route, mais pour chaque morceau de l'arbre de composants. Tout ce qui peut être pré-rendu l'est ; tout ce qui dépend de la requête (cookies, données utilisateur, prix temps réel) est streamé ensuite.

Le modèle mental tient en une phrase : tout ce qui est en dehors d'un <Suspense> est statique, tout ce qui est à l'intérieur est dynamique. Le shell est mis en cache et servi instantanément (souvent depuis l'edge), et le contenu dynamique arrive en streaming depuis l'origine — le tout dans une seule requête, une seule réponse.

Activer Cache Components dans Next.js 16

Dans Next.js 14 et 15, le PPR était expérimental et s'activait via experimental.ppr. Dans Next.js 16, ce flag a disparu : le PPR est désormais le comportement par défaut de l'App Router dès qu'on active l'architecture Cache Components.

// next.config.ts
import type { NextConfig } from "next";

const nextConfig: NextConfig = {
  cacheComponents: true,
};

export default nextConfig;

Ce seul flag débloque deux choses : le Partial Prerendering, et la nouvelle directive de cache explicite 'use cache'. Fini le cache implicite parfois difficile à raisonner : en Cache Components, c'est vous qui décidez explicitement ce qui est mis en cache.

La frontière statique / dynamique avec Suspense

Concrètement, on dessine la frontière avec <Suspense>. Le composant parent est pré-rendu statiquement (le shell), et tout ce qui est enveloppé dans un Suspense devient un trou dynamique qui sera streamé.

// app/produits/[slug]/page.tsx
import { Suspense } from "react";
import { ProductHeader } from "@/components/product-header";
import { Recommendations } from "@/components/recommendations";
import { RecommendationsSkeleton } from "@/components/skeletons";

export default async function ProductPage({
  params,
}: {
  params: Promise<{ slug: string }>;
}) {
  const { slug } = await params;

  return (
    <main>
      {/* Statique : pré-rendu, servi instantanément */}
      <ProductHeader slug={slug} />

      {/* Dynamique : streamé après le shell */}
      <Suspense fallback={<RecommendationsSkeleton />}>
        <Recommendations slug={slug} />
      </Suspense>
    </main>
  );
}

À l'arrivée de l'utilisateur, l'en-tête et la fiche produit s'affichent immédiatement (HTML pré-rendu), pendant que les recommandations personnalisées chargent en arrière-plan avec un skeleton. L'utilisateur n'attend jamais devant une page blanche, et le contenu reste frais.

Si vous voulez creuser le mécanisme de streaming sous-jacent et la perception du temps de chargement, j'en parle plus en détail dans Streaming et Suspense dans Next.js.

La directive 'use cache' : cache explicite et composable

L'autre pilier de Cache Components, c'est 'use cache'. On l'ajoute en haut d'un fichier, d'un composant ou d'une fonction async pour indiquer que son résultat doit être mis en cache.

// lib/products.ts
export async function getProducts(category: string) {
  "use cache";
  const res = await fetch(`https://api.exemple.com/products?cat=${category}`);
  return res.json();
}

La fonction est mémorisée : tant que ses arguments ne changent pas, son résultat sert le cache au lieu de refaire l'appel réseau. On peut aussi l'utiliser directement sur un composant serveur, ce qui le rend cacheable et donc candidat au pré-rendu statique :

// components/category-grid.tsx
import { getProducts } from "@/lib/products";

export async function CategoryGrid({ category }: { category: string }) {
  "use cache";
  const products = await getProducts(category);

  return (
    <ul className="grid grid-cols-2 gap-4 md:grid-cols-4">
      {products.map((p) => (
        <li key={p.id} className="rounded-lg border p-4">
          {p.name}
        </li>
      ))}
    </ul>
  );
}

L'avantage par rapport au cache implicite des versions précédentes : c'est lisible. En lisant le code, vous savez exactement ce qui est mis en cache et ce qui ne l'est pas. Pour gérer la durée de vie et l'invalidation de ces caches, les stratégies de revalidation restent essentielles — j'en fais le tour dans Cache Next.js 16 : fetch, unstable_cache, ISR et revalidation maîtrisés.

Contrôler la fraîcheur : cacheLife et cacheTag

Mettre en cache, c'est bien ; savoir quand rafraîchir, c'est mieux. Cache Components fournit cacheLife pour définir une durée de vie et cacheTag pour marquer un cache afin de l'invalider à la demande.

// lib/products.ts
import { unstable_cacheLife as cacheLife, unstable_cacheTag as cacheTag } from "next/cache";

export async function getProduct(slug: string) {
  "use cache";
  cacheLife("hours"); // profil de durée de vie
  cacheTag(`product-${slug}`); // tag pour invalidation ciblée

  const res = await fetch(`https://api.exemple.com/products/${slug}`);
  return res.json();
}

Ensuite, après une mutation (mise à jour d'un produit côté back, par exemple), on invalide précisément le cache concerné avec revalidateTag('product-le-slug'). C'est exactement le genre de coordination front/back qui devient propre quand on maîtrise les deux côtés : une mutation côté NestJS déclenche une revalidation ciblée côté Next.js, sans purge globale.

Comment Next.js construit la réponse

Pour bien raisonner sur le PPR, il faut comprendre ce qui se passe au build et à la requête. Au moment du build, Next.js parcourt l'arbre de composants et pré-rend tout ce qu'il peut résoudre sans contexte de requête. Dès qu'il rencontre une frontière <Suspense> contenant des données dynamiques, il insère le fallback dans le HTML statique et marque l'emplacement comme un « trou » à remplir.

À la requête, le serveur renvoie immédiatement ce HTML pré-rendu — le shell avec les fallbacks. En parallèle, il exécute les parties dynamiques et streame leur HTML dans la même réponse, qui vient remplacer les fallbacks au fur et à mesure. Le navigateur n'a besoin que d'une seule connexion : pas de waterfall de requêtes côté client, pas de hydratation qui bloque l'affichage initial.

// app/dashboard/page.tsx
import { Suspense } from "react";
import { Sidebar } from "@/components/sidebar";
import { RevenueChart } from "@/components/revenue-chart";
import { ActivityFeed } from "@/components/activity-feed";
import { ChartSkeleton, FeedSkeleton } from "@/components/skeletons";

export default function DashboardPage() {
  return (
    <div className="flex">
      {/* Statique : layout, navigation, titres */}
      <Sidebar />

      <section className="flex-1 space-y-6 p-6">
        <h1 className="text-2xl font-bold">Tableau de bord</h1>

        {/* Deux trous dynamiques indépendants, streamés en parallèle */}
        <Suspense fallback={<ChartSkeleton />}>
          <RevenueChart />
        </Suspense>

        <Suspense fallback={<FeedSkeleton />}>
          <ActivityFeed />
        </Suspense>
      </section>
    </div>
  );
}

Détail important : deux frontières <Suspense> sœurs sont streamées en parallèle. Le graphe de revenus et le flux d'activité chargent en même temps, chacun s'affichant dès que ses données sont prêtes, sans s'attendre l'un l'autre.

Faut-il l'adopter sur tous vos projets ?

Le PPR brille sur les pages qui mélangent une structure stable et des îlots dynamiques : pages produit, tableaux de bord, pages d'accueil avec un bloc personnalisé, articles de blog avec des commentaires en temps réel. C'est là qu'on récupère le meilleur des deux mondes.

À l'inverse, sur une page 100 % statique (une page « mentions légales », par exemple), le PPR n'apporte rien de plus que le rendu statique classique — et c'est très bien ainsi, il ne dégrade rien. Sur une page 100 % dynamique et entièrement personnalisée, le shell statique se réduit à peau de chagrin et le gain devient marginal.

La bonne nouvelle, c'est que l'adoption est progressive. Activer cacheComponents ne vous oblige pas à tout réécrire : vous pouvez introduire des frontières <Suspense> et la directive 'use cache' page par page, en commençant par celles qui ont le plus d'impact business. C'est une migration incrémentale, pas un big bang.

Erreurs courantes à éviter

Tout envelopper dans <Suspense>. Le PPR n'a d'intérêt que si une vraie portion de la page est statique. Si vous mettez toute la page dans un seul Suspense, vous retombez sur du rendu dynamique classique avec un skeleton géant.

Oublier que 'use cache' interdit l'accès aux données de requête. Une fonction marquée 'use cache' ne peut pas lire les cookies(), headers() ou les searchParams, puisqu'elle est pré-rendue sans contexte de requête. Ces accès doivent vivre dans la partie dynamique, à l'intérieur d'un Suspense.

Des fallbacks de mauvaise qualité. Le shell statique s'affiche instantanément, donc le fallback du Suspense est immédiatement visible. Un skeleton qui reflète la structure réelle du contenu améliore nettement la stabilité visuelle et le CLS.

Mettre en cache des données réellement personnalisées. Le panier, le profil, les notifications : tout ce qui est propre à un utilisateur doit rester dynamique. Le cache, lui, est partagé entre les visiteurs.

L'impact sur les Core Web Vitals

Le bénéfice est direct sur les métriques de performance. Un shell statique servi depuis l'edge améliore le TTFB et le LCP, puisque le contenu above-the-fold n'attend plus aucun appel base de données. Le contenu dynamique, lui, arrive en streaming sans bloquer le rendu initial. Concrètement : on gagne la rapidité du statique et la fraîcheur du dynamique, là où il fallait choisir avant.

Si l'optimisation des Core Web Vitals vous intéresse au-delà du PPR, j'ai détaillé une méthode complète dans Performances Next.js : boostez vos Core Web Vitals en 5 étapes.

En résumé

Le Partial Prerendering de Next.js 16 supprime le compromis historique entre rendu statique et dynamique. Avec cacheComponents: true, la directive 'use cache' et la frontière <Suspense>, on compose des pages où chaque morceau est rendu de la façon la plus efficace : statique quand c'est possible, dynamique quand c'est nécessaire, le tout dans une seule réponse HTTP. Pour une mise en production, l'approche gagnante consiste à pré-rendre agressivement le shell, isoler proprement les zones dynamiques et soigner les fallbacks.

Vous avez un projet web et vous voulez un site rapide qui ne sacrifie ni la fraîcheur des données ni l'expérience utilisateur ? N'hésitez pas à me contacter pour en discuter — par formulaire ou directement à contact@alexis-mouchon.fr.