@sedatdemirAra

Next.js 16 Partial Prerendering (PPR) rehberi

Bir e-ticaret ürün sayfası düşün: üst kısımda ürün adı, açıklaması ve görselleri her kullanıcıya aynı. Ama fiyat bölgesi kullanıcının konumuna göre değişiyor, stok bilgisi anlık, yorumlar sürekli güncelleniyor. Bugüne kadar bu sayfayı ya tamamen statik yapıp revalidate süresiyle güncelledin ya da tamamen dinamik render'a mahkum ettin. İki uç arasında bir yol yoktu. Partial Prerendering (PPR) tam olarak bu probleme yanıt veriyor.

Next.js 14'te deneysel olarak ortaya çıkan PPR, Next.js 15'te experimental.ppr bayrağıyla kullanılabilir hale geldi ve Next.js 16 ile varsayılan render stratejisi olarak öne çıkıyor. Artık bir sayfanın statik kısımları build zamanında HTML olarak üretilirken, dinamik kısımlar aynı HTTP yanıtı içinde stream ediliyor.

PPR nasıl çalışıyor?

PPR'nin çalışma modeli üç aşamadan oluşuyor.

Build zamanında Next.js, sayfanın React ağacını render etmeye başlıyor. Statik olan her şey (veritabanı çağrısı yapmayan, cookie veya header okumayan bileşenler) HTML'e dönüşüyor. Dinamik veri gerektiren bileşenler bir <Suspense> sınırına sarılmış olmalı; bu sınırlar build sırasında fallback içerikleriyle birlikte HTML'e yazılıyor.

Kullanıcı sayfayı istediğinde, sunucu önce bu statik kabuğu anında döndürüyor. TTFB neredeyse statik dosya sunmak kadar düşük. Ardından dinamik bileşenler sunucuda render edilip aynı yanıt stream'i içinde HTML parçaları olarak gönderiliyor. Tarayıcı bu parçaları aldıkça fallback'leri gerçek içerikle değiştiriyor.

Bu mekanizma HTTP/1.1 chunked transfer encoding veya HTTP/2 stream'leri üzerinden çalışıyor; ek bir WebSocket ya da istemci tarafı fetch gerekmiyor.

Suspense sınırları PPR'nin kalbi

PPR'de neyin statik neyin dinamik olduğunu belirleyen tek şey Suspense sınırları ve bileşenlerin içindeki dinamik API çağrıları. cookies(), headers(), searchParams prop'u veya connection() gibi dinamik API'ler kullanan bileşenler otomatik olarak dinamik kabul ediliyor.

// app/product/[id]/page.tsx
import { Suspense } from "react";
import { ProductInfo } from "@/components/product-info";
import { PriceBlock } from "@/components/price-block";
import { Reviews } from "@/components/reviews";

export default async function ProductPage({
  params,
}: {
  params: Promise<{ id: string }>;
}) {
  const { id } = await params;

  return (
    <main>
      {/* Statik: build zamanında render edilir */}
      <ProductInfo productId={id} />

      {/* Dinamik: kullanıcıya özel fiyat */}
      <Suspense fallback={<PriceSkeleton />}>
        <PriceBlock productId={id} />
      </Suspense>

      {/* Dinamik: sürekli değişen yorumlar */}
      <Suspense fallback={<ReviewsSkeleton />}>
        <Reviews productId={id} />
      </Suspense>
    </main>
  );
}

