Next.jsCachePerformanceApp RouterTypeScript

Cache Next.js 16 : fetch, unstable_cache, ISR et revalidation maîtrisés

Maîtrisez le cache Next.js 16 : fetch, unstable_cache, ISR, revalidateTag et revalidatePath expliqués avec exemples concrets pour App Router.

AMAlexis Mouchon5 min de lecture

Le cache dans Next.js, c'est probablement le sujet qui génère le plus de questions dans mes projets clients. Entre les évolutions entre la v13, v14, v15 et maintenant la v16, beaucoup de devs ont gardé en tête des règles qui ne sont plus vraies. Voici un guide complet et à jour pour comprendre comment Next.js 16 gère le cache et comment l'exploiter intelligemment.

Le changement de paradigme depuis Next.js 15

Jusqu'à la v14, Next.js mettait tout en cache par défaut : les fetch, les Route Handlers, les pages statiques générées au build. C'était simple mais ça surprenait beaucoup de monde, surtout quand on déployait une fonctionnalité "en direct" et qu'elle restait figée pendant des heures.

Depuis la v15, et confirmé dans la v16, le comportement par défaut s'est inversé : le cache est désormais opt-in, pas opt-out. Concrètement, ça veut dire que par défaut :

C'est un changement bienvenu : Next.js cesse de "deviner" ce qu'on veut, et nous laisse le contrôle explicite. Encore faut-il savoir comment l'exercer.

Les quatre niveaux de cache à connaître

Avant de plonger dans les API, il faut bien comprendre qu'il existe quatre couches de cache distinctes dans Next.js 16. Beaucoup de bugs viennent du fait qu'on confond ces couches.

1. Request Memoization

C'est le cache le plus court : il déduplique les requêtes identiques pendant le rendu d'une seule page. Si trois composants appellent fetch('/api/user/42') dans le même render, Next.js ne fait qu'une seule requête réseau.

Il s'active automatiquement, on n'a rien à configurer. Il disparaît dès que la réponse est envoyée au client.

2. Data Cache

C'est le cache persistant côté serveur (sur disque ou dans Redis si tu utilises un cache handler custom). C'est lui qu'on contrôle avec les options de fetch() ou avec unstable_cache.

3. Full Route Cache

C'est le cache du HTML rendu et du payload RSC pour les routes statiques. C'est lui qui est sollicité quand tu visites une page générée au build.

4. Router Cache (côté client)

C'est le cache du navigateur, qui garde les segments visités pour permettre les navigations instantanées. Il a sa propre logique de durée de vie.

Garder cette hiérarchie en tête évite 80 % des incompréhensions. Quand un dev me dit "ma donnée ne se rafraîchit pas malgré revalidateTag", c'est souvent que le Router Cache côté client conserve l'ancienne version.

Configurer le cache de fetch

Reprenons l'exemple le plus courant : récupérer des données depuis une API dans un Server Component.

// app/products/page.tsx
type Product = {
  id: string;
  name: string;
  price: number;
};

async function getProducts(): Promise<Product[]> {
  // Par défaut en Next.js 16 : pas de cache, fetch à chaque requête
  const res = await fetch("https://api.example.com/products");
  return res.json();
}

export default async function ProductsPage() {
  const products = await getProducts();

  return (
    <ul>
      {products.map((p) => (
        <li key={p.id}>{p.name} — {p.price}€</li>
      ))}
    </ul>
  );
}

Si on veut mettre cette requête en cache, on doit le demander explicitement avec l'option cache: 'force-cache' et idéalement un tag pour pouvoir invalider plus tard :

async function getProducts(): Promise<Product[]> {
  const res = await fetch("https://api.example.com/products", {
    cache: "force-cache",
    next: {
      revalidate: 3600, // revalidation toutes les heures
      tags: ["products"], // pour invalidation ciblée
    },
  });
  return res.json();
}

Trois options coexistent et peuvent te servir selon le cas :

Une erreur classique : utiliser revalidate: 0. Ça ne désactive pas le cache, ça déclenche une revalidation à chaque requête, ce qui est lent et inutile. Utilise cache: 'no-store' à la place.

