Cache'owanie i renderowanie w Next.js w 2026: z wersji 14 na 16

Te same pytania co w 2025, nowe odpowiedzi: SSG, ISR, SSR, PPR i rewalidacja w Next.js 16 z Cache Components. Plus co zostało z mojej pułapki.

Te same pytania, nowe odpowiedzi

W maju 2025 opisałem, jak Next.js renderuje i cache'uje strony: SSG, ISR, SSR, PPR, CSR, react.cache() i ręczną rewalidację, każde z przykładem. Ten tekst był pisany pod Next.js 14 i model, w którym cache'owaniem sterowały export const revalidate i export const dynamic.

Od Next.js 16.4 (6 października 2026) zespół Next.js rekomenduje dla każdej aplikacji Cache Components, a create-next-app włącza je od razu. W Next.js 17 ma to być jedyny model.

Dlatego ten post idzie dokładnie tą samą drogą co tamten. Sekcja po sekcji: najpierw jak to robiłem w Next.js 14, potem jak to robię dziś w 16 i dlaczego. Pułapka, która w 2025 kosztowała mnie najwięcej czasu, też jest tam, gdzie była, w sekcji o ISR. Tylko wygląda już inaczej.

Wszystkie komunikaty błędów i wyniki pomiarów w tym tekście pochodzą z małego demo, które zbudowałem w Next.js 16.4.6 (next build + next start).


Co się zmieniło, w jednym akapicie?

W Next.js 14 strona była domyślnie statyczna, a ty wyłączałeś cache tam, gdzie przeszkadzał. W 16 jest odwrotnie: wszystko jest dynamiczne, a cache włączasz jawnie tam, gdzie go chcesz, dyrektywą "use cache". Next.js składa z tego statyczną powłokę strony i dynamiczne fragmenty w <Suspense>, które dociągają się przy żądaniu.

Next.js 14 kontra 16: jeden czas życia dla całej strony kontra statyczna powłoka, w której każdy fragment ma własny cacheLife
Next.js 14 kontra 16: jeden czas życia dla całej strony kontra statyczna powłoka, w której każdy fragment ma własny cacheLife

Cały model włączają dwie flagi. Bez partialPrefetching Next.js 16.4 wypisuje ostrzeżenie.

next.config.tstypescript
import type { NextConfig } from "next";

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

export default nextConfig;

Czym jest cache w Next.js dziś?

Idea się nie zmieniła: zapamiętujesz wynik, żeby nie liczyć go drugi raz. Zmieniło się to, kto decyduje. W 14 decydowała konfiguracja segmentu i opcje fetch. W 16 decydujesz ty, trzema wywołaniami w kodzie:

  • "use cache" oznacza, co jest cache'owane: funkcję z danymi albo cały komponent;
  • cacheLife() mówi, jak długo;
  • cacheTag() mówi, jak to unieważnić.
tsx
import { cacheLife, cacheTag } from "next/cache";

// Poziom danych: jedna funkcja, używana na wielu stronach
export async function getPosts() {
  "use cache";
  cacheLife("days");
  cacheTag("posts");
  return db.post.findMany();
}

// Poziom UI: cache'owany jest wyrenderowany komponent
async function PostList() {
  "use cache";
  cacheLife("hours");
  const posts = await getPosts();
  return (
    <ul>
      {posts.map((p) => <li key={p.id}>{p.title}</li>)}
    </ul>
  );
}

cacheLife przyjmuje gotowy profil albo obiekt z stale, revalidate i expire:

Profilstalerevalidateexpire
seconds30 s1 s1 min
minutes5 min1 min1 h
hours5 min1 h1 dzień
days5 min1 dzień1 tydzień
weeks5 min1 tydzień30 dni
max5 min30 dni1 rok

Zapamiętaj jeden próg, wraca w każdej sekcji niżej. Cache z revalidate: 0 albo expire poniżej 5 minut jest krótkożyjący: nie trafia do powłoki i staje się dynamiczną dziurą. Z gotowych profili dotyczy to tylko seconds.


Strona statyczna (SSG)

W Next.js 14 strona bez dynamicznych danych była statyczna sama z siebie, a jeśli chciałeś mieć pewność, wymuszałeś to:

app/about/page.tsx (Next.js 14)tsx
export const dynamic = "force-static";

export default function About() {
  return <h1>O mnie</h1>;
}

W Next.js 16 wystarczy, że strona nie czyta niczego z żądania. Trafia wtedy w całości do statycznej powłoki przy buildzie. Dane z bazy czy CMS-a dokładasz w "use cache", żeby też weszły do powłoki:

app/about/page.tsx (Next.js 16)tsx
import { cacheLife } from "next/cache";

async function Bio() {
  "use cache";
  cacheLife("max");
  const bio = await cms.getBio();
  return <p>{bio.text}</p>;
}

export default function About() {
  return (
    <main>
      <h1>O mnie</h1>
      <Bio />
    </main>
  );
}

force-static nie ma prostego odpowiednika. Zamiast wymuszać statyczność, możesz jej pilnować: ensureStatic wywala build, jeśli na stronę trafi cokolwiek dynamicznego. Najlepiej dodać go w layoucie całej sekcji, np. bloga:

app/blog/layout.tsxtsx
export const ensureStatic = "navigation";

Sprawdziłem to w Next.js 16.4.6: dodałem do takiej strony komponent czytający cookies() w <Suspense>. Build się zatrzymał:

plaintext
Error: Route "/static-guard": Next.js encountered uncached or runtime data on a route that must be fully static.
This route is configured to be fully static, but some data requires rendering at request time.

To dokładnie ta kontrola, której brakowało mi w 2025.


Przyrostowa regeneracja (ISR)

W Next.js 14 ISR to była statyczna strona z zegarem. revalidate mówił, co ile sekund ją przebudować, a generateStaticParams które ścieżki wygenerować przy buildzie:

app/blog/[slug]/page.tsx (Next.js 14)tsx
export const revalidate = 3600;

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

export default async function Post({ params }: { params: { slug: string } }) {
  const post = await getPost(params.slug);
  return <Article post={post} />;
}

W Next.js 16 zegar przenosi się z pliku do danych. generateStaticParams zostaje, a revalidate zastępuje cacheLife:

app/blog/[slug]/page.tsx (Next.js 16)tsx
import { cacheLife, cacheTag } from "next/cache";

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

async function getPost(slug: string) {
  "use cache";
  cacheLife("hours");
  cacheTag("posts", `post-${slug}`);
  return cms.getPost(slug);
}

export default async function Post({ params }: { params: Promise<{ slug: string }> }) {
  const { slug } = await params;
  const post = await getPost(slug);
  return <Article post={post} />;
}

Starego export const revalidate nie da się zostawić. Przy włączonych Cache Components build kończy się błędem, osobno dla revalidate i dla dynamic:

plaintext
Error: Route segment config "revalidate" is not compatible with `nextConfig.cacheComponents`. Please remove it.

To dobra wiadomość przy migracji: kompilator sam pokaże wszystkie miejsca do przepisania.

Pułapka z 2025: najkrótszy czas wygrywa. Co z niej zostało?

W 2025 ostrzegałem: Next.js bierze najkrótszy revalidate z całego drzewa renderowania. Jeden fetch z minutą w layoucie i strona, która miała żyć tydzień, przebudowuje się co minutę. Ostrzeżenia brak. Jedyny sposób to patrzeć na nagłówek age w zakładce Network.

W nowym modelu ta pułapka rozpadła się na dwie.

Wersja głośna. Krótkożyjący cache w środku zewnętrznego "use cache", który nie ma własnego cacheLife, wywala prerender:

tsx
async function LiveStatus() {
  "use cache";
  cacheLife("seconds");
  const status = await getStatus();
  return <p>Status: {status.state}</p>;
}

export default async function DocsPage() {
  "use cache"; // brak cacheLife
  const docs = await getDocs();
  return (
    <main>
      <Docs docs={docs} />
      <LiveStatus />
    </main>
  );
}
plaintext
Error: Route "/docs": A nested `"use cache"` with a short `expire` (under 5 minutes) is inside
an outer `"use cache"` that has no `cacheLife()`. Add `cacheLife()` to the outer one to choose
whether to prerender it with a longer `expire` or keep it dynamic with a short `expire`.

To dobra zmiana. To, czego w 2025 trzeba było szukać w nagłówkach, teraz zatrzymuje build.

Wersja cicha. Kiedy zewnętrzny scope ma własny cacheLife, wygrywa on, nawet jeśli jest dłuższy. Pasek statusu z minutes w layoucie z hours pokazuje dane sprzed godziny:

tsx
export default async function DocsLayout({ children }: { children: React.ReactNode }) {
  "use cache";
  cacheLife("hours"); // ten decyduje
  return (
    <section>
      <StatusBar /> {/* "use cache" + cacheLife("minutes") */}
      {children}
    </section>
  );
}

Brak błędu, brak ostrzeżenia, brak wpisu w dev overlay. Zasada z 2025 się odwróciła: wtedy wygrywał najkrótszy czas, teraz wygrywa zewnętrzny.

