React Server Components ve Veri Çekme: Next.js App Router Rehberi

React Server Components, veri çekme kalıpları, önbellekleme, Suspense ile streaming ve rendering stratejilerini kapsamlı bir e-ticaret örneğiyle adım adım öğrenin.

Giriş

Next.js App Router sahneye çıktığında, React ekosisteminde ciddi bir deprem yaşandı desek abartmış olmayız. Bu depremin merkezinde ise React Server Components (RSC) var. Peki neden bu kadar önemli? Çünkü Server Components, bileşenlerinizi doğrudan sunucuda çalıştırarak istemciye gönderilen JavaScript miktarını dramatik biçimde azaltıyor. Veritabanı sorgularını bileşen içinde yapabiliyorsunuz, ve en güzeli: geleneksel getServerSideProps veya getStaticProps kalıplarına artık ihtiyacınız kalmıyor.

Bu makalede, React Server Components'in temellerinden başlayarak veri çekme stratejileri, önbellekleme mekanizmaları, streaming ile kısmi rendering ve rendering stratejilerine kadar geniş bir alanı ele alacağız. Daha önce sitemizde yayınlanan Server Actions ve Middleware makalelerini okuduysanız, bu yazının o konulara nasıl bağlandığını göreceksiniz. Server Actions ile form işleme ve veri mutasyonlarını, Middleware ile istek düzeyinde yönlendirme ve kimlik doğrulamayı konuşmuştuk; şimdi sıra veri okuma ve sunucu tarafı rendering'in tüm inceliklerinde.

Hazırsanız, başlayalım.

React Server Components Nedir?

React Server Components (RSC), React bileşenlerinin yalnızca sunucuda çalıştırılmasını sağlayan bir mimari yaklaşım. Geleneksel React bileşenlerinden farklı olarak, Server Components istemci tarafına JavaScript olarak gönderilmez. Bunun yerine sunucuda render edilir ve sonuçları özel bir format aracılığıyla istemciye aktarılır.

Bu yaklaşımın sağladığı avantajlara bir bakalım:

  • Sıfır istemci JavaScript'i: Server Components, bundle boyutuna katkıda bulunmaz. Büyük kütüphaneleri (tarih formatlama, markdown işleme gibi) yalnızca sunucuda kullanabilirsiniz.
  • Doğrudan veri erişimi: Veritabanına, dosya sistemine veya dahili API'lere doğrudan bileşen içinden erişebilirsiniz.
  • Otomatik kod bölme: İstemci bileşenlerinin yalnızca gerçekten ihtiyaç duyulanları yüklenmesini sağlar.
  • Gelişmiş güvenlik: API anahtarları, veritabanı bağlantı dizileri gibi hassas bilgiler asla istemciye sızmaz.

Next.js App Router'da, app dizini içindeki tüm bileşenler varsayılan olarak Server Component'tir. Bu, "sunucu öncelikli" (server-first) bir zihinsel model oluşturur. Bir bileşenin Client Component olması için açıkça "use client" direktifi ile işaretlenmesi gerekir.

// app/products/page.tsx
// Bu bir Server Component'tir — herhangi bir direktif gerekmez

import { db } from '@/lib/database';

export default async function ProductsPage() {
  // Doğrudan veritabanı sorgusu — istemciye hiçbir şey sızmaz
  const products = await db.product.findMany({
    where: { isActive: true },
    orderBy: { createdAt: 'desc' },
  });

  return (
    <main>
      <h1>Ürünlerimiz</h1>
      <div className="grid grid-cols-3 gap-6">
        {products.map((product) => (
          <ProductCard key={product.id} product={product} />
        ))}
      </div>
    </main>
  );
}

Yukarıdaki örnekte dikkat ederseniz, ProductsPage bileşeni bir async fonksiyon. Bu, Server Components'in en güçlü özelliklerinden biri: bileşenler doğrudan async/await kullanarak veri çekebiliyor. Bu veri çekme işlemi tamamen sunucuda gerçekleşiyor ve sonuç HTML olarak istemciye gönderiliyor.

Zihinsel Model: Sunucu Öncelikli Düşünme

Server Components ile çalışırken benimsemeniz gereken zihinsel model şu: Her şey sunucuda başlar, yalnızca etkileşim gerektiren kısımlar istemciye taşınır.

Bu, geleneksel React yaklaşımının tam tersi. Eskiden her şey istemcide çalışırdı ve veri çekme için özel mekanizmalar kullanılırdı. Şimdi ise akış tersine döndü — her şey sunucuda çalışıyor ve yalnızca etkileşim gerektiren bileşenler istemciye gönderiliyor.

Server ve Client Component Ayrımı

Bu ayrımı doğru yapmak, performanslı bir Next.js uygulaması oluşturmanın anahtarı. Temel kural basit: "use client" direktifini yalnızca gerçekten gerekli olduğunda kullanın.

Ne Zaman "use client" Kullanılmalı?

Bir bileşenin Client Component olması gereken durumlar:

  • useState, useEffect, useRef gibi React hook'ları kullanıyorsa
  • Tarayıcı API'lerine erişim gerekiyorsa (window, localStorage, navigator)
  • Olay dinleyicileri (onClick, onChange gibi) gerekiyorsa
  • Üçüncü parti bir kütüphane tarayıcıda çalışmayı gerektiriyorsa
// components/AddToCartButton.tsx
"use client";

import { useState } from 'react';
import { addToCart } from '@/actions/cart';

interface AddToCartButtonProps {
  productId: string;
  productName: string;
}

