Kurs Next.js · Moduł 5: Strategie renderowania

Zaawansowane strategie cachowania w Next.js

7 min czytania
W tej lekcji7

W poprzednim module poznaliśmy podstawy rewalidacji danych. Teraz czas zagłębić się w zaawansowane mechanizmy cachowania, które Next.js App Router oferuje pod maską. Zrozumienie tych warstw to klucz do tworzenia aplikacji, które działają błyskawicznie - niczym systemy w cyberpunkowym mieście Quantum, gdzie każda milisekunda opóźnienia może kosztować życie.

Architektura warstw cache w Next.js

Next.js App Router implementuje wielowarstwowy system cachowania. Każda warstwa ma inne zadanie i inny czas życia:

  1. Request Memoization - cache w pamięci na czas trwania jednego żądania serwerowego. Jeśli ten sam fetch zostanie wywołany wielokrotnie w ramach jednego renderowania (np. w różnych komponentach), Next.js wykona go tylko raz.
  2. Data Cache - trwały cache danych pobranych przez fetch. Przetrwa między kolejnymi żądaniami użytkowników, a nawet między deploymentami. To właśnie ten cache kontrolujesz przez revalidate i cache: 'no-store'.
  3. Full Route Cache - cache całych stron wygenerowanych statycznie (SSG/ISR). Przechowuje gotowy HTML i React Server Component Payload na serwerze.
  4. Router Cache - cache po stronie przeglądarki. Przechowuje odwiedzone strony i prefetchowane segmenty, żeby nawigacja kliencka była natychmiastowa.

Jak warstwy współpracują

Gdy użytkownik odwiedza stronę, Next.js sprawdza cache w następującej kolejności:

1Router Cache (przeglądarka)
2    ↓ miss
3Full Route Cache (serwer)
4    ↓ miss
5Renderowanie strony → Data Cache → Request Memoization → fetch do API

Zrozumienie tego przepływu pozwala precyzyjnie kontrolować, które dane są świeże, a które mogą być serwowane z cache.

unstable_cache - cachowanie bez fetch

Nie wszystkie dane pochodzą z fetch. Gdy pobierasz dane z bazy danych (np. przez Prisma, Drizzle czy bezpośrednie zapytania), fetch nie jest zaangażowany i standardowy Data Cache nie działa. Do tego służy unstable_cache:

1import { unstable_cache } from 'next/cache';
2
3// Cachowanie zapytania do bazy danych
4const getCachedUser = unstable_cache(
5  async (userId: string) => {
6    // To zapytanie zostanie zcachowane
7    const user = await db.user.findUnique({
8      where: { id: userId },
9      include: { posts: true, profile: true }
10    });
11    return user;
12  },
13  // Klucz cache - tablica stringów identyfikujących ten cache
14  ['user-data'],
15  {
16    // Tagi do rewalidacji na żądanie
17    tags: ['users'],
18    // Czas rewalidacji w sekundach
19    revalidate: 3600
20  }
21);
22
23// Użycie w 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>Posty ({user.posts.length})</h2>
32    </div>
33  );
34}

unstable_cache przyjmuje trzy argumenty:

  • Funkcję asynchroniczną - operacja do zcachowania
  • Klucz cache - tablica stringów tworzących unikalny identyfikator
  • Opcje - tags (do rewalidacji na żądanie) i revalidate (czas w sekundach)

Cache tags - precyzyjna kontrola rewalidacji

Tagi cache pozwalają grupować powiązane dane i rewalidować je razem. Jest to potężniejsze niż revalidatePath, bo działa niezależnie od ścieżek URL.

Strategia tagowania danych

1// Dane produktu - oznaczone tagami ogólnym i specyficznym
2async function getProduct(id: string) {
3  const res = await fetch(`https://api.example.com/products/${id}`, {
4    next: {
5      tags: [
6        'products',           // tag ogólny - rewaliduje wszystkie produkty
7        `product-${id}`       // tag specyficzny - rewaliduje tylko ten produkt
8      ]
9    }
10  });
11  return res.json();
12}
13
14// Dane kategorii - powiązane z produktami
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}

Rewalidacja na żądanie z tagami

