Mert Özen Her Satırda Daha İleri
Frontend React — #15

Next.js'e Kısa Bakış: Server Components, App Router, Server Action'lar, Cache Components ve Ne Zaman SPA Yeter?

Mert Özen 15 Kas 2026 13 dk 4 görüntülenme
Next.js'e Kısa Bakış: Server Components, App Router, Server Action'lar, Cache Components ve Ne Zaman SPA Yeter?

Next.js, React'i sunucuda çalıştırır: Server Component'ler veriyi doğrudan await eder ve kodları tarayıcıya inmez, Cache Components ile neyin cache'leneceğini 'use cache' ve cacheLife ile sen seçersin, her Server Function ise doğrulama ve yetki kontrolü isteyen public bir endpoint'tir. Bookstore'un kitap detayını Next.js 16.4 ile Partial Prerender olarak sunuyor, curl ile Server Function'ı doğrudan çağırıyor ve SPA'nın ne zaman yettiğini tartışıyoruz.

Sürüm notu: Bu yazıdaki örnekler Next.js 16.4 (Turbopack), React 19.3, TypeScript 7.0, zod 4.6 ve Node.js 24 ile denenmiştir; uygulama next build ve next start ile yerelde çalıştırılmış, tarayıcı adımları Playwright 1.65 ile headless Chromium'da gözlenmiştir. Vercel'e deploy denenmedi.

Önce Tahmin Et

Sitedeki serilerin ortak örnek uygulaması Bookstore'un (bir online kitapçı) React istemcisini on dört yazı boyunca tarayıcıda çalışan bir SPA (single-page application; tek HTML sayfasında çalışan uygulama) olarak yazdık. Bu yazıda tek bir ekranı, kitap detayını, Next.js ile sunucuda render ediyoruz. Amaç tam bir Next.js eğitimi değil; React'in sunucu tarafını görmek ve SPA'nın ne zaman yettiğine karar verebilmek.

Üç soru. Olmayan bir ISBN (kitabın uluslararası standart numarası) için notFound() çağıran sayfa hangi HTTP status'u döndürür? Sepete ekleme formunun çağırdığı Server Function'a curl ile quantity=1000 gönderirsen ne olur? Bir Server Component'ten client component'e onAdded diye bir fonksiyon geçebilir misin?

Server Components: Sunucu ile İstemci Arasındaki Sınır

Şimdiye kadar yazdığımız her component tarayıcıda çalıştı. React Server Components modelinde varsayılan tersine dönüyor: component sunucuda çalışıyor, async olabiliyor, veritabanına ya da API'ye doğrudan gidebiliyor ve kodu tarayıcıya hiç inmiyor. Kitap sayfası:

import { notFound } from 'next/navigation';
import { Suspense } from 'react';
import { getBook, getBooksByAuthor, getStock } from '@/lib/bookstoreApi';
import { AddToCartForm } from './AddToCartForm';
import { MoreByAuthor } from './MoreByAuthor';

// Build sırasında prerender edilir; diğer ISBN'ler ilk istekte render edilir
export async function generateStaticParams() {
  return [{ isbn: '9780441172719' }, { isbn: '9780141439587' }];
}

// Bir Server Component: async, yalnız sunucuda çalışır, kodu tarayıcıya hiç inmez
export default async function BookPage({ params }: PageProps<'/books/[isbn]'>) {
  const { isbn } = await params;
  const book = await getBook(isbn);
  if (book === null) {
    notFound();
  }
  // await edilmiyor: promise client component'e gider, o da use() ile bekler
  const moreByAuthor = getBooksByAuthor(book.author).then((books) => books.filter((b) => b.isbn !== book.isbn));

  return (
    <article>
      <h1>{book.title}</h1>
      <p>{book.author}, {book.publishedYear} · {book.category} · {book.price.toFixed(2)} TRY</p>
      <p>{book.description}</p>
      <Suspense fallback={<p className="muted">Checking stock…</p>}>
        <Stock isbn={isbn} />
      </Suspense>
      <AddToCartForm isbn={book.isbn} />
      <Suspense fallback={<p className="muted">Loading more books…</p>}>
        <MoreByAuthor author={book.author} books={moreByAuthor} />
      </Suspense>
    </article>
  );
}