export default function AddToCartButton({ productId, productName }: AddToCartButtonProps) {
  const [isLoading, setIsLoading] = useState(false);
  const [message, setMessage] = useState('');

  const handleAddToCart = async () => {
    setIsLoading(true);
    try {
      await addToCart(productId);
      setMessage(`${productName} sepete eklendi!`);
    } catch (error) {
      setMessage('Bir hata oluştu. Lütfen tekrar deneyin.');
    } finally {
      setIsLoading(false);
    }
  };

  return (
    <div>
      <button
        onClick={handleAddToCart}
        disabled={isLoading}
        className="bg-blue-600 text-white px-6 py-2 rounded-lg"
      >
        {isLoading ? 'Ekleniyor...' : 'Sepete Ekle'}
      </button>
      {message && <p className="mt-2 text-green-600">{message}</p>}
    </div>
  );
}

Kompozisyon Kalıpları: Sunucu Verisini İstemci Bileşenlerine Aktarma

Pratikte en sık kullandığım kalıplardan biri, Server Component'te veri çekip bunu props aracılığıyla Client Component'e aktarmak. Böylece veri çekme işlemi sunucuda kalırken, etkileşim istemcide gerçekleşiyor.

// app/products/[id]/page.tsx — Server Component
import { db } from '@/lib/database';
import AddToCartButton from '@/components/AddToCartButton';
import ProductImageGallery from '@/components/ProductImageGallery';

interface PageProps {
  params: Promise<{ id: string }>;
}

export default async function ProductPage({ params }: PageProps) {
  const { id } = await params;
  const product = await db.product.findUnique({ where: { id } });

  if (!product) {
    return <div>Ürün bulunamadı.</div>;
  }

  return (
    <article className="max-w-4xl mx-auto p-6">
      <h1 className="text-3xl font-bold">{product.name}</h1>

      {/* Client Component — etkileşimli resim galerisi */}
      <ProductImageGallery images={product.images} />

      {/* Statik içerik — Server Component olarak kalır */}
      <p className="text-gray-700 mt-4">{product.description}</p>
      <p className="text-2xl font-semibold mt-2">
        {product.price.toLocaleString('tr-TR')} TL
      </p>

      {/* Client Component — etkileşimli buton */}
      <AddToCartButton productId={product.id} productName={product.name} />
    </article>
  );
}

Yaygın Hatalar

Bu ayrımda geliştiricilerin en sık düştüğü tuzaklar var. Açıkçası bunların bazılarını ben de ilk başta yapmıştım:

  1. Gereksiz "use client" kullanımı: Etkileşim gerektirmeyen bileşenlere "use client" eklemek, istemciye gereksiz JavaScript gönderir.
  2. Server Component'i Client Component'in içine import etmeye çalışmak: Client Component'ler Server Component'leri doğrudan import edemez. Bunun yerine children veya props aracılığıyla iletilmelidir.
  3. Tüm sayfayı "use client" yapmak: Bu, Server Components'in tüm avantajlarını yok eder. Etkileşimli kısımları ayrı bileşenlere ayırın.
// ❌ YANLIŞ: Tüm sayfayı Client Component yapmak
"use client";
export default function Page() {
  const [count, setCount] = useState(0);
  // Veri çekme için useEffect kullanmak zorunda kalırsınız
  // ...
}

// ✅ DOĞRU: Yalnızca etkileşimli kısmı ayırmak
// page.tsx (Server Component)
export default async function Page() {
  const data = await fetchData();
  return (
    <div>
      <h1>{data.title}</h1>
      <Counter /> {/* Sadece bu Client Component */}
    </div>
  );
}

Veri Çekme Temelleri

Next.js App Router'da veri çekme, Server Components sayesinde gerçekten çok basit ve sezgisel hale geldi. Bileşenler doğrudan async/await kullanarak veri çekebiliyor — ek bir konfigürasyona gerek yok.

fetch API ile Veri Çekme

Next.js, standart fetch API'sini genişleterek önbellekleme ve yeniden doğrulama seçenekleri ekliyor.

// app/posts/page.tsx
interface Post {
  id: number;
  title: string;
  body: string;
}

export default async function PostsPage() {
  const response = await fetch('https://jsonplaceholder.typicode.com/posts', {
    next: { revalidate: 3600 }, // 1 saat boyunca önbelleğe al
  });

  if (!response.ok) {
    throw new Error('Gönderiler yüklenirken bir hata oluştu');
  }

  const posts: Post[] = await response.json();

  return (
    <div>
      <h1>Blog Gönderileri</h1>
      {posts.map((post) => (
        <article key={post.id} className="mb-6">
          <h2 className="text-xl font-bold">{post.title}</h2>
          <p className="text-gray-600">{post.body}</p>
        </article>
      ))}
    </div>
  );
}

Paralel ve Sıralı Veri Çekme

Performans açısından kritik bir konu: bağımsız veri çekme işlemlerini mutlaka paralel yürütün. Sıralı veri çekme (waterfall), gereksiz bekleme sürelerine yol açar ve kullanıcı deneyimini ciddi şekilde olumsuz etkiler.

// ❌ YANLIŞ: Sıralı (waterfall) veri çekme
async function Page() {
  const product = await fetchProduct(id);    // 200ms bekle
  const reviews = await fetchReviews(id);    // Sonra 300ms bekle
  const related = await fetchRelatedProducts(id); // Sonra 150ms bekle
  // Toplam: ~650ms
}

// ✅ DOĞRU: Paralel veri çekme
async function Page() {
  const [product, reviews, related] = await Promise.all([
    fetchProduct(id),       // 200ms
    fetchReviews(id),       // 300ms — aynı anda
    fetchRelatedProducts(id) // 150ms — aynı anda
  ]);
  // Toplam: ~300ms (en yavaş isteğin süresi)
}

Aradaki farkı görüyor musunuz? 650ms yerine 300ms. Birden fazla API çağrısı yapıyorsanız bu fark çok daha belirgin hale geliyor.

İstek Memoizasyonu (Request Memoization)

