Partial Prerendering (PPR) w Next.js 16: Kompletny przewodnik po cacheComponents

Partial Prerendering łączy najlepsze cechy renderowania statycznego i dynamicznego w jednej trasie. Sprawdź, jak włączyć cacheComponents w Next.js 16, projektować granice Suspense i unikać typowych pułapek migracji.

Next.js 16 PPR Setup: cacheComponents Guide

Partial Prerendering (PPR) to chyba największa zmiana w modelu renderowania Next.js od czasu, gdy w ogóle pojawił się App Router. W Next.js 16 (październik 2025) PPR doczekał się statusu stabilnego i włącza się go teraz przez flagę cacheComponents: true. Zamiast wybierać między pełnym SSG, ISR a SSR dla całej trasy, możesz wreszcie serwować statyczną powłokę natychmiast i strumieniować dynamiczne fragmenty w tej samej odpowiedzi HTTP. Brzmi jak marzenie? Bo w zasadzie jest.

W tym przewodniku pokażę Ci, jak PPR działa pod maską, gdzie poprawnie umieszczać granice <Suspense>, jak łączyć go z dyrektywą 'use cache' i — co ważniejsze — jakich błędów unikać przy migracji istniejących tras z czystego SSR. Zaczynajmy.

Czym właściwie jest Partial Prerendering

Przed PPR Next.js musiał podjąć dość brutalną decyzję: czy dana trasa jest renderowana w czasie buildu (statycznie), czy w czasie żądania (dynamicznie). Wystarczyło wywołać cookies(), headers() albo sięgnąć po searchParams w jednym jedynym komponencie — i cała strona lądowała w trybie dynamicznym. Statyczna nawigacja, stopki, nagłówki? Tracily całą zaletę buforowania na brzegu sieci, mimo że w nich nie zmieniało się dosłownie nic.

PPR likwiduje ten kompromis. Mentalny model jest naprawdę prosty: wszystko poza <Suspense> jest statyczne, wszystko wewnątrz jest dynamiczne. W czasie buildu Next.js generuje statyczną powłokę HTML oraz tzw. postponed state dla każdej trasy z PPR. Przy żądaniu powłoka leci z CDN natychmiast, a dynamiczne fragmenty są renderowane na serwerze i strumieniowane w tej samej odpowiedzi HTTP — bez dodatkowych rundtripów, bez migotania, bez czekania.

PPR a inne strategie renderowania

  • SSG: cała strona statyczna, zero personalizacji po stronie serwera.
  • ISR: statyczna z okresową rewalidacją, ale wciąż brak dynamicznych części per-request.
  • SSR: cała strona renderowana per-request — wolny TTFB, a jedno powolne wywołanie API blokuje całą odpowiedź.
  • PPR: statyczna powłoka + strumieniowane dynamiczne dziury. Po prostu najlepsze z obu światów.

Włączanie cacheComponents w Next.js 16

W Next.js 15 PPR był eksperymentem — wymagał flagi experimental.ppr i opt-inu per trasa przez export const experimental_ppr = true. W szesnastce tej flagi już nie ma. PPR jest teraz domyślnym zachowaniem, gdy włączysz Cache Components.

// next.config.ts
import type { NextConfig } from 'next'

const nextConfig: NextConfig = {
  cacheComponents: true,
}

export default nextConfig

Po włączeniu flagi zmienia się fundamentalna zasada: żadne pobieranie danych nie jest domyślnie buforowane. Komponenty są albo jawnie cachowane przez 'use cache', albo opakowane w <Suspense> i renderowane dynamicznie w czasie żądania. Trzeciej drogi nie ma — i szczerze mówiąc, to dobre podejście, bo wymusza świadome decyzje zamiast magicznego cache.

Pierwszy przykład: statyczna powłoka + dynamiczna dziura

Najprostszy schemat PPR — statyczny nagłówek i dynamiczny moduł użytkownika:

// app/dashboard/page.tsx
import { Suspense } from 'react'
import { cookies } from 'next/headers'

async function UserPanel() {
  // dynamiczny: czytamy cookies per żądanie
  const cookieStore = await cookies()
  const sessionId = cookieStore.get('session')?.value
  const user = await fetchUser(sessionId)

  return <div>Witaj, {user.name}</div>
}

function UserSkeleton() {
  return <div className="h-6 w-32 animate-pulse bg-gray-200" />
}

export default function DashboardPage() {
  return (
    <main>
      {/* statyczna powłoka — generowana w build time */}
      <h1>Panel sterowania</h1>
      <nav>...</nav>

      {/* dynamiczna dziura — strumieniowana per żądanie */}
      <Suspense fallback={<UserSkeleton />}>
        <UserPanel />
      </Suspense>
    </main>
  )
}