async function Stock({ isbn }: { isbn: string }) {
  const available = await getStock(isbn);
  return <p className="muted">{available > 0 ? `${available} in stock` : 'Out of stock'}</p>;
}

useEffect yok, useQuery yok, loading state'i yok: component veriyi await ediyor ve sonucu render ediyor. Sekizinci yazıda use(promise)'un asıl yerinin framework'ün verdiği promise'ler olduğunu söylemiştik; burada moreByAuthor promise'i sunucuda başlıyor, client component'e props olarak gidiyor ve React sonucu tarayıcıya stream ediyor:

'use client';

import Link from 'next/link';
import { use, useState } from 'react';

type MoreByAuthorProps = {
  author: string;
  // Sunucuda oluşturulan bir promise; React sonucunu tarayıcıya stream eder
  books: Promise<{ isbn: string; title: string }[]>;
};

export function MoreByAuthor({ author, books }: MoreByAuthorProps) {
  const list = use(books);
  const [showAll, setShowAll] = useState(false);
  if (list.length === 0) {
    return null;
  }
  const shown = showAll ? list : list.slice(0, 1);
  return (
    <section>
      <h2>More by {author}</h2>
      <ul>
        {shown.map((b) => <li key={b.isbn}><Link href={`/books/${b.isbn}`}>{b.title}</Link></li>)}
      </ul>
      {list.length > 1 && !showAll && <button type="button" onClick={() => setShowAll(true)}>Show all {list.length}</button>}
    </section>
  );
}

'use client' bir dosyayı ve onun import ettiği her şeyi tarayıcı bundle'ına sokan sınır. State, effect ya da event handler gereken component'ler bu tarafta. Sınırı geçen props'lar serileştirilebilir olmalı: string, number, plain object, array, Date, promise, JSX (JavaScript içinde HTML'e benzeyen syntax) ve Server Function geçebilir; sıradan bir fonksiyon geçemez. Önce Tahmin Et'teki üçüncü sorunun cevabı build sırasında geldi:

Error: Event handlers cannot be passed to Client Component props.
  {isbn: ..., onAdded: function onAdded}
                       ^^^^^^^^^^^^^^^^
If you need interactivity, consider converting part of this to a Client Component.

15-server-client-tr.png

App Router'ın Dosya Konvansiyonları

Route'lar app/ altındaki klasörlerden çıkıyor; React Router'daki route ağacını burada klasör yapısı tanımlıyor:

next/
  next.config.ts
  lib/bookstoreApi.ts            // 'server-only': yalnız sunucuda import edilebilir
  lib/cart.ts
  app/layout.tsx                 // kök layout; SPA'daki RootLayout
  app/page.tsx                   // /
  app/books/[isbn]/page.tsx      // /books/:isbn
  app/books/[isbn]/loading.tsx   // sayfa render edilirken gösterilen Suspense fallback'i
  app/books/[isbn]/error.tsx     // error boundary ('use client')
  app/books/[isbn]/not-found.tsx // notFound() çağrılınca
  app/books/[isbn]/actions.ts    // 'use server': Server Function'lar

Proje create-next-app ile başlıyor. 16.4'ten beri yeni projelerin config'i şöyle geliyor:

$ npx create-next-app@16.4.6 bookstore-next --yes --use-npm --disable-git --no-agent-feedback --skip-install
$ cat bookstore-next/next.config.ts
import type { NextConfig } from "next";

const nextConfig: NextConfig = {
  /* config options here */
  cacheComponents: true,
  partialPrefetching: true,
  turbopack: {
    rules: {
      "*.css": {
        loaders: ["@tailwindcss/turbopack"],
        as: "*.css",
      },
    },
  },
};

export default nextConfig;

Next.js 16.4 duyurusuna göre Cache Components yeni projelerde varsayılan ve Next.js 17'de bütün projeler için varsayılan olacak. Bu modelde hiçbir şey kendiliğinden cache'lenmiyor; cache'lemek istediğin yeri 'use cache' ile işaretliyorsun.

Cache Components: 'use cache', cacheLife ve Statik Shell