Next.js'in küçük ama çok güzel bir özelliği var: aynı render ağacında yapılan aynı URL ve seçeneklere sahip fetch isteklerini otomatik olarak memoize ediyor. Yani aynı veriyi birden fazla bileşende çekmeniz gerektiğinde, gereksiz ağ istekleri yapılmıyor.

// lib/data.ts
export async function getUser(userId: string) {
  const res = await fetch(`https://api.example.com/users/${userId}`);
  return res.json();
}

// app/layout.tsx — Bu getUser çağrısı yapılır
export default async function Layout({ children }: { children: React.ReactNode }) {
  const user = await getUser('123');
  return (
    <html>
      <body>
        <nav>Hoş geldin, {user.name}</nav>
        {children}
      </body>
    </html>
  );
}

// app/profile/page.tsx — Aynı getUser çağrısı, ağ isteği tekrarlanmaz
export default async function ProfilePage() {
  const user = await getUser('123'); // Memoize edilmiş — tekrar istek yapılmaz
  return <h1>{user.name} Profili</h1>;
}

Bu memoizasyon yalnızca GET metodu kullanan fetch istekleri için geçerli ve tek bir render geçişi boyunca aktif. İstek tamamlandıktan sonra önbellek temizleniyor.

Önbellekleme (Caching) Stratejileri

Önbellekleme konusu, Next.js'in hem en güçlü hem de açıkçası en kafa karıştıran özelliklerinden biri. Ama Next.js 15 ve 16 ile birlikte işler epey netleşti.

Önbelleklemenin Evrimi

Next.js'in önceki sürümlerinde fetch istekleri varsayılan olarak önbelleğe alınıyordu. Bu "örtük (implicit) önbellekleme" yaklaşımı, beklemediğiniz davranışlara yol açabiliyordu (ve sıkça açıyordu). Next.js 15 ile birlikte bu varsayılan değişti: fetch istekleri artık varsayılan olarak önbelleğe alınmıyor.

Next.js 16 ise dynamicIO bayrağı ve "use cache" direktifi ile tamamen yeni bir önbellekleme paradigması sunuyor. Bu yaklaşımda önbellekleme açık (explicit) ve bildirimsel (declarative) hale geliyor.

"use cache" Direktifi

Next.js 16'da tanıtılan "use cache" direktifi, fonksiyonların, bileşenlerin ve hatta tüm dosyaların önbelleğe alınmasını sağlıyor. Bu direktif, dynamicIO yapılandırma bayrağı ile birlikte kullanılıyor.

// next.config.ts
import type { NextConfig } from 'next';

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

export default nextConfig;

Bu ayarı yaptıktan sonra "use cache" direktifini bileşenlerinizde ve fonksiyonlarınızda kullanabilirsiniz:

// app/products/page.tsx
"use cache";

import { db } from '@/lib/database';

export default async function ProductsPage() {
  const products = await db.product.findMany({
    where: { isActive: true },
  });

  return (
    <div>
      {products.map((product) => (
        <div key={product.id}>{product.name}</div>
      ))}
    </div>
  );
}

cacheLife ile Önbellek Ömrü Yönetimi

cacheLife fonksiyonu, önbellek ömrünü ayrıntılı biçimde kontrol etmenizi sağlıyor. Önceden tanımlanmış profiller kullanabileceğiniz gibi özel süreler de belirleyebilirsiniz.

// app/products/page.tsx
"use cache";

import { cacheLife } from 'next/cache';

export default async function ProductsPage() {
  cacheLife('hours'); // Önceden tanımlanmış profil: saatlik önbellek

  const products = await fetchProducts();
  return <ProductList products={products} />;
}

Önceden tanımlanmış cacheLife profilleri şunlar:

  • "seconds" — Kısa süreli, sık güncellenen veriler için
  • "minutes" — Orta sıklıkta güncellenen veriler için
  • "hours" — Saatlik güncellemeler için yeterli veriler için
  • "days" — Günlük güncellenen veriler için
  • "weeks" — Nadiren değişen veriler için
  • "max" — Mümkün olduğunca uzun süre önbelleğe almak için

Kendi özel önbellek profilinizi de tanımlayabilirsiniz:

// next.config.ts
const nextConfig: NextConfig = {
  experimental: {
    dynamicIO: true,
    cacheLife: {
      'urun-verisi': {
        stale: 300,     // 5 dakika bayat (stale) kalabilir
        revalidate: 600, // 10 dakikada bir yeniden doğrula
        expire: 3600,   // 1 saat sonra tamamen süresi dolsun
      },
    },
  },
};
// app/products/[id]/page.tsx
"use cache";

import { cacheLife } from 'next/cache';

export default async function ProductPage({ params }: { params: Promise<{ id: string }> }) {
  cacheLife('urun-verisi'); // Özel profili kullan

  const { id } = await params;
  const product = await fetchProduct(id);
  return <ProductDetail product={product} />;
}

cacheTag ile Etiket Tabanlı Yeniden Doğrulama

cacheTag, önbelleğe alınmış verileri etiketler ile ilişkilendirmenizi sağlıyor. Bu sayede belirli etiketlere sahip önbellek girdilerini hedefli biçimde geçersiz kılabilirsiniz. Yani bir ürün güncellendiğinde sadece o ürünün önbelleğini temizleyebilirsiniz — tüm ürün listesini değil.

// lib/data.ts
"use cache";

import { cacheTag, cacheLife } from 'next/cache';

export async function getProduct(productId: string) {
  cacheTag(`product-${productId}`, 'products');
  cacheLife('hours');

  const res = await fetch(`https://api.example.com/products/${productId}`);
  return res.json();
}

export async function getProductReviews(productId: string) {
  cacheTag(`reviews-${productId}`, 'reviews');
  cacheLife('minutes');

  const res = await fetch(`https://api.example.com/products/${productId}/reviews`);
  return res.json();
}
// app/actions/product.ts
"use server";