Efekt? Użytkownik widzi nagłówek i nawigację natychmiast (prosto z CDN), a panel personalizacji pojawia się chwilę później, gdy serwer dokończy renderowanie. To jakościowo zupełnie inne doświadczenie niż klasyczny SSR, w którym cała strona czeka na najwolniejsze wywołanie API.

Trzy warstwy: statyczne, cachowane, dynamiczne

W Next.js 16 z PPR masz teraz trzy odrębne kategorie zawartości w obrębie jednej strony:

// app/blog/page.tsx
import { Suspense } from 'react'
import { cacheLife, cacheTag } from 'next/cache'

export default function BlogPage() {
  return (
    <>
      {/* 1. Statyczne — generowane w build, serwowane z CDN */}
      <header><h1>Nasz blog</h1></header>

      {/* 2. Cachowane — wynik 'use cache' jest częścią powłoki */}
      <BlogPosts />

      {/* 3. Dynamiczne — renderowane per żądanie, strumieniowane */}
      <Suspense fallback={<p>Ładowanie preferencji...</p>}>
        <UserPreferences />
      </Suspense>
    </>
  )
}

async function BlogPosts() {
  'use cache'
  cacheLife('hours')
  cacheTag('posts')

  const res = await fetch('https://api.example.com/posts')
  const posts = await res.json()

  return (
    <ul>
      {posts.map((p) => (
        <li key={p.id}>{p.title}</li>
      ))}
    </ul>
  )
}

Kluczowa zasada (warto ją sobie wytatuować): jeśli komponent nie jest oznaczony 'use cache' ani nie znajduje się w <Suspense>, a używa dynamicznych API, build po prostu wyrzuci błąd. Next.js 16 wymusza świadome decyzje o tym, co jest cachowane, a co dynamiczne. Koniec z przypadkowo dynamicznymi stronami.

Gdzie umieszczać granice Suspense

Najczęstszy błąd, jaki widuję w produkcyjnych aplikacjach, to po prostu zbyt szeroka granica <Suspense>. Jeśli opakujesz cały layout, tracisz korzyść z PPR — Next.js musi odroczyć wszystko, co jest w środku.

Zasada: granica jak najbliżej dynamicznego źródła

// ŹLE — cała strona staje się dynamiczna
<Suspense fallback={<PageSkeleton />}>
  <Header />
  <Sidebar />
  <UserPanel /> {/* tylko ten potrzebuje danych żądania */}
  <Footer />
</Suspense>

// DOBRZE — tylko dynamiczna część jest opóźniona
<Header />
<Sidebar />
<Suspense fallback={<UserSkeleton />}>
  <UserPanel />
</Suspense>
<Footer />

Pułapka: pusty fallback w Root Layout

Umieszczenie <Suspense> z pustym fallbackiem powyżej <body> w app/layout.tsx sprawia, że cała aplikacja jest odraczana do czasu żądania. Brak powłoki to brak natychmiastowej odpowiedzi z CDN — każde żądanie blokuje się aż do pełnego renderowania. Widziałem to w jednym projekcie i benchmarki pokazywały dramatyczny spadek wydajności, choć kod „wyglądał poprawnie”.

Pobieranie równoległe wielu dynamicznych fragmentów

Komponenty wewnątrz różnych granic <Suspense> startują równolegle. To pozwala dramatycznie skrócić TTFB dla stron z kilkoma niezależnymi źródłami danych:

// app/product/[id]/page.tsx
import { Suspense } from 'react'

export default function ProductPage({ params }: { params: { id: string } }) {
  return (
    <article>
      {/* statyczna struktura */}
      <h1>Produkt #{params.id}</h1>

      {/* trzy niezależne dziury — startują równolegle */}
      <Suspense fallback={<PriceSkeleton />}>
        <LivePrice productId={params.id} />
      </Suspense>

      <Suspense fallback={<StockSkeleton />}>
        <Inventory productId={params.id} />
      </Suspense>

      <Suspense fallback={<ReviewsSkeleton />}>
        <Reviews productId={params.id} />
      </Suspense>
    </article>
  )
}

Każda granica wysyła swój fragment do klienta, gdy jest gotowa. Wolne API recenzji? Nie blokuje już wyświetlenia ceny. To zmienia reguły gry.

cacheLife: kontrola czasu życia cache

Po włączeniu cacheComponents, dyrektywa 'use cache' wymaga jawnego określenia czasu życia. cacheLife przyjmuje albo profile, albo obiekt:

import { cacheLife } from 'next/cache'

// profile predefiniowane: 'seconds' | 'minutes' | 'hours' | 'days' | 'weeks' | 'max'
async function getCategories() {
  'use cache'
  cacheLife('days')
  return db.query('SELECT * FROM categories')
}