unstable_cache : pour tout ce qui n'est pas fetch

Tous les accès aux données ne passent pas par fetch. Si tu requêtes MongoDB directement, ou si tu utilises un ORM, tu as besoin d'autre chose. C'est là qu'intervient unstable_cache.

// lib/products.ts
import { unstable_cache } from "next/cache";
import { Product } from "@/models/Product";

export const getCachedProducts = unstable_cache(
  async (category: string) => {
    return Product.find({ category }).lean();
  },
  ["products-by-category"], // clé de base du cache
  {
    revalidate: 3600,
    tags: ["products"],
  },
);

Le premier argument est la fonction à exécuter. Le deuxième est un tableau de clés qui sert à dédupliquer le cache (les arguments de la fonction sont automatiquement intégrés à la clé finale). Le troisième est l'objet d'options, avec les mêmes champs revalidate et tags que fetch.

Petite subtilité importante : unstable_cache ne capture pas le contexte de la requête HTTP. Tu ne peux pas accéder à cookies() ou headers() à l'intérieur. Si ta donnée dépend de l'utilisateur connecté, soit tu passes l'identifiant en argument, soit tu n'utilises pas unstable_cache.

Malgré son nom, cette API est très utilisée en production. Le préfixe unstable_ signale que l'API pourrait évoluer, mais elle fait partie intégrante du toolkit Next.js depuis plusieurs versions.

ISR : la régénération statique incrémentale

L'ISR (Incremental Static Regeneration) permet de générer des pages au build, puis de les régénérer au fil de l'eau quand les données changent. C'est parfait pour un blog, un catalogue produit, ou n'importe quel contenu qui change occasionnellement.

// app/blog/[slug]/page.tsx
export const revalidate = 3600; // page régénérée toutes les heures
export const dynamicParams = true; // permet les slugs non générés au build

export async function generateStaticParams() {
  const posts = await getAllPosts();
  return posts.map((post) => ({ slug: post.slug }));
}

export default async function BlogPost({
  params,
}: {
  params: Promise<{ slug: string }>;
}) {
  const { slug } = await params;
  const post = await getPost(slug);
  return <article>{/* ... */}</article>;
}

Le revalidate = 3600 au niveau du segment indique à Next.js de servir une version mise en cache pendant une heure, puis de régénérer en arrière-plan. L'utilisateur ne ressent jamais de latence : il voit toujours la version cachée, même pendant que la nouvelle se construit.

Pour les sites avec beaucoup de pages dynamiques (e-commerce, marketplace), couple generateStaticParams avec dynamicParams = true pour pré-générer les pages les plus visitées au build, puis générer les autres à la demande. Tu obtiens le meilleur des deux mondes : un build rapide et des pages quasi-statiques pour tout le catalogue.

Invalidation : revalidateTag et revalidatePath

Le cache, c'est bien. Pouvoir l'invalider quand on en a besoin, c'est encore mieux. Next.js 16 expose deux fonctions pour ça, à utiliser dans des Server Actions ou des Route Handlers.

revalidateTag — invalidation ciblée

C'est ma méthode préférée. Tu tagues tes données au cache (avec tags dans fetch ou unstable_cache), puis tu invalides par tag.

// app/actions/products.ts
"use server";

import { revalidateTag } from "next/cache";

export async function updateProduct(id: string, data: FormData) {
  await fetch(`https://api.example.com/products/${id}`, {
    method: "PUT",
    body: data,
  });

  // Invalide toutes les requêtes taguées "products"
  revalidateTag("products");
}

Avantage : tu peux taguer plusieurs caches indépendants (par exemple products, product-42, category-electronics) et invalider précisément ce qui doit l'être. Ça évite de tout reconstruire à chaque modif.

revalidatePath — invalidation par route

Plus simple mais moins ciblée : tu indiques quelle route doit être considérée comme périmée.

import { revalidatePath } from "next/cache";

export async function deleteProduct(id: string) {
  await fetch(`https://api.example.com/products/${id}`, {
    method: "DELETE",
  });

  revalidatePath("/products"); // page liste
  revalidatePath(`/products/${id}`); // page détail
}

