Cours Next.js · Module 5: Stratégies de rendu

Advanced Caching Strategies in Next.js

7 min de lecture
Dans cette leçon7

In the previous module, we learned the basics of data revalidation. Now it's time to dive deep into the advanced caching mechanisms that the Next.js App Router offers under the hood. Understanding these layers is the key to building applications that run lightning-fast - like the systems in the cyberpunk city of Quantum, where every millisecond of delay can cost lives.

Cache Layer Architecture in Next.js

The Next.js App Router implements a multi-layered caching system. Each layer has a different purpose and different lifetime:

  1. Request Memoization - an in-memory cache for the duration of a single server request. If the same fetch is called multiple times within a single render (e.g., in different components), Next.js will execute it only once.
  2. Data Cache - a persistent cache of data fetched via fetch. It survives between user requests and even between deployments. This is the cache you control through revalidate and cache: 'no-store'.
  3. Full Route Cache - a cache of entire statically generated pages (SSG/ISR). It stores ready-made HTML and React Server Component Payload on the server.
  4. Router Cache - a browser-side cache. It stores visited pages and prefetched segments so that client-side navigation is instant.

How the Layers Work Together

When a user visits a page, Next.js checks the cache in the following order:

1Router Cache (browser)
2    ↓ miss
3Full Route Cache (server)
4    ↓ miss
5Page rendering → Data Cache → Request Memoization → fetch to API

Understanding this flow allows you to precisely control which data is fresh and which can be served from cache.

unstable_cache - Caching Without fetch

Not all data comes from fetch. When you retrieve data from a database (e.g., via Prisma, Drizzle, or direct queries), fetch is not involved and the standard Data Cache doesn't work. That's what unstable_cache is for:

1import { unstable_cache } from 'next/cache';
2
3// Caching a database query
4const getCachedUser = unstable_cache(
5  async (userId: string) => {
6    // This query will be cached
7    const user = await db.user.findUnique({
8      where: { id: userId },
9      include: { posts: true, profile: true }
10    });
11    return user;
12  },
13  // Cache key - an array of strings identifying this cache
14  ['user-data'],
15  {
16    // Tags for on-demand revalidation
17    tags: ['users'],
18    // Revalidation time in seconds
19    revalidate: 3600
20  }
21);
22
23// Usage in a Server Component
24export default async function UserProfile({ params }: { params: Promise<{ id: string }> }) {
25  const user = await getCachedUser((await params).id);
26
27  return (
28    <div>
29      <h1>{user.name}</h1>
30      <p>{user.profile?.bio}</p>
31      <h2>Posts ({user.posts.length})</h2>
32    </div>
33  );
34}

unstable_cache accepts three arguments:

  • An async function - the operation to cache
  • Cache key - an array of strings forming a unique identifier
  • Options - tags (for on-demand revalidation) and revalidate (time in seconds)

Cache Tags - Precise Revalidation Control

Cache tags allow grouping related data and revalidating it together. This is more powerful than revalidatePath because it works independently of URL paths.

Data Tagging Strategy

1// Product data - tagged with both a general and specific tag
2async function getProduct(id: string) {
3  const res = await fetch(`https://api.example.com/products/${id}`, {
4    next: {
5      tags: [
6        'products',           // general tag - revalidates all products
7        `product-${id}`       // specific tag - revalidates only this product
8      ]
9    }
10  });
11  return res.json();
12}
13
14// Category data - related to products
15async function getCategory(slug: string) {
16  const res = await fetch(`https://api.example.com/categories/${slug}`, {
17    next: {
18      tags: [
19        'categories',
20        `category-${slug}`
21      ]
22    }
23  });
24  return res.json();
25}

On-demand Revalidation with Tags