ProductInfo bileşeni yalnızca statik veri çekiyorsa (örneğin fetch ile cache'lenen bir API çağrısı), build zamanında HTML'e dönüşüyor. PriceBlock içinde cookies() çağrısı varsa, bu bileşen dinamik olarak işaretleniyor ve istek zamanında render ediliyor.

// components/price-block.tsx
import { cookies } from "next/headers";

export async function PriceBlock({ productId }: { productId: string }) {
  const region = (await cookies()).get("region")?.value ?? "TR";
  const price = await fetch(
    `https://api.example.com/price?product=${productId}&region=${region}`,
    { cache: "no-store" }
  );
  const data = await price.json();

  return (
    <div>
      <span>{data.amount} {data.currency}</span>
    </div>
  );
}

PPR'yi etkinleştirmek

Next.js 15'te PPR hâlâ deneysel bayrak gerektiriyordu:

// next.config.ts (Next.js 15)
import type { NextConfig } from "next";

const nextConfig: NextConfig = {
  experimental: {
    ppr: true,
  },
};

export default nextConfig;

Next.js 16'da PPR varsayılan davranış olarak planlanıyor, yani bu bayrağa ihtiyaç kalmayacak. Ancak 16'nın kararlı sürümü çıkana kadar canary/RC sürümlerinde experimental.ppr bayrağını kullanmaya devam etmek gerekiyor.

Sayfa bazında kontrol de mümkün. Belirli bir route segment'inde PPR'yi açıp kapatmak için experimental_ppr export'u kullanılıyor:

// app/dashboard/layout.tsx
export const experimental_ppr = true;

Bu değer alt route'lara da yayılıyor; dashboard altındaki tüm sayfalar PPR ile render ediliyor.

ISR ile PPR arasındaki fark

ISR (Incremental Static Regeneration) sayfanın tamamını statik üretip belirli aralıklarla yeniden oluşturma mantığıyla çalışıyor. Sayfa ya tamamen statik ya da tamamen dinamik; arada bir nokta yok. ISR'de revalidate: 60 dersen, 60 saniye boyunca eski HTML sunulur, sonra arka planda yeniden üretilir.

PPR'de ise aynı sayfanın bir kısmı statik kabuk olarak anında sunulurken, dinamik kısımlar her istekte taze veriyle render ediliyor. Stale veri problemi yalnızca statik kabuk için geçerli ve bu kısım zaten nadiren değişen içerik (site navigasyonu, ürün açıklaması gibi).

Pratik fark şu: ISR'de fiyatı güncel göstermek için revalidate süresini çok kısa tutman gerekir, bu da aslında dinamik render'a yaklaşmak demek. PPR'de fiyat bileşeni zaten her istekte dinamik, geri kalan sayfa ise sıfır gecikmeyle statik sunuluyor.

Bir başka fark da granülarite. ISR sayfa seviyesinde çalışır. PPR bileşen seviyesinde çalışır. Aynı sayfada üç bağımsız dinamik bölge olabilir ve her biri kendi Suspense sınırı içinde bağımsız stream edilir.

Ne zaman PPR, ne zaman başka strateji?

Her sayfa PPR'ye uygun değil. Sayfanın tamamı kullanıcıya özel veriden oluşuyorsa (örneğin bir dashboard'un her karesi farklı kullanıcı verisi gösteriyorsa), statik kabuk neredeyse boş bir layout'tan ibaret kalır. Bu durumda PPR teknik olarak çalışır ama kazanç minimumdur; klasik SSR yeterli.

PPR en çok şu senaryolarda işe yarıyor: pazarlama sayfalarında kişiselleştirilmiş bir CTA bölgesi, e-ticaret listeleme sayfalarında kullanıcıya özel fiyat veya stok bilgisi, blog sayfalarında dinamik yorum bölümü.

Tamamen statik bir sayfa için (değişmeyen bir "Hakkımızda" sayfası gibi) PPR gereksiz karmaşıklık ekler. Static generation zaten yeterli.

Fallback tasarımının önemi

PPR'de fallback'ler gerçek HTML olarak tarayıcıya gidiyor. Bu skeleton'lar bir yükleme göstergesi değil, layout shift'i önleyen yer tutuculardır. Fallback tasarımı kötüyse CLS (Cumulative Layout Shift) metriği bozulur.

function PriceSkeleton() {
  return (
    <div className="h-8 w-32 animate-pulse rounded bg-gray-200" />
  );
}