Pratique mais à utiliser avec parcimonie : si la même donnée est consommée sur dix routes différentes, revalidateTag te simplifiera la vie.

Marquer une route comme statique ou dynamique

Au-delà des fetch, tu peux forcer le comportement d'une route entière avec les exports de configuration de segment :

// app/dashboard/page.tsx

// Force la page à être rendue dynamiquement à chaque requête
export const dynamic = "force-dynamic";

// Ou au contraire force le statique (erreur si tu utilises cookies/headers)
// export const dynamic = "force-static";

// Désactive complètement le cache fetch dans cette route
export const fetchCache = "default-no-store";

export default async function Dashboard() {
  // ...
}

force-dynamic est utile pour les dashboards, les pages utilisateur, ou n'importe quoi qui dépend du contexte de la requête. À l'inverse, force-static est rarement nécessaire car Next.js détecte automatiquement quand une page peut être statique.

Les erreurs courantes que je vois en mission

Quelques pièges classiques que je rencontre régulièrement en audit ou en refacto :

1. Confondre cache et revalidation. Beaucoup de devs pensent que revalidate: 60 désactive le cache pendant 60 secondes. C'est l'inverse : ça met en cache pendant 60 secondes, puis revalide.

2. Utiliser cookies() ou headers() dans une page "statique". Ces fonctions rendent la page dynamique automatiquement. Si tu vois un build qui passe de "static" à "dynamic" sans raison apparente, vérifie tes imports.

3. Oublier le Router Cache côté client. Tu invalides parfaitement côté serveur, mais l'utilisateur voit toujours l'ancienne version. Pense à router.refresh() côté client après une Server Action si nécessaire.

4. Mettre en cache des données utilisateur. Si ta requête dépend du cookie de session, tu ne peux pas la mettre dans le cache global. Utilise cache: 'no-store' ou structure ton code pour passer l'identifiant utilisateur explicitement.

5. Trop de tags ou des tags trop génériques. Si tu tagues 100 fetchs avec 'products' et que tu invalides à chaque modif, tu reconstruis tout le site à chaque clic. Sois granulaire.

Stratégie recommandée par type de site

Pour un site vitrine ou portfolio : ISR avec revalidate long (24h ou plus), invalidation manuelle via webhook depuis ton CMS. Performance maximale, coût minimal.

Pour un e-commerce : ISR sur les pages produit avec revalidateTag déclenché par les Server Actions de mise à jour stock/prix. force-dynamic sur le panier et le compte utilisateur.

Pour un SaaS : force-dynamic sur les routes authentifiées, cache agressif sur les pages marketing publiques et la documentation.

Pour un blog comme celui-ci : ISR + revalidateTag quand je pousse un nouvel article. Les pages restent statiques 99 % du temps, mais l'actualisation est immédiate quand je publie.

Si tu veux creuser les performances en parallèle, j'ai écrit un guide sur les Core Web Vitals dans Next.js qui complète bien ce sujet. Et pour la couche au-dessus (gestion d'état serveur côté client), TanStack Query v5 avec l'App Router est une excellente complémentarité au cache natif de Next.

Conclusion

Le cache Next.js 16 a gagné en explicite ce qu'il a perdu en "magie". C'est un changement positif : tu sais exactement ce qui est mis en cache, pendant combien de temps, et comment l'invalider. La courbe d'apprentissage est un peu raide au début, mais une fois ces concepts intégrés, tu obtiens un contrôle très fin sur la performance et la fraîcheur de ton site.

Mes recommandations pour bien démarrer : commence sans cache, ajoute-le là où la performance le justifie, et tague systématiquement tes données pour pouvoir invalider proprement. Évite les configurations globales comme fetchCache: 'force-cache' qui masquent les problèmes plutôt que de les résoudre.

Vous avez un projet Next.js où la performance ou la stratégie de cache pose question ? N'hésitez pas à me contacter à contact@alexis-mouchon.fr pour en discuter — c'est exactement le genre de sujet que j'aime creuser en mission.