TEKNİK YAZI / Backend Sistemleri

Server-Side Rendering (SSR): Ne Zaman Gerekli, Ne Zaman Gereksiz?

Server-Side Rendering (SSR) ne zaman değer üretir, ne zaman gereksiz yüktür: Express, Next.js App Router, Pages Router, streaming ve hydration örnekleri.

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?

  1. Tarayıcı sayfayı ister.
  2. Sunucu gereken veriyi okur, bileşeni veya şablonu bu veriyle çalıştırır ve HTML üretir.
  3. Tarayıcı HTML’i hemen gösterir; içerik JavaScript beklemeden görünür.
  4. 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

ModelHTML ne zaman üretilir?Uygun olduğu durum
CSRTarayıcıda, JavaScript yüklendikten sonraGiriş arkasındaki, yoğun etkileşimli paneller
SSRHer istekte, sunucudaKullanıcıya veya isteğe göre değişen, sık güncellenen içerik
SSGDerleme (build) sırasında, bir kezHerkes için aynı, seyrek değişen içerik
ISRDerlemede, 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:

SSR şu durumlarda gereksizdir:

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:

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:

  1. İlk HTML’de içerik: Arama motorları ve JavaScript çalıştırmayan önizleme botları içeriği ve meta etiketleri doğrudan okur.
  2. Hızlı ilk içerik: Tarayıcı içeriği JavaScript paketini beklemeden gösterir.
  3. Dayanıklılık: JavaScript yüklenemese de içerik okunabilir.

Maliyetler:

  1. 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.
  2. Karmaşıklık: Aynı kod hem sunucuda hem tarayıcıda çalışır; window gibi tarayıcı nesneleri sunucuda yoktur ve hydration uyuşmazlıkları ayrıca izlenmelidir.
  3. Gecikmeli etkileşim: İçerik görünür ama JavaScript yüklenip hydration bitene kadar düğmeler çalışmaz.
  4. Ö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.

Birincil kaynaklar