Server-Side Rendering (SSR), sayfanın HTML’inin her istekte sunucuda üretilip tarayıcıya hazır hâlde gönderilmesidir. Doğru yerde ilk içeriğin hızlı görünmesini, arama motorlarının ve önizleme botlarının sayfayı JavaScript çalıştırmadan okumasını ve isteğe bağlı verilerin ilk HTML’de yer almasını sağlar. Yanlış yerde ise her isteğe render maliyeti, sürekli çalışan bir sunucu ve ek operasyon yükü getirir.
SSR’ı değerlendirmenin yolu onu istemci tarafı render (CSR) ve statik üretimle (SSG, ISR) karşılaştırmaktan geçer. Karar ölçütlerinin ardından Express, Next.js App Router ve Pages Router örnekleri, akışlı SSR ve hydration ayrıntıları geliyor.
SSR nasıl çalışır?
- Tarayıcı sayfayı ister.
- Sunucu gereken veriyi okur, bileşeni veya şablonu bu veriyle çalıştırır ve HTML üretir.
- Tarayıcı HTML’i hemen gösterir; içerik JavaScript beklemeden görünür.
- JavaScript paketi yüklenince framework mevcut HTML’e olay dinleyicilerini bağlar. Bu adıma hydration denir; sayfa ancak bundan sonra etkileşimli olur.
PHP, Rails veya Django ile yazılan klasik web uygulamaları zaten böyle çalışır. SSR terimi, React ve Vue gibi istemci tarafı framework’lerin aynı bileşenleri sunucuda da çalıştırabilmesiyle yeniden gündeme geldi.
SSR, CSR, SSG ve ISR
| Model | HTML ne zaman üretilir? | Uygun olduğu durum |
|---|---|---|
| CSR | Tarayıcıda, JavaScript yüklendikten sonra | Giriş arkasındaki, yoğun etkileşimli paneller |
| SSR | Her istekte, sunucuda | Kullanıcıya veya isteğe göre değişen, sık güncellenen içerik |
| SSG | Derleme (build) sırasında, bir kez | Herkes için aynı, seyrek değişen içerik |
| ISR | Derlemede, sonra belirli aralıklarla arka planda | Çok sayıda sayfa, birkaç dakikalık gecikmenin sorun olmadığı içerik |
CSR’da tarayıcı önce boş bir kabuk alır, veriyi JavaScript ile çeker ve sayfayı kendisi çizer:
// CSR: veri tarayıcıda, bileşen yüklendikten sonra çekilir
import { useEffect, useState } from "react";
export default function Page() {
const [data, setData] = useState(null);
useEffect(() => {
fetch("/api/data")
.then(res => res.json())
.then(setData);
}, []);
if (!data) return <p>Yükleniyor...</p>;
return <p>{data.content}</p>;
}
Bu sayfanın ilk HTML’inde yalnız “Yükleniyor…” metni bulunur. Google JavaScript’i işleyebilir; ancak Google’ın kendi belgesi de sunucu tarafı render veya önceden üretimin hâlâ iyi bir fikir olduğunu, çünkü her botun JavaScript çalıştıramadığını belirtir (Google: JavaScript SEO temelleri).
SSR ne zaman gerekli, ne zaman gereksiz?
SSR şu durumlarda değer üretir:
- İçerik isteğe bağlıysa (kullanıcı, konum, yetki veya sorgu parametresi) ve ilk HTML’de görünmesi gerekiyorsa.
- Sayfa sık değişiyor ve arama sonuçlarında ya da sosyal paylaşım önizlemelerinde güncel görünmesi gerekiyorsa; ürün fiyatı ve stok durumu buna örnektir.
- Yavaş cihazlarda içeriğin JavaScript paketi inmeden görünmesi önemliyse.
SSR şu durumlarda gereksizdir:
- İçerik derleme anında biliniyorsa. SSG veya ISR ile üretilen HTML CDN’den sunulur, her istekte render maliyeti oluşmaz.
- Sayfa giriş arkasındaysa ve arama motoru tarafından görülmeyecekse. Yoğun etkileşimli bir panelde CSR çoğu zaman yeterlidir.
- Ekip sunucu tarafı ortamı işletmeye hazır değilse. SSR, statik barındırmanın aksine sürekli çalışan bir Node.js süreci gerektirir.
Express ile en basit SSR
Aşağıdaki sunucu React bileşenini her istekte HTML’e çevirir. JSX içerdiği için bu dosya ve App bileşeni Babel, esbuild veya tsx gibi bir araçla derlenmeden düz Node.js’te çalışmaz.
// server.jsx: App, uygulamanın kök bileşenidir
import express from "express";
import { renderToString } from "react-dom/server";
import App from "./App.jsx";
const app = express();
app.use(express.static("public")); // tarayıcı paketi (client.js) burada
// Express 5'te joker yol adlandırılmalıdır; süslü parantez kök yolu da kapsar
app.get("/{*splat}", (req, res) => {
const html = renderToString(<App url={req.url} />);
res.send(`<!DOCTYPE html>
<html lang="tr">
<head>
<meta charset="utf-8" />
<title>SSR örneği</title>
</head>
<body>
<div id="root">${html}</div>
<script type="module" src="/client.js"></script>
</body>
</html>`);
});
app.listen(3000);
Express 4’teki app.get("*") deseni Express 5’te hata verir; joker karakterin /*splat biçiminde adlandırılması gerekir, kök yolu (/) da yakalamak için /{*splat} kullanılır.
Tarayıcı paketi aynı bileşeni aynı props ile hydrate eder:
// client.jsx
import { hydrateRoot } from "react-dom/client";
import App from "./App.jsx";
hydrateRoot(
document.getElementById("root"),
<App url={location.pathname + location.search} />
);
Next.js App Router ile SSR
Next.js 16’da App Router bileşenleri varsayılan olarak Server Component’tir. Bileşen async tanımlanır ve veriyi doğrudan sunucuda okur; getServerSideProps gibi ayrı bir fonksiyon yoktur. Aşağıdaki örnek, Cache Components ayarı kapalıyken (Next.js’in önceki önbellek modeli) her istekte çalışan klasik SSR davranışını gösterir.
// app/products/[id]/page.tsx (App Router, Server Component)
import { notFound } from "next/navigation";
import { getProduct } from "@/lib/products"; // uygulamanın veri katmanı
export default async function ProductPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
const product = await getProduct(id);
// Akış başlamadan çağrıldığı için yanıt gerçek bir 404 olur
if (!product) notFound();
const jsonLd = {
"@context": "https://schema.org",
"@type": "Product",
name: product.name,
description: product.description,
};
return (
<article>
<h1>{product.name}</h1>
<p>{product.description}</p>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{
// JSON.stringify "<" karakterini kaçırmaz; </script> enjeksiyonunu önler
__html: JSON.stringify(jsonLd).replace(/</g, "\\u003c"),
}}
/>
</article>
);
}
Bu örnekte dikkat edilecek noktalar:
- Veri katmanı: Bileşen sunucuda çalıştığı için veritabanını veya iç servisi doğrudan çağırır. Uygulamanın kendi API rotasına HTTP isteği atmak gereksiz bir ağ turu ekler.
- Önbellek: Güncel Next.js’te
fetchistekleri varsayılan olarak önbelleğe alınmaz. Cache Components açıkken (cacheComponents: true; yenicreate-next-appprojelerinde varsayılan) sonuç'use cache'vecacheLifeile önbelleğe alınır, istek zamanı verisine erişen bileşenlerin ise<Suspense>içine alınması beklenir. - 404 ve akış:
notFound(), yanıt akışı başlamadan çağrılırsa gerçek bir 404 döner. Bir<Suspense>sınırının içinde, akış başladıktan sonra çağrılırsa durum kodu 200 kalır ve Next.js sayfayanoindexetiketi ekler. Varlık kontrolünü bu yüzden ilk<Suspense>sınırından önce yapın. - JSON-LD:
JSON.stringifyHTML’e özgü karakterleri kaçırmaz. Ürün adında</script>geçerse betik etiketi erken kapanır ve XSS’e yol açar;<karakterinin<ile değiştirilmesi bunu önler. Next.js’in JSON-LD rehberi aynı yöntemi önerir.
Pages Router ile SSR: getServerSideProps
getServerSideProps ve getStaticProps, Next.js’in eski ama hâlâ desteklenen Pages Router yapısına aittir. Yeni projelerde App Router önerilir; mevcut Pages Router projelerinde aynı ilkeler şöyle uygulanır:
// pages/products/[id].tsx (Pages Router)
import type { GetServerSideProps } from "next";
import { getProduct, type Product } from "@/lib/products";
type Props = { product: Product };
export const getServerSideProps: GetServerSideProps<
Props,
{ id: string }
> = async ({ params, res }) => {
const product = await getProduct(params!.id);
// 200 dönen bir hata sayfası yerine gerçek 404 (soft 404'ü önler)
if (!product) return { notFound: true };
// CDN 10 saniye taze tutar, sonraki 59 saniyede eskisini sunup arka planda yeniler
res.setHeader(
"Cache-Control",
"public, s-maxage=10, stale-while-revalidate=59"
);
return { props: { product } };
};
export default function ProductPage({ product }: Props) {
return (
<article>
<h1>{product.name}</h1>
<p>{product.description}</p>
</article>
);
}
getServerSideProps sunucuda çalıştığı için Next.js belgeleri uygulamanın kendi API rotasını çağırmak yerine veritabanını veya dış servisi doğrudan kullanmayı söyler. Döndürülen props sayfanın HTML’ine gömülür ve istemcide görünür; gizli veri koymayın.
İçerik derleme anında biliniyorsa getStaticProps ile statik üretim yeterlidir. revalidate eklendiğinde sayfa ISR ile çalışır:
// pages/index.tsx (Pages Router, ISR)
import { getLatestPosts, type Post } from "@/lib/posts";
export async function getStaticProps() {
const posts = await getLatestPosts(); // derleme sırasında veri katmanından okunur
return {
props: { posts },
revalidate: 60, // 60 saniye geçtikten sonraki ilk istek arka planda yenilemeyi başlatır
};
}
export default function Home({ posts }: { posts: Post[] }) {
return (
<ul>
{posts.map(post => (
<li key={post.id}>{post.title}</li>
))}
</ul>
);
}
ISR’da 60 saniye dolduktan sonra gelen istek önbellekteki eski sayfayı alır; yeni sürüm arka planda üretilir ve sonraki isteklere o sunulur.
Veri katmanında önbellek
Yukarıdaki örneklerin kullandığı getProduct, render koduna dokunmadan önbelleğe alınabilir. Basitleştirilmiş örnek:
// lib/products.ts
import Redis from "ioredis";
import { findProductById } from "./db"; // uygulamanın kendi veritabanı sorgusu
export type Product = { id: string; name: string; description: string };
const redis = new Redis(process.env.REDIS_URL ?? "redis://localhost:6379");
export async function getProduct(id: string): Promise<Product | null> {
const key = `product:${id}`;
const cached = await redis.get(key);
if (cached) return JSON.parse(cached) as Product;
const product = await findProductById(id);
if (product) {
await redis.set(key, JSON.stringify(product), "EX", 300); // 5 dakika
}
return product;
}
Kullanıcıya özel sayfalar bu şekilde paylaşılan bir önbelleğe yazılmamalıdır; anahtar yalnız herkes için aynı olan veriyi temsil etmelidir.
Akışlı SSR (streaming)
React 18 ve sonrasında renderToPipeableStream, sayfanın hazır olan kısmını (kabuk) hemen gönderir, <Suspense> içindeki yavaş bölümleri ise hazır oldukça aynı yanıta ekler. Bu, yanıtın parça parça gönderilmesine dayandığı için Server-Sent Events ile aynı altyapı sorunlarını paylaşır: tamponlayan bir proxy veya sıkıştırma katmanı akışın faydasını ortadan kaldırır.
// App.jsx: streaming için kök bileşen <html> dahil tüm belgeyi döndürür
import { Suspense } from "react";
import Reviews from "./Reviews.jsx"; // veri beklerken askıya alınan bileşen
export default function App() {
return (
<html lang="tr">
<head>
<meta charSet="utf-8" />
<title>Ürün</title>
</head>
<body>
<h1>Ürün</h1>
<Suspense fallback={<p>Yorumlar yükleniyor...</p>}>
<Reviews />
</Suspense>
</body>
</html>
);
}
// server.jsx
import express from "express";
import { renderToPipeableStream } from "react-dom/server";
import App from "./App.jsx";
const app = express();
app.use(express.static("public"));
app.get("/{*splat}", (req, res) => {
let didError = false;
const { pipe, abort } = renderToPipeableStream(<App />, {
bootstrapScripts: ["/client.js"],
onShellReady() {
// Durum kodu ve başlıklar yalnız bir kez, akış başlarken gönderilir
res.statusCode = didError ? 500 : 200;
res.setHeader("Content-Type", "text/html; charset=utf-8");
pipe(res);
},
onShellError() {
// Kabuk üretilemedi; henüz bir şey gönderilmediği için 500 dönülebilir
res.statusCode = 500;
res.setHeader("Content-Type", "text/html; charset=utf-8");
res.send("<h1>Sunucu hatası</h1>");
},
onError(error) {
// Yalnız kaydet: akış başladıysa yanıt artık değiştirilemez
didError = true;
console.error(error);
},
});
setTimeout(abort, 10000); // takılan render'ı 10 saniye sonra kes
});
app.listen(3000);
Akış başladıktan sonra HTTP durum kodu ve başlıklar değiştirilemez. Bu yüzden 500 yanıtı onShellError içinde gönderilir; onError yalnız kayıt tutar. Kabuğun dışındaki (<Suspense> içindeki) bir bileşen hata verirse React render’ı durdurmaz, o bölümü istemcide yeniden dener.
Kök bileşen <html> döndürdüğü için tarayıcıda belgenin tamamı hydrate edilir:
// client.jsx
import { hydrateRoot } from "react-dom/client";
import App from "./App.jsx";
hydrateRoot(document, <App />);
Hydration uyuşmazlıkları
Hydration sırasında istemcideki ilk render, sunucunun ürettiği HTML ile aynı sonucu vermelidir. Uyuşmazlığın yaygın nedenleri typeof window kontrolüyle farklı içerik üretmek, window.matchMedia gibi yalnız tarayıcıda bulunan API’leri render sırasında kullanmak ve sunucuda farklı, istemcide farklı veri göstermektir (saat, rastgele değer, yerel ayar).
Yalnız tarayıcıda anlamlı olan içerik için iki aşamalı render kullanılır: ilk render sunucuyla aynı çıktıyı verir, içerik bir effect çalıştıktan sonra gösterilir.
import { useEffect, useState } from "react";
// İçeriği yalnız hydration tamamlandıktan sonra gösterir
function ClientOnly({ children, fallback = null }) {
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
return mounted ? children : fallback;
}
// Kullanıcının yerel saati sunucuda bilinemez
export function LocalTime() {
return (
<ClientOnly fallback={<span>--:--</span>}>
<span>{new Date().toLocaleTimeString("tr-TR")}</span>
</ClientOnly>
);
}
Bu yöntem bileşeni iki kez render ettiği için hydration’ı yavaşlatır; yalnız gerçekten gerekli yerlerde kullanılmalıdır. Tek bir öğedeki kaçınılmaz metin farkı (ör. zaman damgası) için suppressHydrationWarning kullanılabilir; bu ayar yalnız bir seviye derinde çalışır ve farkı düzeltmez, yalnız uyarıyı bastırır.
Avantajlar ve maliyetler
Avantajlar:
- İlk HTML’de içerik: Arama motorları ve JavaScript çalıştırmayan önizleme botları içeriği ve meta etiketleri doğrudan okur.
- Hızlı ilk içerik: Tarayıcı içeriği JavaScript paketini beklemeden gösterir.
- Dayanıklılık: JavaScript yüklenemese de içerik okunabilir.
Maliyetler:
- Sunucu yükü: Her istek render maliyeti doğurur ve ilk bayta kadar geçen süreyi (TTFB) uzatır. Statik barındırma yerine sürekli çalışan bir Node.js süreci gerekir; bu süreç üretimde genellikle bir systemd servisi veya container olarak işletilir.
- Karmaşıklık: Aynı kod hem sunucuda hem tarayıcıda çalışır;
windowgibi tarayıcı nesneleri sunucuda yoktur ve hydration uyuşmazlıkları ayrıca izlenmelidir. - Gecikmeli etkileşim: İçerik görünür ama JavaScript yüklenip hydration bitene kadar düğmeler çalışmaz.
- Önbellek sınırı: Kullanıcıya özel HTML, CDN’de herkes için paylaşılamaz.
Sonuç
SSR bir SEO anahtarı değil, HTML’in istek zamanında üretilmesi kararıdır. İçerik derleme anında biliniyorsa SSG veya ISR daha düşük maliyetle aynı ilk HTML’i sağlar; giriş arkasındaki etkileşimli ekranlarda CSR çoğu zaman yeterlidir. SSR’ı isteğe bağlı ve sık değişen, ilk HTML’de görünmesi gereken sayfalara ayırın. Modern framework’ler bu modelleri aynı uygulamada rota ya da bileşen düzeyinde birleştirebilir; karar verirken TTFB, ilk içerik gösterimi, hydration süresi ve istemci JavaScript boyutunu ayrı metrikler olarak ölçün.