Ten sam pasek statusu: wewnątrz layoutu z cacheLife("hours") stoi w miejscu, obok layoutu odświeża się co minutę
Ten sam pasek statusu: wewnątrz layoutu z cacheLife("hours") stoi w miejscu, obok layoutu odświeża się co minutę

Sprawdziłem to w Next.js 16.4.6 na buildzie produkcyjnym (next start). Przez 16 minut przeładowywałem co minutę dwie wersje tej samej strony:

PrzeładowaniePasek wewnątrz layoutu z hoursPasek obok layoutu
08:3808:35:5308:37:10
08:4108:35:5308:40:22
08:4408:35:5308:43:22
08:4708:35:5308:46:22
08:5008:35:5308:49:22
08:5308:35:5308:52:22

08:35:53 to moment builda. Pasek wewnątrz layoutu nie zmienił się ani razu. Pasek obok pokazuje czas sprzed minuty, bo tak działa stale-while-revalidate: przeładowanie dostaje poprzednią wersję i zleca policzenie nowej.

Jedno miejsce jednak tę pułapkę zdradza: tabela tras na końcu next build. Kolumna Revalidate pokazuje czas, który naprawdę obowiązuje dla trasy:

plaintext
Route (app)          Revalidate  Expire
├ ○ /trap2-fixed             1m      1h
├ ○ /trap2-silent            1h      1d

Jeśli pasek miał się odświeżać co minutę, a trasa ma 1h, to właśnie wpadłeś w wersję cichą.

Rozwiązanie dla obu wersji jest to samo: nie zagnieżdżaj krótszego cache'u w dłuższym. Postaw je obok siebie:

tsx
export default function DocsLayout({ children }: { children: React.ReactNode }) {
  return (
    <section>
      <DocsShell /> {/* "use cache" + cacheLife("hours") */}
      <Suspense fallback={<p>Ładuję status…</p>}>
        <StatusBar /> {/* "use cache" + cacheLife("minutes") */}
      </Suspense>
      {children}
    </section>
  );
}

Renderowanie na żądanie (SSR)

W Next.js 14 całą stronę przełączałeś w tryb dynamiczny jednym eksportem:

app/dashboard/page.tsx (Next.js 14)tsx
export const dynamic = "force-dynamic";

export default async function Dashboard() {
  const user = await getCurrentUser();
  return <h1>Cześć, {user.name}</h1>;
}

W Next.js 16 dynamiczność jest domyślna, ale już nie dla całej strony, tylko dla fragmentu. Część czytająca żądanie idzie do <Suspense>, reszta zostaje w statycznej powłoce:

app/dashboard/page.tsx (Next.js 16)tsx
import { Suspense } from "react";
import { cookies } from "next/headers";

async function Greeting() {
  const session = (await cookies()).get("session")?.value;
  const user = await getUserBySession(session);
  return <h1>Cześć, {user.name}</h1>;
}

export default function Dashboard() {
  return (
    <main>
      <Suspense fallback={<h1>Cześć!</h1>}>
        <Greeting />
      </Suspense>
      <DashboardNav /> {/* statyczne, w powłoce */}
    </main>
  );
}

Jeśli dynamiczny fragment nie jest w <Suspense>, Next.js zgłasza błąd zamiast po cichu renderować całą stronę na żądanie.

Uwaga: znacznik czasu też jest danymi z żądania

new Date(), Date.now(), Math.random() i crypto.randomUUID() poza cache'em blokują prerender. Najpierw zdecyduj, o który czas ci chodzi:

tsx
import { connection } from "next/server";

// Czas tego żądania: dynamiczna dziura
async function RequestTime() {
  await connection();
  return <p>{new Date().toISOString()}</p>;
}

// Czas zbudowania treści: cache'owany razem z nią
async function BuiltAt() {
  "use cache";
  cacheLife("hours");
  return <p>{new Date().toISOString()}</p>;
}

Bez connection() build zatrzymuje się z bardzo konkretnym komunikatem, który od razu podaje trzy wyjścia. W next dev ten sam błąd pojawia się przy pierwszym wejściu na stronę:

plaintext
Error: Route "/time": Next.js encountered the unstable value `new Date()` while prerendering.
Ways to fix this:
  - [dynamic] Render at request time by adding a dynamic data access (e.g. `await connection()`)
  - [cache] Prerender and cache the value with "use cache"
  - [client] Render the value on the client with "use client"

Strona mieszana (dawny PPR)

W 2025 Partial Prerendering było osobną, eksperymentalną funkcją. W Next.js 16 to po prostu sposób, w jaki działa każda strona: statyczna powłoka plus dynamiczne dziury w <Suspense>. Nie ma czego włączać osobno.