import { revalidateTag } from 'next/cache';

export async function updateProduct(productId: string, data: FormData) {
  await db.product.update({
    where: { id: productId },
    data: { name: data.get('name') as string },
  });

  // Belirli ürünün önbelleğini geçersiz kıl
  revalidateTag(`product-${productId}`);
}

export async function addReview(productId: string, data: FormData) {
  await db.review.create({
    data: {
      productId,
      content: data.get('content') as string,
      rating: Number(data.get('rating')),
    },
  });

  // İlgili yorumların önbelleğini geçersiz kıl
  revalidateTag(`reviews-${productId}`);
}

revalidatePath ise belirli bir yol (path) için tüm önbelleği geçersiz kılıyor:

import { revalidatePath } from 'next/cache';

// Belirli bir sayfanın önbelleğini geçersiz kıl
revalidatePath('/products');

// Belirli bir ürün sayfasının önbelleğini geçersiz kıl
revalidatePath(`/products/${productId}`);

Streaming ve Suspense ile Kısmi Rendering

React Suspense ve Server Components birleştiğinde ortaya çok güçlü bir özellik çıkıyor: streaming. Streaming, sayfanın tüm verisinin hazır olmasını beklemek yerine, hazır olan kısımları anında istemciye göndermeyi sağlıyor. Kullanıcı açısından bu büyük bir fark yaratıyor — sayfanın bir kısmını hemen görüyorsunuz, geri kalanı da hazır oldukça beliriyor.

Suspense ile Bileşen Düzeyinde Yükleme

// app/products/[id]/page.tsx
import { Suspense } from 'react';
import ProductInfo from '@/components/ProductInfo';
import ProductReviews from '@/components/ProductReviews';
import RelatedProducts from '@/components/RelatedProducts';
import ReviewsSkeleton from '@/components/skeletons/ReviewsSkeleton';
import RelatedSkeleton from '@/components/skeletons/RelatedSkeleton';

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

  return (
    <div className="max-w-6xl mx-auto">
      {/* Bu veri hızlı yüklenir, hemen gösterilir */}
      <ProductInfo productId={id} />

      {/* Yorumlar yüklenirken iskelet (skeleton) gösterilir */}
      <Suspense fallback={<ReviewsSkeleton />}>
        <ProductReviews productId={id} />
      </Suspense>

      {/* İlgili ürünler ayrı bir Suspense sınırında */}
      <Suspense fallback={<RelatedSkeleton />}>
        <RelatedProducts productId={id} />
      </Suspense>
    </div>
  );
}

Buradaki her Suspense sınırı bağımsız bir streaming birimi oluşturuyor. ProductInfo hazır olduğunda hemen gönderiliyor, ardından ProductReviews ve RelatedProducts hazır oldukça aşamalı olarak sayfaya ekleniyor.

loading.tsx Kalıbı

Next.js, her route segmenti için otomatik Suspense sınırı oluşturan özel bir loading.tsx dosyası sunuyor. Bunu kullanması oldukça kolay:

// app/products/loading.tsx
export default function ProductsLoading() {
  return (
    <div className="animate-pulse">
      <div className="h-8 bg-gray-200 rounded w-1/3 mb-6" />
      <div className="grid grid-cols-3 gap-6">
        {Array.from({ length: 6 }).map((_, i) => (
          <div key={i} className="h-64 bg-gray-200 rounded" />
        ))}
      </div>
    </div>
  );
}

Bu dosya, app/products/page.tsx yüklenirken otomatik olarak gösteriliyor. Next.js bunu dahili olarak bir Suspense sınırına sarıyor.

Kısmi Ön-Rendering (Partial Pre-Rendering) Kavramı

Next.js'in en heyecan verici vizyonlarından biri olan Partial Pre-Rendering (PPR), statik ve dinamik içeriği aynı sayfa içinde birleştirmeyi amaçlıyor. PPR ile sayfanın statik kabuk kısmı build zamanında oluşturulur ve Suspense sınırlarındaki dinamik kısımlar istek zamanında streaming ile doldurulur.

Hem statik sayfaların hızından hem de dinamik içeriğin esnekliğinden yararlanmak — kulağa güzel değil mi?

// next.config.ts — PPR'yi etkinleştirme
const nextConfig: NextConfig = {
  experimental: {
    ppr: true,
  },
};

Rendering Stratejileri: SSR, SSG ve ISR

Next.js App Router, rendering stratejilerini Pages Router'dan farklı bir biçimde ele alıyor. Artık getStaticProps, getServerSideProps veya getStaticPaths yok. Bunların yerine daha doğal ve esnek mekanizmalar kullanılıyor.

Statik Oluşturma (Static Generation — SSG)

Dinamik veri çekmeyen veya tüm verileri build zamanında çekebilen sayfalar otomatik olarak statik oluşturuluyor. Dinamik route'lar için generateStaticParams kullanıyoruz:

// app/blog/[slug]/page.tsx
import { db } from '@/lib/database';

// Build zamanında hangi sayfaların oluşturulacağını belirle
export async function generateStaticParams() {
  const posts = await db.post.findMany({
    select: { slug: true },
  });

  return posts.map((post) => ({
    slug: post.slug,
  }));
}

export default async function BlogPost({ params }: { params: Promise<{ slug: string }> }) {
  const { slug } = await params;
  const post = await db.post.findUnique({ where: { slug } });

  if (!post) {
    return <div>Gönderi bulunamadı.</div>;
  }

  return (
    <article>
      <h1>{post.title}</h1>
      <div dangerouslySetInnerHTML={{ __html: post.content }} />
    </article>
  );
}

Artımlı Statik Yeniden Oluşturma (ISR)