1// app/actions.ts
2'use server';
3
4import { revalidateTag, revalidatePath } from 'next/cache';
5
6// Po aktualizacji jednego produktu
7export async function updateProduct(id: string, data: ProductData) {
8  await db.product.update({ where: { id }, data });
9
10  // Rewaliduj tylko ten konkretny produkt
11  revalidateTag(`product-${id}`);
12
13  // Rewaliduj też listę produktów (np. stronę sklepu)
14  revalidateTag('products');
15}
16
17// Po masowej aktualizacji cen
18export async function bulkUpdatePrices() {
19  await db.product.updateMany({ /* ... */ });
20
21  // Jedna komenda rewaliduje WSZYSTKIE produkty we wszystkich stronach
22  revalidateTag('products');
23}

revalidateTag vs revalidatePath - kiedy co użyć

MetodaZasięgKiedy używać
revalidateTag('products')Wszystkie dane z tagiem 'products', niezależnie od stronyGdy dane pojawiają się na wielu stronach
revalidatePath('/products')Cała strona /products - wszystkie dane na niejGdy chcesz odświeżyć konkretną stronę
revalidatePath('/products', 'layout')Strona + wszystkie podstrony z tym layoutemGdy zmienił się layout lub dane współdzielone

Data Cache vs Full Route Cache vs Router Cache

Te trzy warstwy cache często są mylone. Oto kluczowe różnice:

Data Cache

Przechowuje wyniki fetch na serwerze. Kontrolujesz go przez:

1// Dane cachowane na stałe (domyślnie)
2fetch(url);
3
4// Dane cachowane na 60 sekund
5fetch(url, { next: { revalidate: 60 } });
6
7// Dane nigdy nie cachowane
8fetch(url, { cache: 'no-store' });

Czas życia: Przetrwa między żądaniami i deploymentami (chyba że rewalidowany).

Full Route Cache

Przechowuje wygenerowany HTML + RSC Payload statycznych stron. Tworzony w czasie next build.

1// Ta strona będzie w Full Route Cache (statyczna)
2export default async function AboutPage() {
3  return <h1>O nas</h1>;
4}
5
6// Ta strona NIE będzie w Full Route Cache (dynamiczna)
7export default async function DashboardPage() {
8  const session = await getSession(); // dynamic function
9  return <h1>Witaj, {session.user.name}</h1>;
10}
11
12// Wymuszenie rewalidacji Full Route Cache
13export const revalidate = 3600; // strona rewalidowana co godzinę

Czas życia: Do następnego next build lub rewalidacji.

Router Cache

Przechowuje odwiedzone strony w przeglądarce użytkownika. Dzięki niemu nawigacja wstecz jest natychmiastowa.

1'use client';
2
3import { useRouter } from 'next/navigation';
4
5export function RefreshButton() {
6  const router = useRouter();
7
8  return (
9    <button onClick={() => {
10      // Wyczyść Router Cache i pobierz świeże dane z serwera
11      router.refresh();
12    }}>
13      Odśwież dane
14    </button>
15  );
16}

Czas życia:

  • Strony statyczne: 5 minut
  • Strony dynamiczne: 30 sekund
  • Prefetched: do nawigacji

Cache scope - per-request vs cross-request

Ważne rozróżnienie, które wielu programistów pomija:

Per-request (Request Memoization)

Działa tylko w ramach jednego żądania serwerowego. Jeśli komponent A i komponent B wywołują ten sam fetch, zostanie on wykonany tylko raz:

1// Oba komponenty współdzielą ten sam fetch w ramach jednego renderowania
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(); // ten sam fetch - z memoizacji, nie z sieci!
9  return <aside>{user.avatar}</aside>;
10}

Cross-request (Data Cache)

Przetrwa między żądaniami różnych użytkowników:

1async function getPopularPosts() {
2  // Te dane są współdzielone między WSZYSTKIMI użytkownikami
3  const res = await fetch('https://api.example.com/popular', {
4    next: { revalidate: 300 } // cache na 5 minut
5  });
6  return res.json();
7}

Debugowanie cache

Problemy z cache są jednymi z najtrudniejszych do zdiagnozowania. Oto sprawdzone techniki:

1. Logowanie w 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}

Jeśli widzisz ten sam timestamp przy odświeżaniu - strona jest serwowana z Full Route Cache.

2. Nagłówek x-nextjs-cache