Strona produktu w Next.js 16: co trafia do statycznej powłoki, a co jest dynamiczną dziurą
Strona produktu w Next.js 16: co trafia do statycznej powłoki, a co jest dynamiczną dziurą

Dziura to komponent serwerowy, który czyta dane z żądania (cookies(), headers(), searchParams, connection()) albo używa krótkożyjącego cache'u.

app/product/[id]/page.tsx (Next.js 16)tsx
import { Suspense } from "react";
import { cookies } from "next/headers";
import { cacheLife } from "next/cache";

async function ProductInfo({ id }: { id: string }) {
  "use cache";
  cacheLife("days");
  const product = await getProduct(id);
  return <ProductDetails product={product} />; // w powłoce
}

async function Cart() {
  const cartId = (await cookies()).get("cart")?.value;
  const cart = await getCart(cartId);
  return <CartBadge count={cart.items.length} />; // dziura
}

export default async function ProductPage({ params }: { params: Promise<{ id: string }> }) {
  const { id } = await params;
  return (
    <main>
      <ProductInfo id={id} />
      <Suspense fallback={<CartBadge count={0} />}>
        <Cart />
      </Suspense>
    </main>
  );
}

Poprawka do mojego przykładu z 2025

W tamtym poście jako przykład PPR pokazałem komponent kliencki, który pobiera dane w useEffect, owinięty w <Suspense>. To nie był PPR. Komponent kliencki z useEffect nie zawiesza renderu: trafia do powłoki i pobiera dane dopiero po hydracji w przeglądarce. To był zwykły CSR w statycznej powłoce. Dynamiczna część PPR to komponent serwerowy, jak Cart powyżej.


Renderowanie po stronie klienta (CSR)

To jedyna sekcja, w której prawie nic się nie zmieniło. Komponent z "use client", który pobiera dane w przeglądarce, działa w 16 tak samo jak w 14. Zmieniło się tylko otoczenie: reszta strony wokół niego jest teraz statyczną powłoką albo dynamiczną dziurą na serwerze.

Dlatego CSR ma dziś węższe zastosowanie niż w 2025. Ma sens dla danych, które zmieniają się na oczach użytkownika (wyszukiwarka z podpowiedziami, odświeżany wykres) albo które zależą od czegoś, co zna tylko przeglądarka (np. localStorage). Dane, które są gotowe w chwili żądania, lepiej pobrać w komponencie serwerowym w <Suspense>: użytkownik dostaje HTML bez czekania na JavaScript.


react.cache() kontra "use cache"

W Next.js 14 react.cache() było sposobem na deduplikację: dwa komponenty pytające o tego samego użytkownika w jednym żądaniu robiły jedno zapytanie zamiast dwóch.

W Next.js 16 react.cache() robi dokładnie to samo i nadal się przydaje. Trzeba tylko pamiętać, że to są dwa różne narzędzia:

react.cache()"use cache"
Jak długo żyjejedno żądaniewedług cacheLife, między żądaniami
Kto widzi wyniktylko to żądaniewszyscy użytkownicy
Może czytać cookies/sesjętaknie (chyba że "use cache: private")
Do czegodeduplikacja danych użytkownikadane wspólne dla wszystkich
lib/user.tstsx
import "server-only";
import { cache } from "react";
import { cookies } from "next/headers";

export const getCurrentUser = cache(async () => {
  const session = (await cookies()).get("session")?.value;
  return db.user.findBySession(session); // bezpośrednio do bazy
});

Jeszcze jedna poprawka do 2025: tamten przykład pobierał użytkownika przez fetch do własnego /api/user z komponentu serwerowego. To zbędny skok HTTP do samego siebie. Komponent serwerowy może zapytać bazę albo serwis bezpośrednio, jak wyżej.


Ręczna rewalidacja

W Next.js 14 tagowałeś fetch i unieważniałeś tag jedną funkcją, niezależnie od tego, kto ją wołał:

Next.js 14tsx
const res = await fetch(url, { next: { tags: ["posts"] } });

// gdziekolwiek: Server Action albo Route Handler
revalidateTag("posts");

W Next.js 16 tag dajesz w scope "use cache" przez cacheTag, a unieważnianie rozdziela się na dwa przypadki.

Redaktor kliknął „Zapisz” w twojej aplikacji (Server Action). Używasz updateTag: tag wygasa, a następny render czeka na świeże dane. Redaktor od razu widzi swoją zmianę.

app/actions.tstsx
"use server";
import { updateTag } from "next/cache";