// szczegółowa kontrola
async function getProductPrice(id: string) {
  'use cache'
  cacheLife({
    stale: 60,        // klient może użyć przez 60s bez sprawdzania
    revalidate: 300,  // serwer rewaliduje co 5 min
    expire: 3600,     // po 1h cache wygasa całkowicie
  })
  return fetchPrice(id)
}

Migracja istniejącej trasy SSR na PPR

Załóżmy, że masz stronę produktową z pełnym SSR. Każde żądanie pobiera dane produktu, cenę i opinie. Czas TTFB to suma wszystkich wywołań API — czyli pełen koszmar.

Krok 1: zidentyfikuj części statyczne

Layout, breadcrumbs, opis produktu (który rzadko się zmienia) — to klasyczni kandydaci na powłokę statyczną lub przynajmniej cachowaną.

Krok 2: opakuj dynamiczne źródła w Suspense

// PRZED migracją
export default async function ProductPage({ params }: any) {
  const product = await fetchProduct(params.id)   // 200ms
  const price = await fetchLivePrice(params.id)   // 400ms
  const reviews = await fetchReviews(params.id)   // 800ms
  // TTFB = ~1400ms

  return <ProductLayout product={product} price={price} reviews={reviews} />
}

// PO migracji
export default function ProductPage({ params }: { params: { id: string } }) {
  return (
    <ProductShell productId={params.id}>
      <Suspense fallback={<PriceSkeleton />}>
        <LivePrice productId={params.id} />
      </Suspense>
      <Suspense fallback={<ReviewsSkeleton />}>
        <Reviews productId={params.id} />
      </Suspense>
    </ProductShell>
  )
  // TTFB = ~50ms (powłoka z CDN), reszta strumieniowana
}

async function ProductShell({
  productId,
  children,
}: {
  productId: string
  children: React.ReactNode
}) {
  'use cache'
  cacheLife('hours')
  cacheTag(`product-${productId}`)

  const product = await fetchProduct(productId)
  return (
    <article>
      <h1>{product.name}</h1>
      <p>{product.description}</p>
      {children}
    </article>
  )
}

Krok 3: zweryfikuj wynik buildu

Po next build spójrz na wskaźniki tras:

Route (app)          Size    First Load
──────────────────────────────────────
◐ /product/[id]     4.2 kB   89 kB
○ /about            1.1 kB   82 kB
● /blog/[slug]      2.3 kB   84 kB

○ Static  ● SSG  ◐ Partial Prerendering

Symbol potwierdza, że trasa korzysta z PPR — ma zarówno statyczną powłokę, jak i dynamiczne fragmenty. Jeżeli go nie widzisz, coś poszło nie tak (najczęściej dynamiczne API poza Suspense).

Obsługa błędów: graceful degradation

W tradycyjnym SSR jeden błąd API daje pełną stronę 500. Z PPR możesz pokazać statyczną powłokę i lokalny komunikat tylko w problematycznym fragmencie — reszta strony działa normalnie:

// app/dashboard/error.tsx — granica błędu per segment
'use client'

export default function Error({ error }: { error: Error }) {
  return (
    <div className="rounded border border-red-300 p-3">
      Nie udało się załadować tego modułu.
    </div>
  )
}

Jeszcze precyzyjniej — opakuj pojedynczy dynamiczny komponent w ErrorBoundary obok Suspense, by błąd jednego fragmentu nie wpływał na pozostałe. Małe rozwiązanie, ogromna różnica w UX.

PPR a self-hosting

Domyślnie 'use cache' używa cache w pamięci. Przy wielu instancjach (kilka procesów Node, klaster Kubernetes) każda replika trzyma własny cache — co oczywiście prowadzi do niespójności. Możliwe rozwiązania:

  • Custom cache handler ze wspólnym backendem (Redis, Memcached) przez opcję cacheHandler w next.config.ts.
  • Sticky sessions na load balancerze, gdy mutacje rewalidują tag tylko lokalnie.
  • Wdrożenie na Vercel albo innej platformie z natywnym wsparciem PPR (CloudFlare, Netlify) — wtedy cache jest współdzielony przez CDN i masz święty spokój.

Częste pułapki i jak ich uniknąć

1. Build failuje z błędem „dynamic data without Suspense”

Po włączeniu cacheComponents Next.js wymusza świadomą decyzję. Komponent używa cookies(), headers() albo searchParams? Musi siedzieć w <Suspense> lub mieć 'use cache' na danych. Bez wyjątków.

2. Wszystko jest dynamiczne, mimo że Suspense jest na miejscu

Sprawdź, czy nie używasz dynamicznych API poza komponentem opakowanym w Suspense. Wystarczy jedno wywołanie cookies() w komponencie nadrzędnym, by cała powłoka stała się dynamiczna. To częsta wpadka.

3. searchParams blokuje statyczne renderowanie