ISR, statik sayfaları belirli aralıklarla yeniden oluşturmanızı sağlıyor. App Router'da bu, sayfa düzeyinde veya veri çekme düzeyinde yapılandırılabiliyor. İşte üç farklı yöntem:

// Yöntem 1: Sayfa düzeyinde revalidate
export const revalidate = 3600; // Her 1 saatte bir yeniden oluştur

export default async function ProductsPage() {
  const products = await fetchProducts();
  return <ProductList products={products} />;
}

// Yöntem 2: fetch düzeyinde revalidate
export default async function ProductsPage() {
  const res = await fetch('https://api.example.com/products', {
    next: { revalidate: 3600 },
  });
  const products = await res.json();
  return <ProductList products={products} />;
}

// Yöntem 3: "use cache" ile (Next.js 16+)
"use cache";
import { cacheLife } from 'next/cache';

export default async function ProductsPage() {
  cacheLife('hours');
  const products = await fetchProducts();
  return <ProductList products={products} />;
}

Dinamik Rendering (SSR)

Bir sayfanın her istekte yeniden render edilmesi gerektiğinde dinamik rendering devreye giriyor. Aşağıdaki durumlar sayfayı otomatik olarak dinamik yapıyor:

  • cookies() veya headers() fonksiyonlarının kullanılması
  • searchParams prop'unun kullanılması
  • fetch isteklerinde cache: 'no-store' belirtilmesi
  • export const dynamic = 'force-dynamic' belirtilmesi
// app/dashboard/page.tsx — Her istekte dinamik olarak render edilir
import { cookies } from 'next/headers';

export default async function DashboardPage() {
  const cookieStore = await cookies();
  const sessionToken = cookieStore.get('session')?.value;

  if (!sessionToken) {
    redirect('/login');
  }

  const userData = await fetchUserData(sessionToken);

  return (
    <div>
      <h1>Hoş geldiniz, {userData.name}</h1>
      {/* Dashboard içeriği */}
    </div>
  );
}

Gerçek Dünya Örneği: E-Ticaret Ürün Sayfası

Şimdi işleri biraz daha somutlaştıralım. Tüm öğrendiğimiz kalıpları birleştirerek kapsamlı bir e-ticaret ürün sayfası oluşturacağız. Bu örnek; Server Components ile veri çekme, Suspense ile streaming, Client Components ile etkileşim ve önbellekleme stratejilerini bir arada gösterecek.

Veri Çekme Fonksiyonları

// lib/api/products.ts
"use cache";

import { cacheTag, cacheLife } from 'next/cache';

export interface Product {
  id: string;
  name: string;
  slug: string;
  description: string;
  price: number;
  images: string[];
  category: string;
  inStock: boolean;
  specifications: Record<string, string>;
}

export interface Review {
  id: string;
  author: string;
  rating: number;
  content: string;
  createdAt: string;
}

export async function getProduct(slug: string): Promise<Product | null> {
  cacheTag(`product-${slug}`, 'products');
  cacheLife('hours');

  const res = await fetch(`${process.env.API_URL}/products/${slug}`);
  if (!res.ok) return null;
  return res.json();
}

export async function getProductReviews(productId: string): Promise<Review[]> {
  cacheTag(`reviews-${productId}`, 'reviews');
  cacheLife('minutes');

  const res = await fetch(`${process.env.API_URL}/products/${productId}/reviews`);
  if (!res.ok) return [];
  return res.json();
}

export async function getRelatedProducts(category: string, excludeId: string): Promise<Product[]> {
  cacheTag('products', `category-${category}`);
  cacheLife('hours');

  const res = await fetch(
    `${process.env.API_URL}/products?category=${category}&exclude=${excludeId}&limit=4`
  );
  if (!res.ok) return [];
  return res.json();
}

export async function getAllProductSlugs(): Promise<string[]> {
  cacheTag('product-slugs');
  cacheLife('days');

  const res = await fetch(`${process.env.API_URL}/products/slugs`);
  if (!res.ok) return [];
  return res.json();
}

Ürün Sayfası Bileşeni

// app/urunler/[slug]/page.tsx
import { Suspense } from 'react';
import { notFound } from 'next/navigation';
import { getProduct, getAllProductSlugs } from '@/lib/api/products';
import ProductImages from '@/components/product/ProductImages';
import ProductActions from '@/components/product/ProductActions';
import ProductReviewsSection from '@/components/product/ProductReviewsSection';
import RelatedProductsSection from '@/components/product/RelatedProductsSection';
import ReviewsSkeleton from '@/components/skeletons/ReviewsSkeleton';
import RelatedProductsSkeleton from '@/components/skeletons/RelatedProductsSkeleton';
import type { Metadata } from 'next';

interface PageProps {
  params: Promise<{ slug: string }>;
}

// Build zamanında statik sayfalar oluştur
export async function generateStaticParams() {
  const slugs = await getAllProductSlugs();
  return slugs.map((slug) => ({ slug }));
}

// Dinamik metadata oluşturma
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
  const { slug } = await params;
  const product = await getProduct(slug);

  if (!product) {
    return { title: 'Ürün Bulunamadı' };
  }

  return {
    title: `${product.name} | Mağazamız`,
    description: product.description.slice(0, 160),
    openGraph: {
      images: [product.images[0]],
    },
  };
}