1// app/actions.ts
2'use server';
3
4import { revalidateTag, revalidatePath } from 'next/cache';
5
6// After updating a single product
7export async function updateProduct(id: string, data: ProductData) {
8  await db.product.update({ where: { id }, data });
9
10  // Revalidate only this specific product
11  revalidateTag(`product-${id}`);
12
13  // Also revalidate the product list (e.g., the shop page)
14  revalidateTag('products');
15}
16
17// After a bulk price update
18export async function bulkUpdatePrices() {
19  await db.product.updateMany({ /* ... */ });
20
21  // One command revalidates ALL products across all pages
22  revalidateTag('products');
23}

revalidateTag vs revalidatePath - When to Use Which

MethodScopeWhen to use
revalidateTag('products')All data with the 'products' tag, regardless of pageWhen data appears on multiple pages
revalidatePath('/products')The entire /products page - all data on itWhen you want to refresh a specific page
revalidatePath('/products', 'layout')The page + all subpages with this layoutWhen the layout or shared data has changed

Data Cache vs Full Route Cache vs Router Cache

These three cache layers are often confused. Here are the key differences:

Data Cache

Stores the results of fetch on the server. You control it through:

1// Data cached permanently (default)
2fetch(url);
3
4// Data cached for 60 seconds
5fetch(url, { next: { revalidate: 60 } });
6
7// Data never cached
8fetch(url, { cache: 'no-store' });

Lifetime: Survives between requests and deployments (unless revalidated).

Full Route Cache

Stores generated HTML + RSC Payload of static pages. Created during next build.

1// This page will be in the Full Route Cache (static)
2export default async function AboutPage() {
3  return <h1>About Us</h1>;
4}
5
6// This page will NOT be in the Full Route Cache (dynamic)
7export default async function DashboardPage() {
8  const session = await getSession(); // dynamic function
9  return <h1>Welcome, {session.user.name}</h1>;
10}
11
12// Forcing Full Route Cache revalidation
13export const revalidate = 3600; // page revalidated every hour

Lifetime: Until the next next build or revalidation.

Router Cache

Stores visited pages in the user's browser. Thanks to it, navigating back is instant.

1'use client';
2
3import { useRouter } from 'next/navigation';
4
5export function RefreshButton() {
6  const router = useRouter();
7
8  return (
9    <button onClick={() => {
10      // Clear the Router Cache and fetch fresh data from the server
11      router.refresh();
12    }}>
13      Refresh data
14    </button>
15  );
16}

Lifetime:

  • Static pages: 5 minutes
  • Dynamic pages: 30 seconds
  • Prefetched: until navigation

Cache Scope - Per-request vs Cross-request

An important distinction that many developers miss:

Per-request (Request Memoization)

Works only within a single server request. If component A and component B call the same fetch, it will be executed only once:

1// Both components share the same fetch within a single render
2async function Header() {
3  const user = await getUser(); // fetch #1
4  return <nav>{user.name}</nav>;
5}
6
7async function Sidebar() {
8  const user = await getUser(); // same fetch - from memoization, not the network!
9  return <aside>{user.avatar}</aside>;
10}

Cross-request (Data Cache)

Survives between requests from different users:

1async function getPopularPosts() {
2  // This data is shared between ALL users
3  const res = await fetch('https://api.example.com/popular', {
4    next: { revalidate: 300 } // cache for 5 minutes
5  });
6  return res.json();
7}

Debugging Cache

Cache problems are among the hardest to diagnose. Here are proven techniques:

1. Logging in Server Components

1export default async function Page() {
2  console.log('[Page] Rendering at:', new Date().toISOString());
3
4  const data = await getData();
5  console.log('[Page] Data fetched, first item:', data[0]?.id);
6
7  return <div>{/* ... */}</div>;
8}

If you see the same timestamp when refreshing - the page is served from the Full Route Cache.

2. The x-nextjs-cache Header

In production mode, Next.js adds the x-nextjs-cache header to responses:

  • HIT - page from the Full Route Cache
  • MISS - page rendered live
  • STALE - page from cache, but revalidation was triggered in the background

3. Forcing Dynamic Rendering for Testing