export async function savePost(formData: FormData) {
  await db.post.update(/* ... */);
  updateTag("posts");
}

Headless CMS wysłał webhook (Route Handler). updateTag działa tylko w Server Actions, więc tu używasz revalidateTag z profilem:

app/api/revalidate/route.tstypescript
import { revalidateTag } from "next/cache";

export async function POST(request: Request) {
  // najpierw zweryfikuj podpis webhooka
  revalidateTag("posts", "max");
  return Response.json({ ok: true });
}

Z "max" to stale-while-revalidate: pierwsze żądanie po webhooku dostaje jeszcze starą treść i uruchamia odświeżenie. W demo pierwsze przeładowanie po webhooku pokazało starą treść, a nowa pojawiła się dopiero przy drugim. Jeśli redaktor opublikuje w CMS-ie i od razu odświeży stronę, raz zobaczy starą wersję. Uprzedź go, zanim zgłosi błąd.

revalidatePath nadal działa, gdy chcesz odświeżyć konkretny adres, a nie tag.


Co się zmienia na produkcji?

Wszystko powyżej działa na next start na laptopie. Produkcja dokłada dwie rzeczy, których lokalnie nie widać.

Domyślny cache żyje w pamięci instancji. To, co zostało wyrenderowane przy buildzie, siedzi w statycznej powłoce i jest bezpieczne. Wpis "use cache" utworzony w trakcie działania (dynamiczna ścieżka, rewalidacja) żyje w pamięci instancji, która go policzyła. Na serverless następne żądanie może trafić na zimną instancję i liczyć wszystko od nowa. "use cache: remote" przenosi wpis do współdzielonego cache'u, ale każde trafienie to wtedy żądanie sieciowe, więc opłaca się tylko przy wysokim hit rate.

Deploy zabiera ze sobą tylko to, co powstało przy buildzie. Strony wyrenderowane w czasie builda (np. ścieżki z generateStaticParams) są częścią deployu i od razu serwowane są szybko. W demo pierwsze żądanie do takiej strony trwało lokalnie 34 ms, drugie 3 ms, a w logach nie było żadnego ponownego liczenia danych. Przepadają natomiast wpisy utworzone w trakcie działania: nowe ścieżki spoza generateStaticParams i wszystko, co zostało przeliczone po rewalidacji. Klucze cache'u zawierają build ID, więc po deployu te wpisy liczą się od nowa, a płacą za to pierwsi użytkownicy.


Którą strategię wybrać w 2026?

PrzypadekNext.js 14Next.js 16
Strona „O mnie”, regulaminforce-staticnic nie czytaj z żądania; ensureStatic w layoucie
Blog, dokumentacja z CMS-aISR, revalidate"use cache" + cacheTag + cacheLife("max") + webhook
Katalog produktówISR, revalidate: 3600"use cache" + cacheLife("hours")
Strona produktu z koszykiemPPR (eksperyment)powłoka z produktem + koszyk w <Suspense>
Panel użytkownikaforce-dynamicdynamiczne fragmenty w <Suspense>, react.cache()
Wyszukiwarka z podpowiedziamiCSRCSR, bez zmian

Pełne porównanie

StrategiaŚwieżość danychWydajnośćSEOKontrola cache'u w 16
Statyczna powłokaz buildunajwyższatakensureStatic
"use cache" (dawne ISR)według cacheLifewysokatakcacheLife, cacheTag
Dynamiczna dziura (dawne SSR)przy każdym żądaniuzależy od źródłatak<Suspense>, connection()
CSRw przeglądarcezależy od klientasłabepoza Next.js
react.cache()jedno żądaniededuplikacjanie dotyczynie dotyczy
Schemat decyzyjny: którą strategię cache'owania wybrać w Next.js 16
Schemat decyzyjny: którą strategię cache'owania wybrać w Next.js 16

Szybkie zasady na 2026

  1. Każdy "use cache" dostaje jawny cacheLife.
  2. Nie zagnieżdżaj krótszego cache'u w dłuższym. Stawiaj je obok siebie.
  3. Treść z CMS-a: cacheTag + max + webhook z revalidateTag(tag, "max"). updateTag zostaw dla Server Actions.
  4. Sekcja, która musi być statyczna, dostaje ensureStatic = "navigation" w layoucie.
  5. Cache'owanie sprawdzaj na buildzie produkcyjnym i na platformie, na której wdrażasz, a nie w next dev. Zerknij też na kolumnę Revalidate w wyniku next build.

Next.js 17 ma zrobić z Cache Components jedyny model. Jeśli migrujesz teraz, robisz to raz.