export default async function ProductPage({ params }: PageProps) {
  const { slug } = await params;
  const product = await getProduct(slug);

  if (!product) {
    notFound();
  }

  return (
    <main className="max-w-7xl mx-auto px-4 py-8">
      <section className="grid grid-cols-1 md:grid-cols-2 gap-8 mb-12">
        <ProductImages images={product.images} productName={product.name} />

        <div>
          <h1 className="text-3xl font-bold text-gray-900">{product.name}</h1>
          <p className="text-sm text-gray-500 mt-1">Kategori: {product.category}</p>

          <p className="text-3xl font-semibold text-blue-600 mt-4">
            {product.price.toLocaleString('tr-TR', {
              style: 'currency',
              currency: 'TRY',
            })}
          </p>

          <p className={`mt-2 text-sm font-medium ${
            product.inStock ? 'text-green-600' : 'text-red-600'
          }`}>
            {product.inStock ? 'Stokta mevcut' : 'Stokta yok'}
          </p>

          <p className="mt-4 text-gray-700 leading-relaxed">
            {product.description}
          </p>

          <ProductActions
            productId={product.id}
            productName={product.name}
            inStock={product.inStock}
            price={product.price}
          />

          <div className="mt-8">
            <h3 className="text-lg font-semibold mb-3">Teknik Özellikler</h3>
            <dl className="divide-y divide-gray-200">
              {Object.entries(product.specifications).map(([key, value]) => (
                <div key={key} className="py-2 flex justify-between">
                  <dt className="text-gray-600">{key}</dt>
                  <dd className="text-gray-900 font-medium">{value}</dd>
                </div>
              ))}
            </dl>
          </div>
        </div>
      </section>

      <Suspense fallback={<ReviewsSkeleton />}>
        <ProductReviewsSection productId={product.id} />
      </Suspense>

      <Suspense fallback={<RelatedProductsSkeleton />}>
        <RelatedProductsSection
          category={product.category}
          currentProductId={product.id}
        />
      </Suspense>
    </main>
  );
}

İstemci Bileşenleri: Etkileşimli Resim Galerisi

// components/product/ProductImages.tsx
"use client";

import { useState } from 'react';
import Image from 'next/image';

interface ProductImagesProps {
  images: string[];
  productName: string;
}

export default function ProductImages({ images, productName }: ProductImagesProps) {
  const [selectedIndex, setSelectedIndex] = useState(0);

  return (
    <div>
      <div className="relative aspect-square overflow-hidden rounded-lg bg-gray-100">
        <Image
          src={images[selectedIndex]}
          alt={`${productName} - Görsel ${selectedIndex + 1}`}
          fill
          className="object-cover"
          priority
          sizes="(max-width: 768px) 100vw, 50vw"
        />
      </div>

      <div className="flex gap-2 mt-4">
        {images.map((image, index) => (
          <button
            key={index}
            onClick={() => setSelectedIndex(index)}
            className={`relative w-20 h-20 rounded-md overflow-hidden border-2 transition-colors ${
              index === selectedIndex ? 'border-blue-600' : 'border-transparent'
            }`}
          >
            <Image
              src={image}
              alt={`${productName} küçük resim ${index + 1}`}
              fill
              className="object-cover"
              sizes="80px"
            />
          </button>
        ))}
      </div>
    </div>
  );
}

İstemci Bileşenleri: Sepet İşlemleri

// components/product/ProductActions.tsx
"use client";

import { useState, useTransition } from 'react';
import { addToCart } from '@/actions/cart';

interface ProductActionsProps {
  productId: string;
  productName: string;
  inStock: boolean;
  price: number;
}

export default function ProductActions({
  productId,
  productName,
  inStock,
  price,
}: ProductActionsProps) {
  const [quantity, setQuantity] = useState(1);
  const [isPending, startTransition] = useTransition();
  const [notification, setNotification] = useState<string | null>(null);

  const handleAddToCart = () => {
    startTransition(async () => {
      try {
        await addToCart({ productId, quantity });
        setNotification(
          `${quantity} adet "${productName}" sepete eklendi.`
        );
        setTimeout(() => setNotification(null), 3000);
      } catch {
        setNotification('Bir hata oluştu. Lütfen tekrar deneyin.');
      }
    });
  };

  return (
    <div className="mt-6 space-y-4">
      <div className="flex items-center gap-4">
        <label htmlFor="quantity" className="text-sm font-medium">
          Adet:
        </label>
        <select
          id="quantity"
          value={quantity}
          onChange={(e) => setQuantity(Number(e.target.value))}
          className="border rounded-md px-3 py-2"
        >
          {Array.from({ length: 10 }, (_, i) => i + 1).map((num) => (
            <option key={num} value={num}>{num}</option>
          ))}
        </select>

        <span className="text-lg font-semibold">
          Toplam: {(price * quantity).toLocaleString('tr-TR', {
            style: 'currency',
            currency: 'TRY',
          })}
        </span>
      </div>

      <button
        onClick={handleAddToCart}
        disabled={!inStock || isPending}
        className="w-full bg-blue-600 hover:bg-blue-700 disabled:bg-gray-400
                   text-white font-semibold py-3 px-6 rounded-lg transition-colors"
      >
        {isPending ? 'Sepete ekleniyor...' : inStock ? 'Sepete Ekle' : 'Stokta Yok'}
      </button>

      {notification && (
        <div className="bg-green-50 border border-green-200 text-green-700
                        px-4 py-3 rounded-lg text-sm">
          {notification}
        </div>
      )}
    </div>
  );
}

Streaming Bileşenler: Yorumlar ve İlgili Ürünler

// components/product/ProductReviewsSection.tsx
import { getProductReviews } from '@/lib/api/products';

interface ProductReviewsSectionProps {
  productId: string;
}