W trybie produkcyjnym Next.js dodaje nagłówek x-nextjs-cache do odpowiedzi:

  • HIT - strona z Full Route Cache
  • MISS - strona renderowana na żywo
  • STALE - strona z cache, ale rewalidacja została wyzwolona w tle

3. Wymuszanie dynamicznego renderowania do testów

1import { headers } from 'next/headers';
2
3export default async function DebugPage() {
4  // Użycie await headers() wymusza dynamiczne renderowanie
5  const headersList = await headers();
6
7  return (
8    <div>
9      <p>Rendered: {new Date().toISOString()}</p>
10      <p>Ten timestamp powinien się zmieniać przy każdym odświeżeniu</p>
11    </div>
12  );
13}

Podsumowanie

Zaawansowane strategie cachowania w Next.js tworzą wielowarstwowy system, który - dobrze skonfigurowany - potrafi radykalnie przyspieszyć aplikację. Kluczowe wnioski:

  1. 4 warstwy cache - Request Memoization, Data Cache, Full Route Cache, Router Cache - każda z innym zasięgiem i czasem życia
  2. unstable_cache - pozwala cachować dane z baz danych i innych źródeł nieopartych na fetch
  3. Cache tags - precyzyjna kontrola rewalidacji: revalidateTag dla danych, revalidatePath dla stron
  4. Per-request vs cross-request - Request Memoization działa w ramach jednego renderowania, Data Cache między żądaniami
  5. Debugowanie - logowanie timestampów, nagłówki x-nextjs-cache, wymuszanie dynamicznego renderowania
Kod do tej lekcji: App.tsx
1import React, { useState } from 'react';
2
3// Warstwy cache w 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 (serwer)',
23    lifetime: 'Czas trwania jednego renderowania',
24    description: 'Deduplikacja identycznych fetch w ramach jednego zadania. Jesli Header i Sidebar wolaja getUser(), fetch wykona sie raz.',
25    invalidation: 'Automatycznie po zakonczeniu renderowania',
26    example: 'const user = await getUser(); // wolany 3x, wykonany 1x',
27  },
28  {
29    id: 'data-cache',
30    name: 'Data Cache',
31    scope: 'Cross-request (serwer)',
32    lifetime: 'Miedzy zadaniami i deploymentami',
33    description: 'Trwaly cache wynikow fetch. Kontrolowany przez revalidate i cache options. Wspoldzielony miedzy uzytkownikami.',
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: 'Statyczne strony (serwer)',
41    lifetime: 'Do nastepnego buildu lub rewalidacji',
42    description: 'Cache gotowego HTML + RSC Payload dla stron statycznych. Tworzony podczas next build.',
43    invalidation: 'revalidatePath() / next build / revalidate na stronie',
44    example: "export const revalidate = 3600; // cala strona co godzine",
45  },
46  {
47    id: 'router-cache',
48    name: 'Router Cache',
49    scope: 'Przegladarka uzytkownika',
50    lifetime: 'Statyczne: 5min, Dynamiczne: 30s',
51    description: 'Cache w przegladarce dla nawigacji klienckiej. Prefetchowane strony laduja natychmiast.',
52    invalidation: 'router.refresh() / automatycznie po czasie',
53    example: "router.refresh(); // wyczysc 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 }}>Warstwy cache w 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' }}>Opis:</span> {selected.description}</div>
101          <div><span style={{ color: '#888' }}>Inwalidacja:</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        Symuluj request
113      </button>
114
115      {requestFlow.length > 0 && (
116        <div style={{ marginTop: 16 }}>
117          <div style={{ color: '#888', fontSize: 12, marginBottom: 8 }}>Przeplyw zadania przez warstwy 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}

Widzisz błąd w tej lekcji?

Sprawdź się

Odpowiedz na pytania z tej lekcji. Wybierz odpowiedź, a od razu zobaczysz, czy jest poprawna.

  1. 1. Do czego służy unstable_cache w Next.js App Router?

  2. 2. generateStaticParams służy do:

Zadania praktyczne w grze

  • Edytor kodu

    Utwórz stronę z optymalizacją: lazy loading, Suspense, proper caching, Image optimization

  • Układanie w poziomie

    Ułóż składnię eksportu dynamic w Next.js

  • Klikanie w kolejności

    Ułóż składnię komponentu Suspense z fallback w Next.js

  • Edytor kodu

    Utwórz Server Action do obsługi form submission z walidacją i database update

Przydatne artykuły