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ę
cacheHandlerwnext.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.