Od Next.js 15 searchParams jest Promise. Czytaj je wyłącznie wewnątrz komponentu opakowanego w Suspense:

// dynamiczna sekcja wyników wyszukiwania
async function SearchResults({
  searchParams,
}: {
  searchParams: Promise<{ q?: string }>
}) {
  const { q } = await searchParams
  const results = await search(q)
  return <Results items={results} />
}

export default function SearchPage({
  searchParams,
}: {
  searchParams: Promise<{ q?: string }>
}) {
  return (
    <>
      <h1>Wyszukiwarka</h1> {/* statyczne */}
      <Suspense fallback={<ResultsSkeleton />}>
        <SearchResults searchParams={searchParams} />
      </Suspense>
    </>
  )
}

4. fetch w komponentach klienckich

PPR działa wyłącznie na komponentach serwerowych. Wywołania fetch z poziomu 'use client' nie korzystają z cache Next.js — w takich miejscach używaj SWR/React Query, albo (i to zwykle lepszy pomysł) przenieś logikę pobierania do komponentu serwerowego.

Kiedy NIE używać PPR

  • W pełni statyczne strony (landing, dokumentacja) — czysty SSG jest prostszy i zupełnie wystarczy.
  • W pełni dynamiczne dashboardy z minimalną zawartością statyczną — overhead konfiguracji po prostu nie zwróci się w korzyściach.
  • Strony z silnym wymaganiem konsekwencji per-request (np. transakcje finansowe pokazujące salda) — tu klasyczny SSR ma znacznie jaśniejszą semantykę.

FAQ

Czy muszę przepisywać wszystkie trasy, żeby włączyć cacheComponents?

Tak — flaga jest globalna. Po jej włączeniu każda trasa korzystająca z dynamicznych API musi mieć granicę <Suspense> lub jawne 'use cache'. W praktyce migracja często odsłania trasy, które niepotrzebnie były dynamiczne i można je przerobić na statyczne albo cachowane. Czasem to wręcz okazja do sprzątania.

Jaka jest różnica między PPR a streamingiem SSR?

Streaming SSR generuje całą stronę per żądanie i strumieniuje ją fragmentami. PPR preprocesuje statyczną część w build time, serwuje ją z CDN natychmiast, a tylko dynamiczne fragmenty są renderowane per żądanie. Różnica jest najwidoczniejsza w TTFB — PPR daje ~50ms, streaming SSR zwykle kilkaset.

Czy PPR działa z Pages Routerem?

Nie. PPR jest funkcją wyłącznie App Routera, ponieważ opiera się na React Server Components i Suspense w drzewie serwerowym. Migracja na App Router jest po prostu warunkiem koniecznym.

Czy PPR zwiększa czas buildu?

Marginalnie. Build musi wygenerować zarówno powłokę statyczną, jak i postponed state. W praktyce wzrost to kilka–kilkanaście procent względem SSG dla tych samych tras, ale zysk w runtime jest nieporównywalnie większy.

Co się stanie, jeśli zapomnę cacheLife przy 'use cache'?

Build wyrzuci błąd. Od Next.js 16 z cacheComponents: true każda funkcja oznaczona 'use cache' musi mieć jawnie zadeklarowany czas życia — to celowy mechanizm wymuszający świadome decyzje o świeżości danych. Wkurzające na początku, ale w dłuższej perspektywie to świetne zabezpieczenie.

Podsumowanie

Partial Prerendering w Next.js 16 to nie kolejny eksperymentalny gadżet — to nowy domyślny model renderowania dla aplikacji App Routera. Sekret leży w precyzyjnym rozmieszczeniu granic <Suspense>: im bliżej dynamicznego źródła danych, tym większy fragment strony serwujesz natychmiast z CDN.

Włącz cacheComponents: true, oznacz cachowalne źródła przez 'use cache' z cacheLife, owijaj dynamiczne dziury w <Suspense> z sensownymi fallbackami — i obserwuj, jak Twoje TTFB spada do dziesiątek milisekund nawet na stronach z personalizacją. Honestly, gdy raz zobaczysz te liczby w produkcji, ciężko będzie wrócić do klasycznego SSR.

O Autorze Marcus Okonkwo

Marcus picked up React in 2017 and has been writing it professionally ever since. Five years at Stripe on the Dashboard team, where he led the migration of the billing UI off a legacy webpack 4 setup onto Next.js with module federation for the embedded merchant components. He shipped the first internal RSC prototype there in late 2023, which mostly worked. Now he's a staff engineer at a logistics startup in Austin, owning the customer-facing Next.js app and the design system that backs it. He spends a disproportionate amount of time thinking about route group conventions, parallel routes for modal UX, and why next/image keeps surprising people on Cloudflare's image resizing. His writing favors annotated code over prose. Seven years of React, two of those deep in App Router land.