// Sunucuda cache'lenir: aynı ISBN bir saat boyunca Bookstore.Api'ye tekrar gitmez
export async function getBook(isbn: string): Promise<Book | null> {
  'use cache';
  cacheLife('hours');
  cacheTag(`book-${isbn}`);
  const book = await getJson<BookWithStock>(`/api/books/${isbn}`, 'getBook');
  if (book === null) {
    return null;
  }
  // Stok bir saat cache'lenmemeli: cache'lenen değerin dışında bırak
  const { available: _, ...rest } = book;
  return rest;
}
// ...
// Cache'lenmez: stok her siparişte değişir, bu yüzden her istekte okunur
export async function getStock(isbn: string): Promise<number> {
  return (await getJson<BookWithStock>(`/api/books/${isbn}`, 'getStock'))?.available ?? 0;
}

cacheLife('hours') preset'i Next.js dokümanına göre 5 dakika stale (istemci router'ının sunucuya sormadan kullanacağı süre), 1 saat revalidate (arka planda yenileme) ve 1 gün expire demek. cacheTag ile bir kitabın cache'ini fiyat değiştiğinde revalidateTag ya da updateTag ile hedefli olarak düşürebiliyorsun. Stok ise her siparişle değiştiği için cache'lenmiyor. Prerender build sırasında API'yi çağırdığı için next build çalışırken Bookstore.Api'nin ayakta olması gerekiyor (BOOKSTORE_API, varsayılan http://localhost:5080). Build çıktısı bu kararları gösteriyor:

▲ Next.js 16.4.6 (Turbopack)
✓ Running next.config.ts took 24ms
- Cache Components enabled
- Partial Prefetching enabled

  Creating an optimized production build ...
✓ Compiled successfully in 190ms
  Running TypeScript ...
  Finished TypeScript in 223ms ...
  Collecting page data using 5 workers ...
  Generating static pages using 5 workers (0/6) ...
✓ Generating static pages using 5 workers (6/6) in 294ms
  Finalizing page optimization ...

Route (app)                 Revalidate  Expire
┌ ○ /                               1m      1h
├ ○ /_not-found
└   /books/[isbn]
  ├ ◐ /books/[isbn]
  ├ ◐ /books/9780441172719          1h      1d
  └ ◐ /books/9780141439587          1h      1d


○  (Static)             prerendered as static content
◐  (Partial Prerender)  prerendered as static HTML with dynamic server-streamed content

generateStaticParams'ta verdiğimiz Dune ve Emma build sırasında prerender edildi; ◐ işareti Partial Prerender: sayfanın statik kısmı (başlık, açıklama, fiyat) hazır HTML, stok satırı istek anında stream edilen bir boşluk. next start ile Dune'u üç kez istedik:

GET /books/9780441172719 → 200 in 0.019572s
GET /books/9780441172719 → 200 in 0.012198s
GET /books/9780441172719 → 200 in 0.014251s
# next start log
[api] getStock: GET /api/books/9780441172719
[api] getStock: GET /api/books/9780441172719
[api] getStock: GET /api/books/9780441172719
[api] getStock: GET /api/books/9780441172719
[api] getStock: GET /api/books/9780441172719
[api] getStock: GET /api/books/9780441172719

getBook hiç çağrılmadı; kitap build'de cache'e girmişti. Her istek yalnız stoğu okudu, ama iki kez. Aynı ölçümü partialPrefetching: false ile tekrarladığımızda istek başına bir çağrıya indi; istek anında okunan veri pahalıysa bu davranışı kendi uygulamanda ölç. Prerender edilmemiş bir kitap (1984) ilk istekte getBook ve getBooksByAuthor'ı çağırdı, ikinci istekte artık cache'ten geldi.

Stok satırını saran <Suspense> zorunlu. Onu ve loading.tsx'i kaldırınca build durdu:

Error: Route "/books/[isbn]": Next.js encountered uncached or runtime data during prerendering.

`fetch(...)`, `cookies()`, `headers()`, `params`, `searchParams`, or `connection()` accessed outside of `<Suspense>` prevents the route from being prerendered, blocking the page load and leading to a slower user experience.

Ways to fix this:
  - [stream] Provide a placeholder with `<Suspense fallback={...}>` around the data access
  - [cache] For uncached data (`fetch`, database calls): cache the access with `"use cache"` (does not apply to `connection()`)
  - [block] Set `export const instant = false` to allow a blocking route

Learn more: https://nextjs.org/docs/messages/blocking-prerender-dynamic

Next.js burada bir karar istiyor: veriyi cache'le, bir fallback ile stream et ya da sayfanın bloklanmasını bilerek kabul et. Sekizinci yazıda TanStack Query ile istemcide verdiğimiz kararın aynısı, bu sefer sunucuda ve build sırasında soruluyor.

Server Function'lar ve Form'lar

Onuncu yazıda form action'larını istemcide çalışan fonksiyonlarla kullanmıştık ve aynı API'nin Server Function'larla da çalıştığını söylemiştik. 'use server' ile işaretlenen fonksiyon sunucuda çalışıyor; client component onu bir referans olarak alıyor:

'use server';

import { cookies } from 'next/headers';
import * as z from 'zod';
import { addToServerCart } from '@/lib/cart';

export type AddToCartState = { message: string } | null;

const addToCartInput = z.object({
  isbn: z.string().regex(/^\d{13}$/),
  quantity: z.coerce.number().int().min(1).max(10),
});

// Server Function public bir POST endpoint'i: yalnız bu form değil, herkes istediği gövdeyle çağırabilir
export async function addToCart(_previous: AddToCartState, formData: FormData): Promise<AddToCartState> {
  const input = addToCartInput.safeParse({ isbn: formData.get('isbn'), quantity: formData.get('quantity') });
  if (!input.success) {
    return { message: 'Invalid request.' };
  }
  const cookieStore = await cookies();
  const cart = await addToServerCart(cookieStore.get('cartId')?.value, input.data.isbn, input.data.quantity);
  // Sepet kimliği sunucu tarafında kalır: localStorage değil, HttpOnly bir cookie
  cookieStore.set('cartId', cart.id, { httpOnly: true, sameSite: 'lax', path: '/' });
  const count = cart.items.reduce((sum, line) => sum + line.quantity, 0);
  return { message: `Added. Your cart has ${count} item(s).` };
}
'use client';

import { useActionState } from 'react';
import { addToCart, type AddToCartState } from './actions';

// Form bir Server Function'a POST eder; useActionState döndürdüğü mesajı tutar
export function AddToCartForm({ isbn }: { isbn: string }) {
  const [state, formAction, isPending] = useActionState<AddToCartState, FormData>(addToCart, null);
  return (
    <form action={formAction}>
      <input type="hidden" name="isbn" value={isbn} />
      <label>Copies <input name="quantity" type="number" min={1} max={10} defaultValue={1} /></label>{' '}
      <button type="submit" disabled={isPending}>{isPending ? 'Adding…' : 'Add to cart'}</button>
      {state && <p role="status">{state.message}</p>}
    </form>
  );
}

Sepet kimliği bu sefer localStorage'da değil, HttpOnly bir cookie'de; on dördüncü yazıdaki BFF'in (Backend for Frontend; frontend'e özel sunucu katmanı) yaptığı işi burada Next.js sunucusu yapıyor. Playwright ile JavaScript açık ve kapalı denedik:

JavaScript on : status = "Added. Your cart has 2 item(s)."
                cookie cartId: httpOnly=true sameSite=Lax
JavaScript off: visible text after the POST = ""
                result inside the response = "Added. Your cart has 1 item(s)."

JavaScript açıkken useActionState sonucu sayfa yenilenmeden gösterdi. JavaScript kapalıyken form klasik bir HTML formu gibi POST etti ve Server Function çalıştı (sonuç yanıtın içinde var), ama tarayıcı boş bir sayfa gösterdi: yanıtta yalnız stream edilen gizli parçalar vardı, onları yerine koyan script'ler de JavaScript istiyor. Progressive enhancement'a güveniyorsan, JavaScript kapalıyken kendi sayfalarında dene.

Her Server Function Public Bir Endpoint

Formdaki gizli input'lar action'ın kimliğini HTML'de taşıyor; onu bilen herkes formu kullanmadan çağırabilir. Önce Tahmin Et'teki ikinci sorunun cevabı:

$ curl -s $NEXT/books/9780441172719 | grep -o "ACTION_1:0[^/]*"
ACTION_1:0" value="{&quot;id&quot;:&quot;60f4e072abc6…&quot;,&quot;bound&quot;:&quot;$@1&quot;}"
$ curl -s -F '$ACTION_REF_1=' -F '$ACTION_1:0={"id":"<action-id>","bound":"$@1"}' -F '$ACTION_1:1=[null]' \
       -F '$ACTION_KEY=<key>' -F isbn=9780441172719 -F quantity=3 $NEXT/books/9780441172719 | grep -o 'Added[^"]*\|Invalid request\.'