function ReviewsSkeleton() {
  return (
    <div className="space-y-4">
      {Array.from({ length: 3 }).map((_, i) => (
        <div key={i} className="h-20 animate-pulse rounded bg-gray-100" />
      ))}
    </div>
  );
}

Skeleton'ların boyutlarını gerçek bileşenin boyutlarıyla eşleştirmek gerekiyor. Aksi halde kullanıcı statik kabuğu gördüğünde bir düzen, dinamik kısım geldiğinde farklı bir düzen görür.

PPR ve caching davranışı

PPR'de statik kabuk CDN'de cache'lenebilir. Dinamik kısımlar ise her istekte sunucudan gelir. Next.js, statik kabuğu bir prerender olarak saklıyor ve dinamik bileşenleri istek zamanında render ediyor.

Bu, React Server Components ile doğal bir uyum sağlıyor. Sunucu bileşenleri zaten sunucuda çalışıyor; PPR sadece bu render işlemini iki fazaya (statik, dinamik) ayırıyor.

Dikkat edilmesi gereken nokta: fetch çağrılarının cache davranışı PPR'de de geçerli. Statik kabukta yer alan bir fetch varsayılan olarak cache'leniyor. Dinamik bileşenlerdeki fetch ise cache: "no-store" veya dinamik API kullanımı nedeniyle cache'lenmiyor.

Server Actions ile PPR birlikte kullanımı

PPR, Server Actions ile birlikte çalışıyor. Dinamik bileşen içinde bir form action'ı tetiklendiğinde, sunucu action'ı çalıştırıp sayfayı yeniden render eder. Bu durumda statik kabuk yeniden gönderilmez; yalnızca değişen dinamik kısımlar güncellenir.

import { revalidatePath } from "next/cache";

export async function addReview(formData: FormData) {
  "use server";
  const productId = formData.get("productId") as string;
  const content = formData.get("content") as string;

  await db.insert(reviews).values({ productId, content });
  revalidatePath(`/product/${productId}`);
}

revalidatePath çağrısı statik kabuğu da yeniden oluşturur, bu yüzden gereksiz yere her action'da çağırmamak gerekiyor. Yalnızca statik kısımda da değişiklik olduğunda (ürün açıklaması güncellendiğinde gibi) kullanılmalı.

Geliştirme sırasında PPR'yi test etmek

next dev modunda PPR davranışını gözlemlemek zor çünkü geliştirme sunucusu her şeyi dinamik render eder. PPR'nin gerçek etkisini görmek için:

next build
next start

Build çıktısında hangi sayfaların PPR ile oluşturulduğu gösteriliyor. Statik kısımların boyutunu ve dinamik Suspense sınırlarını build log'unda görebilirsin.

Performans etkisi

PPR'nin asıl kazancı TTFB ve FCP (First Contentful Paint) metriklerinde. Statik kabuk anında sunulduğu için kullanıcı sayfanın iskeletini milisaniyeler içinde görüyor. Dinamik kısımlar stream olarak geldiğinde LCP (Largest Contentful Paint) de iyileşiyor çünkü tarayıcı statik kabuğu parse edip render etmeye çoktan başlamış oluyor.

Ancak TTI (Time to Interactive) açısından dikkatli olmak gerekiyor. Dinamik kısımlar geç gelirse, kullanıcı skeleton'lara tıklamaya çalışıp etkileşim alamayabilir. Skeleton'ların tıklanamaz olduğunu görsel olarak belli etmek, pointer-events: none gibi basit CSS kurallarıyla sağlanabilir.

PPR, statik ve dinamik arasındaki tercih sorununu ortadan kaldıran bir mimari. Her sayfada kullanılacak bir araç değil ama doğru senaryoda, özellikle karma içerikli sayfalarda, hem geliştirici deneyimini hem kullanıcı deneyimini belirgin şekilde iyileştiriyor. Tek koşul: Suspense sınırlarını bilinçli çizmek ve fallback'leri gerçek boyutlarıyla tasarlamak.