Next.js 16, Ekim 2025'te yayımlandığında uzun zamandır deneysel olarak piştiği kazanı kapatıp Partial Prerendering (PPR)'yi Cache Components adıyla üretime açtı. Kısaca: PPR, bir sayfanın statik kabuğunu build anında üretip dinamik parçaları istek zamanında Suspense sınırları üzerinden stream eden hibrit bir render stratejisi. Sonuç? Edge önbelleğinden saniyeler yerine milisaniyeler içinde dönen bir LCP — ve tek bir upstream hatasının tüm sayfayı çökertmediği, aşamalı olarak iyileşen bir deneyim.
Şahsen bunu üretim bir e-ticaret projesinde ilk kez denediğimde LCP'nin 1.8 sn'den 180 ms'ye düştüğünü görünce ekran başında hafif bir "hmm, gerçekten mi?" anı yaşadım. Gerçekti.
Bu rehberde PPR'nin zihinsel modelini, Next.js 15'teki incremental modunu, Next.js 16 ile gelen cacheComponents bayrağını, Suspense sınır yerleşimini, edge'de CDN davranışını, dinamik API'lerle etkileşimi (cookies, headers, searchParams) ve üretimde başınıza gelecek hata yönetimi kalıplarını çalışan örneklerle ele alacağız. Mevcut bir SSR uygulamasını PPR'ye nasıl taşırsınız — onu da adım adım göstereceğim.
Partial Prerendering Nedir ve Neden Önemli?
Klasik Next.js render modelinde bir rota ya tamamen statiktir (SSG/ISR) ya da tamamen dinamiktir (SSR). Ortası yok. Sayfanızda tek bir dinamik veri (mesela giriş yapmış kullanıcının adı, sepet sayısı, kişiye özel bir öneri) varsa tüm rota SSR'a düşer; bu da TTFB'yi doğrudan upstream servisinizin keyfine bağlar.
PPR tam olarak bu ikilemi kırıyor. Tek bir rota iki parçaya bölünüyor:
- Statik kabuk (static shell): Build zamanında üretilip CDN'de önbelleklenen HTML. Layout, navigasyon, hero, footer, pazarlama metni — değişmeyen ne varsa burada.
- Dinamik delikler (dynamic holes):
<Suspense>sınırının içine alınıp istek anında origin'de render edilen ve aynı HTTP yanıtına streamlenen bölümler. Kullanıcı selamlaması, anlık fiyat, stok, sepet… bu kategoriye giriyor.
Zihinsel model basit, hatta yalın: Suspense sınırının dışındaki her şey statik, içindeki her şey dinamik. Sınırın fallback'i build zamanında statik kabuğun bir parçası olarak prerender edilir; gerçek içerik istek zamanında stream ile gelir. Hepsi bu.
SSR, SSG ve PPR Karşılaştırması
| Strateji | Build zamanı | TTFB | Kişiselleştirme | Hata dayanıklılığı |
|---|---|---|---|---|
| SSG / ISR | Tüm HTML | ~edge latency | Yok | Yüksek |
| SSR | Yok | Upstream'e bağlı | Tam | Düşük (tek hata = 500) |
| PPR | Statik kabuk + postponedState | ~edge latency | Suspense içinde tam | Yüksek (kabuk sağlam kalır) |
Next.js 15'te Incremental PPR
Next.js 15, PPR'yi incremental modda üretim için kararlı olarak işaretledi. Global ppr: true hâlâ deneysel; ama rota bazında experimental_ppr = true kararlı. Yani mevcut bir uygulamayı parça parça taşımak istiyorsanız başlamak için ideal nokta burası.
// next.config.ts
import type { NextConfig } from 'next'
const nextConfig: NextConfig = {
experimental: {
ppr: 'incremental',
},
}
export default nextConfig
Sonrasında PPR açmak istediğiniz her rotanın page.tsx ya da layout.tsx dosyasında opt-in yaparsınız:
// app/urunler/[slug]/page.tsx
export const experimental_ppr = true
import { Suspense } from 'react'
import ProductHero from './product-hero'
import LivePrice from './live-price'
import PriceSkeleton from './price-skeleton'
export default async function ProductPage({
params,
}: {
params: Promise<{ slug: string }>
}) {
const { slug } = await params
return (
<main>
{/* Statik kabuk: build zamanında prerender edilir */}
<ProductHero slug={slug} />
{/* Dinamik delik: istek anında streamlenir */}
<Suspense fallback={<PriceSkeleton />}>
<LivePrice slug={slug} />
</Suspense>
</main>
)
}
LivePrice içinde cookies() veya headers() kullanıldığında, ya da cache: 'no-store' ile fetch yapıldığında Next.js bu bileşeni otomatik olarak dinamik sayar ve Suspense sınırında "postpone" eder. Tek bir uyarı: sınırın dışına sakın dinamik API koymayın — koyarsanız rotanın tamamı SSR'a düşer ve PPR'den eser kalmaz.
Next.js 16: Cache Components ile Kararlı PPR
Next.js 16, PPR'yi cacheComponents bayrağı altında stabil hâle taşıdı. Bu yeni model use cache direktifiyle birlikte çok daha ifadeli bir önbellek kontrolü sunuyor.
// next.config.ts
import type { NextConfig } from 'next'
const nextConfig: NextConfig = {
cacheComponents: true,
}
export default nextConfig
Burada kritik nokta şu: cacheComponents: true ile varsayılan davranış tersine dönüyor. Her şey artık dinamik, statik yapmak istediğiniz bileşene ya da fonksiyona use cache direktifi eklersiniz. Zihinsel geçişin biraz alışmaya ihtiyacı var (ilk gün bana da garip geldi), ama bir kez oturunca çok daha öngörülebilir.
// app/urunler/[slug]/page.tsx
import { Suspense } from 'react'
async function ProductShell({ slug }: { slug: string }) {
'use cache'
const product = await fetch(`https://api.example.com/p/${slug}`).then(r => r.json())
return (
<section>
<h1>{product.name}</h1>
<p>{product.description}</p>
</section>
)
}
async function LiveInventory({ slug }: { slug: string }) {
const res = await fetch(`https://api.example.com/stock/${slug}`, {
cache: 'no-store',
})
const { inStock, count } = await res.json()
return <p>{inStock ? `${count} adet stokta` : 'Tükendi'}</p>
}
export default async function Page({
params,
}: {
params: Promise<{ slug: string }>
}) {
const { slug } = await params
return (
<main>
<ProductShell slug={slug} />
<Suspense fallback={<p>Stok bilgisi yükleniyor…</p>}>
<LiveInventory slug={slug} />
</Suspense>
</main>
)
}
use cache Direktifinin Üç Varyantı
use cache direktifini üç seviyede kullanabilirsiniz:
- Dosya seviyesi: Dosyanın en üstüne yazılırsa tüm export'lar önbelleklenir.
- Bileşen seviyesi: Async Server Component'in ilk satırına yazılır — o bileşen önbelleklenir.
- Fonksiyon seviyesi: Veri çeken bir yardımcı fonksiyonda — sadece o çağrı memoize edilir.
// app/lib/products.ts
import { cacheLife, cacheTag } from 'next/cache'
export async function getProduct(slug: string) {
'use cache'
cacheLife('hours') // 1 saat geçerli
cacheTag(`product:${slug}`) // on-demand revalidation için etiket
const res = await fetch(`https://api.example.com/p/${slug}`)
return res.json()
}
cacheLife ile ömrü (seconds, minutes, hours, days, weeks, max ya da özel profil), cacheTag ile de etiket atarsınız. Sonra revalidateTag() ile hedefli invalidasyon yapabilirsiniz — yani "sadece bu ürünü yenile, diğerlerine dokunma" demek mümkün.
Suspense Sınırlarını Doğru Yerleştirmek
PPR'den en iyi verimi almanın yolu, Suspense sınırını doğru yere koymaktan geçiyor. Genel kural: sınırı, dinamik veriye ihtiyaç duyan en küçük bileşenin hemen etrafına koyun. Sınır ne kadar dar olursa, statik kabuk o kadar büyük, LCP o kadar hızlı.
Kötü örnek: Sınır çok geniş
// Tüm sayfa dinamikleşir, statik kabuk yok olur
<Suspense fallback={<FullPageSkeleton />}>
<Header />
<Hero />
<UserGreeting /> {/* sadece bu dinamik */}
<Footer />
</Suspense>
İyi örnek: Sınır dar
<Header />
<Hero />
<Suspense fallback={<GreetingSkeleton />}>
<UserGreeting />
</Suspense>
<Footer />
İkinci versiyonda Header, Hero ve Footer statik kabukta kalır; yalnızca UserGreeting istek anında streamlenir. Fark gözle görülür.
Dinamik Bileşenleri Tanımlamak
Bir bileşenin dinamik sayılması için içinde şunlardan biri olmalı:
cookies(),headers(),draftMode()— istek bazlı API'lersearchParams— query string'e bağlı renderfetch(url, { cache: 'no-store' })— önbelleksiz veri- Next.js 16'da ayrıca:
use cacheile işaretlenmemiş her async Server Component otomatik dinamiktir.
CDN Davranışı: Statik Kabuk + Dinamik Stream
PPR, statik ve dinamik içeriği tek bir HTTP yanıtında birleştirir. Vercel, Cloudflare ya da HTTP streaming destekleyen herhangi bir edge platformunda iş şu sırayla yürür:
- İstek CDN edge'ine ulaşır.
- CDN, önbellekteki statik kabuğu anında yanıta eklemeye başlar (ilk byte milisaniyeler içinde).
- Paralel olarak origin sunucuya bir resume isteği gider;
postponedStateile birlikte dinamik bölümleri render etmesi istenir. - Origin sadece dinamik delikleri render edip stream yanıtı CDN'e iletir.
- CDN iki akışı birleştirir ve kullanıcıya tek, sürekli bir HTML yanıtı olarak sunar.
Sonuç? LCP çoğunlukla 100 ms altında. Çünkü hero ve ilk görünen alan zaten statik kabukta. Kullanıcı içeriği görmeye ve okumaya başlarken dinamik delikler arkada sessizce doluyor.
Hata Yönetimi ve Dayanıklılık
SSR'da upstream bir API çöktüğünde ne olur? Tüm sayfa 500 döner. PPR'de ise dinamik delikteki hata yalnızca o Suspense sınırını etkiler; statik kabuk yerinde durmaya devam eder. Bu, bence PPR'nin en çok hafife alınan avantajlarından biri.
// app/urunler/[slug]/error.tsx
'use client'
export default function Error({
error,
reset,
}: {
error: Error
reset: () => void
}) {
return (
<div role="alert">
<p>Stok bilgisi alınamadı.</p>
<button onClick={reset}>Tekrar dene</button>
</div>
)
}
Hata sınırını Suspense sınırının üstüne, yani ilgili rota segmentine koyun. Böylece LiveInventory çökse bile ProductShell, navigasyon ve layout kullanıcıya görünmeye devam eder.
PPR ile Uyumsuz Kalıplar
Şu durumlar ya PPR'yi tamamen bozar ya da rotayı olduğu gibi dinamikleştirir:
- Üst seviye (Suspense dışı) bir bileşende
cookies()ya daheaders()çağırmak. - Layout düzeyinde dinamik API kullanmak — bu tek başına tüm alt rotaları dinamikleştirir.
generateStaticParamsile çakışan dinamik segmentler.dynamic = 'force-dynamic'export etmek — PPR'yi tamamen devre dışı bırakır.revalidate = 0— rotayı düz SSR'a zorlar.
Mevcut SSR Uygulamasını PPR'ye Taşımak
- Rotayı denetleyin: Hangi veriler gerçekten istek bazlı? Hangileri kullanıcıya göre değişmiyor ve saatlerce geçerli kalabiliyor? Bu soruyu dürüstçe cevaplamak ilk adım.
- Statik parçayı çıkarın: Layout, hero, ürün açıklaması gibi değişmeyen bileşenleri ayırın. Next.js 16'da bu bileşenlere
use cachedirektifini ekleyin. - Dinamik parçaları Suspense'e sarın: Fiyat, stok, kullanıcı selamlaması, sepet sayısı gibi istek bazlı verileri küçük bileşenlere bölüp
<Suspense fallback={...}>içine alın. - Fallback'i iyi tasarlayın: Fallback statik kabuğun parçası olarak kullanıcıya gösterilir — yani CLS (Cumulative Layout Shift) yaratmamalı, gerçek içerikle aynı boyut ve stili korumalı.
- Hata sınırlarını yerleştirin: Her dinamik bölgenin üstüne bir
error.tsxkoyun. - Ölçün: Lighthouse, WebPageTest veya Vercel Speed Insights ile LCP, TTFB ve INP metriklerini taşıma öncesi ve sonrası karşılaştırın. Sayı yoksa söz de yok.
Gerçek Dünya Örneği: E-Ticaret Ürün Sayfası
// app/p/[slug]/page.tsx (Next.js 16 cacheComponents)
import { Suspense } from 'react'
import { cookies } from 'next/headers'
async function ProductDetails({ slug }: { slug: string }) {
'use cache'
const product = await fetch(`https://api.shop.com/products/${slug}`)
.then(r => r.json())
return (
<article>
<h1>{product.title}</h1>
<img src={product.image} alt={product.title} />
<p>{product.description}</p>
</article>
)
}
async function PersonalizedPrice({ slug }: { slug: string }) {
const jar = await cookies()
const userId = jar.get('uid')?.value
const res = await fetch(
`https://api.shop.com/pricing/${slug}?uid=${userId ?? 'guest'}`,
{ cache: 'no-store' }
)
const { price, discount } = await res.json()
return (
<div>
<strong>{price} TL</strong>
{discount > 0 && <span>%{discount} indirim</span>}
</div>
)
}
async function CartButton({ slug }: { slug: string }) {
const jar = await cookies()
const cartId = jar.get('cart')?.value
const { inCart } = await fetch(
`https://api.shop.com/cart/${cartId}/contains/${slug}`,
{ cache: 'no-store' }
).then(r => r.json())
return (
<button>{inCart ? 'Sepete eklendi' : 'Sepete ekle'}</button>
)
}
export default async function Page({
params,
}: {
params: Promise<{ slug: string }>
}) {
const { slug } = await params
return (
<main>
<ProductDetails slug={slug} />
<Suspense fallback={<div className="price-skel" />}>
<PersonalizedPrice slug={slug} />
</Suspense>
<Suspense fallback={<button disabled>…</button>}>
<CartButton slug={slug} />
</Suspense>
</main>
)
}
Bu sayfada ProductDetails statik kabukta; PersonalizedPrice ve CartButton ise iki ayrı dinamik delikte paralel olarak streamleniyor. Her fetch kendi Suspense sınırında çözüldüğünde yerine oturuyor ve biri çökse bile diğeri etkilenmiyor. Bu izolasyon, canlı ortamda gerçekten hayat kurtarıyor.
Ölçüm ve Debug
PPR'nin gerçekten çalıştığını doğrulamak için birkaç pratik adım:
next buildçıktısında rotanın yanında ◐ sembolü görünmeli (partially prerendered).- Production'da sayfa HTML'ini
curlile çekin: statik kabuk hemen gelmeli, dinamik bölümler yanıtın sonunda belirmeli. - Chrome DevTools → Network → "Timing" sekmesinde TTFB ve Content Download ayrımına bakın. PPR'de TTFB çok kısa, Content Download görece uzundur.
- Vercel kullanıyorsanız Speed Insights üzerinden LCP düşüşünü ölçün.
Sık Sorulan Sorular (SSS)
Partial Prerendering ile ISR arasındaki fark nedir?
ISR bir rotanın tamamını periyodik olarak yeniden üretir ve önbelleğe alır; kişiselleştirme yapamaz. PPR ise aynı rotada statik kabuk (ISR benzeri) ile istek bazlı kişiselleştirilmiş dinamik bölümleri birleştirir. Pratikte PPR'yi "ISR + Suspense streaming" olarak düşünebilirsiniz: statik parça ISR gibi davranır, Suspense içindeki her şey her istekte yeniden render edilir.
PPR'yi Vercel dışında bir platformda kullanabilir miyim?
Evet. PPR, HTTP streaming (Transfer-Encoding: chunked) destekleyen her platformda çalışır. Cloudflare Workers, AWS Lambda@Edge, Node.js self-hosting, Docker konteynerler — hepsi uyumlu. Yalnızca statik kabuğun CDN'de önbelleklenebilmesi için platformun streaming yanıtları cache edebilmesi gerekir. Vercel bunu varsayılan olarak yapıyor; diğer platformlarda CDN katmanını ayrıca yapılandırmanız gerekebilir.
experimental_ppr ile cacheComponents aynı şey mi?
Hayır — ama aynı mekaniği paylaşıyorlar. experimental_ppr Next.js 15'te per-route opt-in için kullanılır ve "varsayılan statik, opt-in dinamik" mantığıyla çalışır. Next.js 16'daki cacheComponents ise varsayılanı tersine çevirir: her şey dinamik, use cache ile statik yaparsınız. cacheComponents ayrıca cacheLife ve cacheTag ile çok daha granüler bir önbellek kontrolü sunuyor ve PPR'yi kararlı bir API olarak paketliyor.
Suspense fallback'i SEO'ya zarar verir mi?
Hayır. Next.js, botları ve arama motoru tarayıcılarını htmlLimitedBots listesi üzerinden tanır; bu istemciler için tam HTML tamamlanana kadar yanıtı bloke eder. Yani normal kullanıcılar streaming yanıtı alırken Googlebot tam prerendered HTML'i alıyor. Metadata da benzer şekilde streamlenir, ancak botlar için <head> içine önce yerleştirilir.
PPR'yi Server Actions ile nasıl birleştiririm?
Bir Server Action, form gönderiminden sonra revalidatePath() ya da revalidateTag() çağırdığında hem statik kabuk hem de ilgili dinamik bölümler geçersizleşir. Etiket bazlı invalidasyon ile yalnızca belirli bir use cache segmentini yenileyebilirsiniz: cacheTag('product:123') ile işaretlenmiş bir bileşeni revalidateTag('product:123') ile tazelersiniz. Dinamik delikler (Suspense içindekiler) zaten her istekte yeniden render edildiğinden ek invalidasyona ihtiyaç duymazlar.
Sonuç
Partial Prerendering, Next.js App Router'ın olgunluk çağının imzası gibi: statik hız, dinamik kişiselleştirme ve hata dayanıklılığını tek bir rota içinde birleştiriyor. Next.js 15'te incremental modla denemeye başlayabilir, Next.js 16'da cacheComponents ile üretime taşıyabilirsiniz. Önce dar Suspense sınırları, sonra use cache + cacheTag kombinasyonu, ardından ölçüm — bu sıra, LCP'nizi yarıya indirmenin ve 500'lere karşı dayanıklılık kazanmanın gördüğüm en kısa yolu.