export default async function ProductReviewsSection({ productId }: ProductReviewsSectionProps) {
  const reviews = await getProductReviews(productId);

  if (reviews.length === 0) {
    return (
      <section className="mt-12">
        <h2 className="text-2xl font-bold mb-4">Müşteri Yorumları</h2>
        <p className="text-gray-500">Henüz yorum yapılmamış. İlk yorumu siz yapın!</p>
      </section>
    );
  }

  const averageRating =
    reviews.reduce((sum, r) => sum + r.rating, 0) / reviews.length;

  return (
    <section className="mt-12">
      <h2 className="text-2xl font-bold mb-2">Müşteri Yorumları</h2>
      <p className="text-gray-600 mb-6">
        Ortalama Puan: {averageRating.toFixed(1)} / 5 ({reviews.length} yorum)
      </p>

      <div className="space-y-6">
        {reviews.map((review) => (
          <div key={review.id} className="border-b pb-4">
            <div className="flex items-center justify-between">
              <span className="font-semibold">{review.author}</span>
              <span className="text-yellow-500">
                {'★'.repeat(review.rating)}{'☆'.repeat(5 - review.rating)}
              </span>
            </div>
            <p className="text-gray-700 mt-2">{review.content}</p>
            <time className="text-sm text-gray-400 mt-1 block">
              {new Date(review.createdAt).toLocaleDateString('tr-TR')}
            </time>
          </div>
        ))}
      </div>
    </section>
  );
}
// components/product/RelatedProductsSection.tsx
import { getRelatedProducts } from '@/lib/api/products';
import Image from 'next/image';
import Link from 'next/link';

interface RelatedProductsSectionProps {
  category: string;
  currentProductId: string;
}

export default async function RelatedProductsSection({
  category,
  currentProductId,
}: RelatedProductsSectionProps) {
  const relatedProducts = await getRelatedProducts(category, currentProductId);

  if (relatedProducts.length === 0) return null;

  return (
    <section className="mt-12">
      <h2 className="text-2xl font-bold mb-6">İlgili Ürünler</h2>
      <div className="grid grid-cols-2 md:grid-cols-4 gap-6">
        {relatedProducts.map((product) => (
          <Link
            key={product.id}
            href={`/urunler/${product.slug}`}
            className="group"
          >
            <div className="relative aspect-square overflow-hidden rounded-lg bg-gray-100">
              <Image
                src={product.images[0]}
                alt={product.name}
                fill
                className="object-cover group-hover:scale-105 transition-transform"
                sizes="(max-width: 768px) 50vw, 25vw"
              />
            </div>
            <h3 className="mt-2 font-medium group-hover:text-blue-600">
              {product.name}
            </h3>
            <p className="text-blue-600 font-semibold">
              {product.price.toLocaleString('tr-TR', {
                style: 'currency',
                currency: 'TRY',
              })}
            </p>
          </Link>
        ))}
      </div>
    </section>
  );
}

Performans Optimizasyonu ve En İyi Pratikler

React Server Components ve Next.js App Router ile geliştirme yaparken performansı en üst düzeye çıkarmak için dikkat etmeniz gereken bazı önemli noktalar var. Bunları tek tek ele alalım.

1. Gereksiz "use client" Kullanımından Kaçının

Belki de en temel performans ilkesi bu: "use client" direktifini yalnızca gerçekten ihtiyaç duyulan bileşenlerde kullanın. Her "use client" direktifi, o bileşen ve altındaki tüm bileşenlerin istemci JavaScript bundle'ına dahil edilmesi anlamına geliyor.

// ✅ DOĞRU: Etkileşimli kısmı küçük tutun
// components/ProductCard.tsx — Server Component
import LikeButton from './LikeButton'; // Sadece bu Client Component

export default function ProductCard({ product }: { product: Product }) {
  return (
    <div className="border rounded-lg p-4">
      <h3 className="text-lg font-bold">{product.name}</h3>
      <p className="text-gray-600">{product.description}</p>
      <p className="text-xl font-semibold">{product.price} TL</p>
      <LikeButton productId={product.id} />
    </div>
  );
}

2. Bileşen Granülerliği

Etkileşimli kısımları mümkün olan en küçük bileşenlere ayırın. Bir sayfadaki tek bir butonun onClick gerektirmesi, tüm sayfayı Client Component yapmak için yeterli bir neden değil. Bu hata düşündüğünüzden daha yaygın.

3. Paralel Veri Çekme

Birbirinden bağımsız veri çekme işlemlerini her zaman Promise.all veya ayrı Suspense sınırları ile paralel yürütün. Toplam sayfa yükleme süresini dramatik biçimde azaltır.

4. Akıllı Önbellekleme

Her veri kaynağı için uygun önbellek stratejisini belirlemek önemli:

  • Sık değişen veriler (fiyatlar, stok durumu): cacheLife('seconds') veya cacheLife('minutes')
  • Orta sıklıkta değişen veriler (ürün açıklamaları, blog yazıları): cacheLife('hours')
  • Nadiren değişen veriler (kategori listesi, site ayarları): cacheLife('days') veya cacheLife('weeks')

5. Prefetching ile Navigasyon Hızlandırma

Next.js, Link bileşeni aracılığıyla görünür durumdaki bağlantıları otomatik olarak önceden yüklüyor. Bu davranışı kontrol edebilirsiniz:

import Link from 'next/link';

// Varsayılan: Otomatik prefetch (statik rotalar tam, dinamik rotalar kısmi)
<Link href="/products">Ürünler</Link>

// Prefetch'i devre dışı bırak (nadiren kullanılır)
<Link href="/products" prefetch={false}>Ürünler</Link>

6. error.tsx ile Hata Yönetimi

Her route segmenti için hata sınırı oluşturmak, kullanıcı deneyimini ciddi ölçüde iyileştiriyor. Bir bileşende hata oluştuğunda tüm sayfa çökmek yerine yalnızca ilgili bölüm hata durumunu gösteriyor.

// app/products/[id]/error.tsx
"use client"; // Hata bileşenleri Client Component olmalıdır

interface ErrorProps {
  error: Error & { digest?: string };
  reset: () => void;
}