1import { headers } from 'next/headers';
2
3export default async function DebugPage() {
4  // Using await headers() forces dynamic rendering
5  const headersList = await headers();
6
7  return (
8    <div>
9      <p>Rendered: {new Date().toISOString()}</p>
10      <p>This timestamp should change on every refresh</p>
11    </div>
12  );
13}

Summary

Advanced caching strategies in Next.js create a multi-layered system that - when well configured - can radically speed up an application. Key takeaways:

  1. 4 cache layers - Request Memoization, Data Cache, Full Route Cache, Router Cache - each with a different scope and lifetime
  2. unstable_cache - allows caching data from databases and other non-fetch sources
  3. Cache tags - precise revalidation control: revalidateTag for data, revalidatePath for pages
  4. Per-request vs cross-request - Request Memoization works within a single render, Data Cache across requests
  5. Debugging - logging timestamps, x-nextjs-cache headers, forcing dynamic rendering
Code de cette leçon : App.tsx
1import React, { useState } from 'react';
2
3// Couches de cache dans Next.js App Router
4// Request Memoization -> Data Cache -> Full Route Cache -> Router Cache
5
6type CacheLayer = 'request-memo' | 'data-cache' | 'full-route' | 'router-cache';
7
8interface LayerInfo {
9  id: CacheLayer;
10  name: string;
11  scope: string;
12  lifetime: string;
13  description: string;
14  invalidation: string;
15  example: string;
16}
17
18const layers: LayerInfo[] = [
19  {
20    id: 'request-memo',
21    name: 'Request Memoization',
22    scope: 'Per-request (serveur)',
23    lifetime: 'Durée d\'un seul rendu',
24    description: 'Déduplication des fetch identiques au sein d\'une même requête. Si Header et Sidebar appellent getUser(), le fetch s\'exécutera une seule fois.',
25    invalidation: 'Automatiquement à la fin du rendu',
26    example: 'const user = await getUser(); // appelé 3x, exécuté 1x',
27  },
28  {
29    id: 'data-cache',
30    name: 'Data Cache',
31    scope: 'Cross-request (serveur)',
32    lifetime: 'Entre les requêtes et les déploiements',
33    description: 'Cache persistant des résultats de fetch. Contrôlé par revalidate et les options de cache. Partagé entre les utilisateurs.',
34    invalidation: 'revalidateTag() / revalidatePath() / revalidate: N',
35    example: "fetch(url, { next: { revalidate: 60, tags: ['products'] } })",
36  },
37  {
38    id: 'full-route',
39    name: 'Full Route Cache',
40    scope: 'Pages statiques (serveur)',
41    lifetime: 'Jusqu\'au prochain build ou à la revalidation',
42    description: 'Cache du HTML final + RSC Payload pour les pages statiques. Créé lors du next build.',
43    invalidation: 'revalidatePath() / next build / revalidate sur la page',
44    example: "export const revalidate = 3600; // toute la page toutes les heures",
45  },
46  {
47    id: 'router-cache',
48    name: 'Router Cache',
49    scope: 'Navigateur de l\'utilisateur',
50    lifetime: 'Statique : 5min, Dynamique : 30s',
51    description: 'Cache dans le navigateur pour la navigation côté client. Les pages préchargées se chargent immédiatement.',
52    invalidation: 'router.refresh() / automatiquement après un délai',
53    example: "router.refresh(); // vide le Router Cache",
54  },
55];
56
57export default function CacheLayersDemo() {
58  const [selected, setSelected] = useState<LayerInfo>(layers[0]);
59  const [requestFlow, setRequestFlow] = useState<{ layer: string; result: 'HIT' | 'MISS' }[]>([]);
60
61  const simulateRequest = () => {
62    const flow: { layer: string; result: 'HIT' | 'MISS' }[] = [];
63    let resolved = false;
64
65    for (const layer of layers) {
66      if (resolved) break;
67      const hit = Math.random() > 0.4;
68      flow.push({ layer: layer.name, result: hit ? 'HIT' : 'MISS' });
69      if (hit) resolved = true;
70    }
71
72    if (!resolved) {
73      flow.push({ layer: 'Origin API', result: 'MISS' });
74    }
75
76    setRequestFlow(flow);
77  };
78
79  return (
80    <div style={{ background: '#0f0f23', minHeight: '100vh', padding: 20, color: '#e0e0e0', fontFamily: 'monospace' }}>
81      <h1 style={{ color: '#64ffda', fontSize: 18 }}>Couches de cache dans Next.js</h1>
82
83      <div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', margin: '16px 0' }}>
84        {layers.map((l, i) => (
85          <button key={l.id} onClick={() => setSelected(l)} style={{
86            background: selected.id === l.id ? '#1565c0' : '#1a2744',
87            color: '#fff', border: `1px solid ${selected.id === l.id ? '#64ffda' : '#333'}`,
88            borderRadius: 6, padding: '8px 12px', cursor: 'pointer', fontSize: 11, fontFamily: 'monospace'
89          }}>
90            {i + 1}. {l.name}
91          </button>
92        ))}
93      </div>
94
95      <div style={{ background: '#0d1117', borderRadius: 8, padding: 16, marginBottom: 16 }}>
96        <div style={{ color: '#64ffda', fontSize: 15, marginBottom: 8 }}>{selected.name}</div>
97        <div style={{ display: 'grid', gap: 6, fontSize: 12 }}>
98          <div><span style={{ color: '#888' }}>Scope:</span> <span style={{ color: '#ce93d8' }}>{selected.scope}</span></div>
99          <div><span style={{ color: '#888' }}>Lifetime:</span> <span style={{ color: '#ffab40' }}>{selected.lifetime}</span></div>
100          <div><span style={{ color: '#888' }}>Description :</span> {selected.description}</div>
101          <div><span style={{ color: '#888' }}>Invalidation :</span> <span style={{ color: '#ef5350' }}>{selected.invalidation}</span></div>
102          <code style={{ color: '#4caf50', background: '#1a2744', padding: '6px 10px', borderRadius: 4, fontSize: 11 }}>
103            {selected.example}
104          </code>
105        </div>
106      </div>
107
108      <button onClick={simulateRequest} style={{
109        background: '#4caf50', color: '#fff', border: 'none', borderRadius: 6,
110        padding: '8px 16px', cursor: 'pointer', fontFamily: 'monospace'
111      }}>
112        Simuler une requête
113      </button>
114
115      {requestFlow.length > 0 && (
116        <div style={{ marginTop: 16 }}>
117          <div style={{ color: '#888', fontSize: 12, marginBottom: 8 }}>Flux de la requête à travers les couches de cache :</div>
118          <div style={{ display: 'flex', gap: 4, alignItems: 'center', flexWrap: 'wrap' }}>
119            {requestFlow.map((step, i) => (
120              <React.Fragment key={i}>
121                {i > 0 && <span style={{ color: '#555' }}>-&gt;</span>}
122                <div style={{
123                  background: step.result === 'HIT' ? '#1b5e20' : '#b71c1c',
124                  borderRadius: 4, padding: '4px 8px', fontSize: 11
125                }}>
126                  {step.layer}: {step.result}
127                </div>
128              </React.Fragment>
129            ))}
130          </div>
131        </div>
132      )}
133    </div>
134  );
135}

Tu as repéré une erreur dans cette leçon ?

Teste tes connaissances

Réponds aux questions de cette leçon. Choisis une réponse pour voir tout de suite si elle est correcte.

  1. 1. À quoi sert unstable_cache dans l'App Router de Next.js ?

  2. 2. generateStaticParams sert à :

Tâches pratiques dans le jeu

  • Éditeur de code

    Crée une page avec optimisation : lazy loading, Suspense, choix d'une stratégie de rendu adaptée

  • Tri horizontal

    Reconstitue la syntaxe de l'export dynamic dans Next.js

  • Clique dans l'ordre

    Reconstitue la syntaxe du composant Suspense avec fallback dans Next.js

  • Éditeur de code

    Crée une Server Action pour gérer l'envoi d'un formulaire avec validation et gestion des erreurs

Articles utiles