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: cacheComponents i Suspense 2026

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 Editorial Team

Our team of expert writers and editors.