Added. Your cart has 3 item(s).
$ curl -s -F '$ACTION_REF_1=' -F '$ACTION_1:0={"id":"<action-id>","bound":"$@1"}' -F '$ACTION_1:1=[null]' \
       -F '$ACTION_KEY=<key>' -F isbn=9780441172719 -F quantity=1000 $NEXT/books/9780441172719 | grep -o 'Added[^"]*\|Invalid request\.'
Invalid request.

Next.js dokümanı bunu açıkça söylüyor: export edilen bir Server Function doğrudan POST ile çağrılabilir. Next.js action kimliklerini şifreli ve build'e özel üretiyor, kullanılmayan action'ları bundle'dan atıyor ve Origin header'ını Host ile karşılaştırarak başka sitelerden gelen çağrıları reddediyor. Ama bunlar input doğrulamanın ve yetki kontrolünün yerini tutmuyor. Bizdeki zod şeması quantity=1000'i bu yüzden reddetti. Sayfadaki bir oturum kontrolü sayfanın Server Function'larını korumaz; her action, ASP.NET Core'daki bir controller action'ı gibi kullanıcıyı ve kaynağın sahibini kendisi kontrol etmeli. Sipariş veren bir action yazsaydık müşteri bilgisini formdan değil oturumdan alırdık.

Next.js, Angular SSR ve ASP.NET Core MVC

Tablo Next.js'i Angular SSR (server-side rendering; sayfanın ilk HTML'inin sunucuda üretilmesi) ve ASP.NET Core MVC (Model-View-Controller; sayfayı sunucuda controller ve view ile üreten yapı) ile karşılaştırıyor:

KonuNext.js App RouterAngular SSRASP.NET Core MVC
Sunucuda ne çalışırServer Component'ler; client component'ler de ilk HTML içinBütün uygulama, sonra tarayıcıda hydrationController ve Razor view
Tarayıcıya giden JavaScriptYalnız client component'lerBütün uygulamaYalnız eklediğin script'ler
Veri yazmaServer FunctionHttpClient ile APIForm POST, controller action
Cache'use cache', cacheLifeHttpClient transfer cache (TransferState)Output caching, response cache
Çalışma ortamıNode.js sunucusu (next start)Node.js sunucusu.NET (Kestrel)