export default function ProductError({ error, reset }: ErrorProps) {
  return (
    <div className="max-w-md mx-auto mt-12 text-center">
      <h2 className="text-2xl font-bold text-red-600">Bir hata oluştu</h2>
      <p className="text-gray-600 mt-2">
        Ürün bilgileri yüklenirken bir sorun yaşandı.
      </p>
      <button
        onClick={reset}
        className="mt-4 bg-blue-600 text-white px-6 py-2 rounded-lg
                   hover:bg-blue-700 transition-colors"
      >
        Tekrar Dene
      </button>
    </div>
  );
}

7. Metadata ve SEO Optimizasyonu

Server Components, metadata oluşturmayı da kolaylaştırıyor. Her sayfa için dinamik metadata tanımlayarak SEO performansını artırabilirsiniz:

// app/products/[slug]/page.tsx
import type { Metadata } from 'next';

export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
  const { slug } = await params;
  const product = await getProduct(slug);

  return {
    title: product?.name ?? 'Ürün Bulunamadı',
    description: product?.description?.slice(0, 155),
    alternates: {
      canonical: `/urunler/${slug}`,
    },
    openGraph: {
      title: product?.name,
      description: product?.description,
      images: product?.images?.[0] ? [{ url: product.images[0] }] : [],
      type: 'website',
    },
  };
}

8. Bundle Boyutunu İzleyin

Next.js'in yerleşik bundle analiz araçlarını kullanarak istemciye gönderilen JavaScript miktarını mutlaka izleyin. @next/bundle-analyzer paketi bu konuda çok işe yarıyor:

// next.config.ts
import withBundleAnalyzer from '@next/bundle-analyzer';

const nextConfig: NextConfig = {
  // ...
};

export default process.env.ANALYZE === 'true'
  ? withBundleAnalyzer({ enabled: true })(nextConfig)
  : nextConfig;
# Bundle analizi çalıştırma
ANALYZE=true npm run build

9. Server Component'lerde Büyük Kütüphaneleri Tercih Edin

Ağır kütüphaneleri (tarih formatlama, markdown işleme, veri dönüştürme gibi) Server Components'te kullanarak istemci bundle'ınızı küçük tutun. date-fns, marked veya lodash gibi kütüphaneleri Server Component'lerde kullandığınızda istemciye hiçbir ek JavaScript gönderilmiyor. Bu tek başına bile ciddi bir avantaj.

10. Doğru Rendering Stratejisini Seçin

Her sayfa için en uygun rendering stratejisini belirlemek kritik önem taşıyor:

  • Statik (SSG): Pazarlama sayfaları, blog yazıları, dokümantasyon — içerik nadiren değişir.
  • ISR: Ürün sayfaları, kategori listeleri — içerik periyodik olarak güncellenir ama anlık doğruluk kritik değildir.
  • Dinamik (SSR): Kullanıcı kontrol paneli, alışveriş sepeti, kişiselleştirilmiş içerik — her istekte güncel veri gerekir.
  • İstemci tarafı: Gerçek zamanlı sohbet, anlık bildirimler — sürekli güncellenen etkileşimli arayüzler.

Sonuç

Bu makalede React Server Components ve veri çekme stratejilerinin Next.js App Router ile nasıl uygulandığını kapsamlı bir biçimde inceledik. Hızlıca özetleyelim.

React Server Components, web uygulamalarının mimarisini temelden değiştiren bir paradigma. Sunucu öncelikli düşünme modeli sayesinde daha az JavaScript, daha hızlı yükleme süreleri ve daha iyi güvenlik elde ediyoruz. Temel ilke basit: bileşenlerinizi varsayılan olarak Server Component olarak tutun, yalnızca etkileşim gerektiren kısımları "use client" ile işaretleyin.

Veri çekme artık doğrudan bileşen içinde, async/await ile yapılıyor. Paralel veri çekme ve istek memoizasyonu ile performansı optimize edebiliyoruz. Doğrudan veritabanı erişimi ve API çağrıları sayesinde ayrı API route'larına olan ihtiyaç büyük ölçüde azalıyor.

Önbellekleme stratejileri Next.js 15 ve 16 ile birlikte örtük modelden açık modele geçiş yaptı. "use cache" direktifi, cacheLife ve cacheTag ile her veri kaynağı için uygun önbellek politikasını bildirimsel olarak tanımlayabiliyoruz.

Streaming ve Suspense, kullanıcıya aşamalı bir yükleme deneyimi sunarak algılanan performansı dramatik biçimde artırıyor. Her Suspense sınırı bağımsız bir streaming birimi oluşturarak hazır olan içeriğin anında gösterilmesini sağlıyor.

Rendering stratejileri (SSG, ISR, SSR) artık sayfa düzeyinde değil, bileşen düzeyinde belirleniyor. Aynı sayfa içinde statik ve dinamik bileşenleri bir arada kullanabilmek, esnekliği inanılmaz artırıyor.

E-ticaret ürün sayfası örneğimiz de tüm bu kalıpların pratikte nasıl bir araya geldiğini gösterdi. Ürün bilgileri Server Component ile render edilirken, resim galerisi ve sepete ekleme butonu Client Component olarak çalışıyor; yorumlar ve ilgili ürünler Suspense ile ayrı streaming birimlerinde yükleniyor.

Bu makaledeki kalıpları, daha önce yayınladığımız Server Actions ve Middleware makaleleriyle birleştirdiğinizde, Next.js App Router ile modern full-stack geliştirmenin tüm temel yapı taşlarına sahip olursunuz.

Next.js ekosistemi hızla gelişmeye devam ediyor. dynamicIO, Partial Pre-Rendering ve "use cache" gibi özellikler, sunucu tarafı rendering'in geleceğini şekillendiriyor. Bu gelişmeleri takip etmek ve projelerinize uygulamak, kullanıcılarınıza en iyi deneyimi sunmanızı sağlayacaktır.

Bir sonraki makalede görüşmek üzere!

Yazar Hakkında Editorial Team

Our team of expert writers and editors.