Hydration (sunucunun ürettiği HTML'e tarayıcıda event handler'ların ve state'in bağlanması) Angular SSR'da bütün uygulamayı kapsıyor; Server Components'ta yalnız client component'leri. Sunucu tarafı render seçimini .NET tarafından düşünmek için MVC serisinin ilk yazısı "Server-Side Rendering Ne Zaman? MVC, Razor Pages, Blazor SSR ve Angular SPA Arasında Seçim" iyi bir başlangıç. React Router tarafında kalmak isteyen için framework mode da aynı soruyu cevaplıyor: dokuzuncu yazıdaki loader'lar sunucuda çalışıyor, sayfa sunucuda render ediliyor.

On birinci yazıda module seviyesindeki bir store'un server-side rendering'de bütün kullanıcılar arasında paylaşılacağını söylemiştik. Next.js'te Zustand'ın önerisi bu yüzden global store değil, istek başına store: createStore ile bir factory, onu bir provider'da useState(() => createStore()) ile oluşturmak ve Context ile client component'lere vermek. Server Component'ler store'u okumaz; hook ve Context kullanamazlar.

Ne Zaman SPA Yeter?

Bookstore'un back office'i (stok, sipariş onayı, kurumsal portal) giriş yapmış kullanıcının uzun süre kaldığı, arama motorunun hiç görmediği ekranlar; bir SPA ve önünde bir BFF burada yeterli ve daha basit. Katalog ve kitap detayı ise arama motorunda görünmesi, ilk açılışı hızlı olması gereken sayfalar; sunucu render'ı ve cache burada gerçek fayda sağlıyor. Ekibin .NET ağırlıklıysa bu sayfalar için Razor da bir seçenek; her şeyi tek framework'e taşımak zorunda değilsin. Karar sayfa türüne göre verilmeli: SEO (Search Engine Optimization; arama motorlarında görünürlük) ve ilk yükleme mi önemli, yoksa uzun süreli etkileşim mi?

Üretimde Dikkat

Next.js bir Node.js sunucusu istiyor: next start ya da onu taşıyan bir Docker image'ı. Next.js'in self-hosting rehberine göre Cache Components next start ile varsayılan olarak çalışıyor, ama birkaç noktaya dikkat etmek gerekiyor. Varsayılan cache her instance'ın kendi belleğinde; birden çok instance çalıştırıyorsan ortak bir cache handler (Redis gibi) ve revalidateTag'in bütün instance'lara ulaşması gerekiyor. Server Function closure'larını şifreleyen anahtar her build'de yeniden üretiliyor; instance'lar aynı anahtarı kullanmalı (NEXT_SERVER_ACTIONS_ENCRYPTION_KEY). Önüne nginx koyarsan streaming için yanıt buffering'ini kapatman gerekiyor, yoksa statik shell'in hızlı gelmesinin anlamı kalmaz.

Vendor bağımlılığı tartışmasının pratik özeti: kod React, ama 'use cache', cacheLife, dosya konvansiyonları ve Server Function'ların taşınma biçimi Next.js'e özgü. Vercel'de bu altyapı platformun parçası; kendi sunucunda çalıştırmak mümkün ama yukarıdaki işleri senin yapman gerekiyor. Bu yazıda Vercel'e deploy denemedik.

Sık Düşülen Tuzaklar

notFound()'ın 404 döndürdüğünü varsaymak. Önce Tahmin Et'teki ilk sorunun cevabı:

$ curl -s -o /dev/null -w "%{http_code}\n" $NEXT/books/9999999999999
200
$ curl -s $NEXT/books/9999999999999 | grep -o "<meta name=\"robots\"[^>]*>"
<meta name="robots" content="noindex"/>

Cache Components ile statik shell önce gönderiliyor ve status 200 olarak sabitleniyor; Next.js arama motorları için noindex ekliyor. Gerçek bir 404 gerekiyorsa (analitik, izleme), kontrolü stream başlamadan önce, örneğin proxy.ts'te yapmak gerekiyor.

Secret'ı client component'e sızdırmak: Server Component'ten client component'e geçen her props tarayıcıya gidiyor. API'den gelen bütün kaydı değil, ekranın ihtiyacı olan alanları geç; server-only import'u sunucu kodunun yanlışlıkla client tarafına girmesini build hatasına çeviriyor. NEXT_PUBLIC_ önekli değişkenler, Vite'taki VITE_ gibi bundle'a yazılıyor.

Request-time veriyi cache'lenmiş bir fonksiyona koymak: stoğu getBook'un içinde bıraksaydık bir saat boyunca eski stok gösterilecekti. Bu yüzden available'ı cache'lenen değerden çıkardık.

Küçük Bir Deneme

★☆☆ getBook'ta cacheLife('hours')'u cacheLife('seconds') yap ve next build'i tekrarla. Route tablosundaki Revalidate ve Expire sütunları ve ◐ satırları nasıl değişti?

★★☆ Fiyat değişikliği için bir Server Function yaz: updateTag(`book-${isbn}`) ile kitabın cache'ini düşürsün. Kimin çağırabileceğini nasıl kontrol edersin?

★★★ Olmayan bir ISBN için 404 status'u döndüren bir proxy.ts yaz. ISBN biçimi geçersizse API'ye gitmeden cevap ver; biçim geçerliyse ne yaparsın?

React'in sunucu tarafını da gördük. Serinin son yazısında geriye dönüp React 18'den 19.3'e gelen değişikliklere bakacağız: concurrent rendering, Actions, use(), ref'in props olması, Activity, useEffectEvent, <ViewTransition> ve Fragment ref'leri; ayrıca mevcut bir projeyi yükseltirken kullanılacak bir kontrol listesi.