Server Components Gerçekten Ne Zaman Kullanılmalı
Veriyi sunucuda bırakıp etkileşimi yaprağa indirmek

Ürün sayfasının tepesine 'use client' yazdığım günü hatırlıyorum. Gerekçe küçüktü: beğeni butonu. Buton useState istiyordu, ben de tüm makaleyi, fiyatı, tabloyu ve ilgili yazı listesini aynı dosyaya taşıdım. Sayfa çalıştı. Network sekmesinde JS 280 KB geldi, HTML ise neredeyse iskelet. Veriyi tarayıcıda bir kez daha istedim çünkü sunucuda çektiğim JSON'u client ağacına sokamamıştım.
App Router tanıtılırken Server Component varsayılan diye anlatıldı. Varsayılan, 'her yerde kullan' demek değil. Sunucu bileşeni bir yer: gizli kalması gereken iş, ilk HTML'e girmesi gereken metin, tarayıcıya hiç binmemesi gereken bağımlılık. Client bileşen başka bir yer: tıklama, yerel state, browser API. İkisini aynı cümlede tutamayınca dosyanın tepesine 'use client' yazmak en kısa yol gibi duruyor. En kısa yol, sınırı yanlış çiziyor.
Sunucu bileşeninin gerçekten ödediği şey
Server Component üç işi ucuza çözer. Bir: veriyi istek sırasında okur, HTML üretir, tarayıcıya ham satır göndermez. İki: ortam değişkeni, imza, veritabanı sürücüsü paket grafiğine girmez. Üç: üst bileşen sunucu kaldıkça altındaki statik ağaç da sunucuda kalır. Bu üçü bir arada değilse 'async function Page' yazmak sadece sözdizimi.
İlk ölçümüm kaba oldu. Aynı sayfayı ikiye böldüm. Biri kökte "use client", diğeri sunucu sayfa artı 40 satırlık bir buton. İkinci varyantta first-party JS üçte birine düştü. LCP metin olduğu için boyama da öne geldi. Beğeni sayısı hidrasyondan sonra değil, HTML içinde doğdu. Kullanıcı butona basmadan önce sayfa zaten okunabilirdi.
Bunu 'sunucu daha hızlı' diye satmıyorum. Sunucu da yavaş olabilir. Kazanç, işin tarayıcıya taşınmaması. Prisma, markdown dönüştürücü, tarih formatı, yetki kontrolü — bunlar client bundle'da durursa her ziyaretçi onları indirir. Sunucuda dururlarsa bir kez çalışır, HTML gider. Ayrım performans sloganı değil, taşıma kararı.
Sınırı butona indirmek
Doğru ağaç şöyle duruyor: sayfa sunucu, gövde sunucu, etkileşim yaprak. Beğeni butonu client. Sayfa ürünü okur, butona yalnızca kimlik ve ilk sayı verir. Buton gerisini kendi state'inde tutar. Gövdeyi client yapmak, gövdenin hiçbir hook kullanmaması halinde bile tüm çocukları client'a çevirir.
import { notFound } from "next/navigation";
import { LikeButton } from "./like-button";
import { getProduct } from "@/lib/catalog";
export default async function ProductPage({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
const product = await getProduct(slug);
if (!product) notFound();
return (
{product.title}
{product.summary}
);
}
"use client";
import { useState, useTransition } from "react";
import { toggleLike } from "./actions";
export function LikeButton({
productId,
initialCount,
}: {
productId: string;
initialCount: number;
}) {
const [count, setCount] = useState(initialCount);
const [pending, startTransition] = useTransition();
return (
);
}
Bu iki dosya aynı problemi çözüyor: tıklanınca sayı değişiyor. Fark, getProduct çağrısının tarayıcıya hiç inmemesi. toggleLike bir server action; gizli kalır, fetch URL'ini ben uydurmam. Client dosyası React ve geçişten başka bir şey import etmiyor. Markdown parser, veritabanı, slug çözümü sayfada kalıyor.
Ne zaman sunucuda kalmalı
Kararı üç soruya indiriyorum. Bu dosya olay dinliyor mu? Tarayıcı nesnesine dokunuyor mu? Kullanıcının tıklamasından doğan kısa ömürlü state tutuyor mu? Üçü de hayırsa sunucu. Biri evetse o parça client, gerisi değil.
- Liste, detay, makale gövdesi, fiyat, stok özeti: sunucu.
- Formun statik etiketleri ve ilk değerleri: sunucu; gönderen düğme ve alan state'i: client veya form action.
- Tema anahtarı, sürükle-bırak, pencere genişliği: client.
- Tarih formatı kullanıcının saat dilimine göre değilse: sunucu. Dilimine göreyse formatı client'ta veya sunucuda locale ile bilinçli seç.
Üçüncü madde sık unutuluyor. 'Tarihi güzel göster' diye date-fns'i client sayfasına almak, 30 KB'lık bir formatörü herkese yüklemek. ISO string'i sunucuda format edip metin göndermek çoğu vitrinde yeter. Kullanıcı '2 dakika önce' istiyorsa o tek satırlık göreli zaman client olabilir; tüm makale değil.
Serileştirilemeyen props
Sunucudan client'a geçen her prop uçuşa yazılır. Fonksiyon, class instance, Map, Date'in bazı sarmalayıcıları, düz olmayan Prisma proxy'si — bunlar ya hata verir ya şişirir. Client'a id, sayı, kısa metin, düz nesne. Koca satırı vermek ayrı bir yazının konusu; burada kural şu: yaprak neyi kullanıyorsa onu ver.
Beğeni butonuna product nesnesinin tamamını geçirmek cazip. Tip hazır, yarın bir alan daha lazım olur. Uçuşta açıklama, HTML gövde, yazar biyografisi de gider. Buton üçünü de kullanmaz. productId ve initialCount yeterli. Yarın lazım olursa prop eklerim; bugün kiloyu taşımam.
async sayfa bir sihir değil
async Server Component, await ettiği sürece yanıtı tutar. Tutulan süre loading UI'ye veya Suspense'e düşmezse kullanıcı boş bakar. Bunu dördüncü yazıda ayırıyorum. Burada önemli olan: async diye her şeyi sayfada sıraya dizmek. Bağımsız iki sorguyu aynı fonksiyonda await await zincirlemek, suyu yokuşa akıtır. Promise.all veya ayrı Suspense sınırları, sunucu bileşenini 'yavaş' olmaktan çıkarır.
cookies() veya headers() okuyan bir sayfa statik üretilemez. Bunu bilmeden 'sunucu olduğu için cache'lenir' demek, sonra her istekte veritabanına inmek. Sunucu bileşeni olmak ile statik olmak aynı kapı değil. Birincisi nerede çalışacağını, ikincisi ne sıklıkla çalışacağını söyler.
Kullanmadığım yerler
Canvas, WebSocket, geolocation, clipboard — bunlar sunucuda yok. 'Önce sunucuda dene' demek burada zaman kaybı. Küçük bir client kabuğu açıp içine çocuk olarak sunucu içeriği vermek mümkün; children sunucuda kalır, kabuk client olur. Bu desen, modal ve sürüklemeli tahta için biçilmiş kaftan. Kabuğun kendisine veri çekmek değil, çocukları yerleştirmek düşer.
"use client";
import { useState, type ReactNode } from "react";
export function Drawer({ children }: { children: ReactNode }) {
const [open, setOpen] = useState(false);
return (
{open ? : null}
);
}
Filtre listesini children olarak sunucu sayfasından geçiriyorum. Liste verisi client'a JSON olup uçmaz; HTML olarak çekmeceye girer. Çekmece sadece açık/kapalı bilir. Bu, 'use client'ı üste yazıp listeyi de client'ta fetch etmekten farklı bir dünya.
Ölçmeden 'hepsi sunucu' da ezber
Her kartı sunucu yapmak, her kart için ayrı await demek olabilir. Yüz kartlık bir ızgarada yüz ayrı sorgu, client bundle'dan daha pahalı. Orada bir liste sorgusu, düz DTO, sonra gerekirse bir client süs. Sunucu bileşeni mikroservis değil. Sınır, dosya sayısı değil, veri ve etkileşim.
Bundle analyzer ve bir yavaş 3G kaydı, slayttan daha dürüst. 'Server Components kullandık' cümlesini kapatırken baktığım şey: sayfanın HTML'inde okunacak metin var mı, ilk JS'de Prisma var mı, client dosyası kaç satır. Üçü de yerindeyse sınır duruyor. Biri kaçtıysa butonu değil, dosyanın tepesini tekrar okuyorum.
Gizli kalması gerekenler
Sunucu bileşeninin ikinci kazancı paket değil, sır. process.env.DATABASE_URL bir client dosyasına import zinciriyle sızarsa, Next derlemede bağırır veya daha kötüsü sessizce public prefix bekler. Prisma client, imza anahtarı, iç servis URL'si — bunlar sayfada veya lib/server altında durur. Beğeni butonunun onları 'kolaylık olsun' diye import etmesi, sınırı tekrar yukarı çeker.
Bu yüzden server-only paketini iç servis dosyasının tepesine koyuyorum. Yanlış import derlemede kırılır. Kırılmayı ertelemek, production bundle'da connection string aramak demek. Bir kez gördüm: katalog helper'ı hem page hem de bir client süzgeç tarafından import edilmişti. Süzgeç yalnızca tag listesi istiyordu; helper ise veritabanını açıyordu. Helper'ı ikiye böldüm. Tag listesi düz dizi oldu, sorgu sunucuda kaldı.
Tüm sayfanın client olduğu nadir yer
Bir çizim tahtası, bir e-posta düzenleyici, bir sürüklemeli kanban — bunlar neredeyse baştan client. Orada da kabuğu sunucu bırakıyorum: başlık, yetki, ilk JSON. Tahta o JSON'u prop olarak alır. 'Hepsi client' diye page.tsx tepesine direktif yazmak, generateMetadata'yı kovar, ilk HTML'i boşaltır, yetkiyi tarayıcıya taşır. Editor.tsx client olur, page.tsx olmaz.
import { notFound } from "next/navigation";
import { Board } from "./board";
import { getBoard } from "@/lib/boards";
export default async function BoardPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
const board = await getBoard(id);
if (!board) notFound();
return (
{board.title}
({
id: c.id,
title: c.title,
cardIds: c.cardIds,
}))}
/>
);
}
Board client, ilk kolon listesi düz ve küçük. Kart gövdelerini tahta açılınca ayrıca istemek ayrı bir karar; ilk boyaya hepsini gömmüyorum. Bu sayfa 'hepsi client' gibi durur, değildir. Sunucu hâlâ yetkiyi ve ilk iskeleti yazar. Kullanıcı JS kapalıysa başlığı görür, tahtayı görmez. Bu, boş bir spinner'dan daha dürüst bir bozulma.
await sırası ve bağımsız iş
async sayfada iki await alt alta yazmak, ikinciyi birinci bitmeden başlatmaz. Ürün ve önerilenler bağımsızsa Promise.all. Önerilenler ürüne bağlıysa zincir doğru. Bunu düşünmeden 'sunucu olduğu için paralel' demek, kendi şelaleni sunucuya taşımak. Şelale tarayıcıda da, sunucuda da şelaledir. Fark, birincinin HTML'i geciktirmesi, ikincinin hidrasyonu geciktirmesi.
Bağımsız ama yavaş olanı Suspense'e almak, dördüncü yazının işi. Burada not: sunucu bileşeni kullanmak, tüm işi ilk boyadan önce bitirmek zorunda değilsin. Kullanmak, işi tarayıcıya taşımamak. Zamanlamayı sınırlarla konuşursun. Sınırsız bir async page, loading.tsx yoksa, kullanıcıya kapalı bir kapı gösterir.
Bu seride duracağım yer
Bir sonraki yazı, 'use client'ı üste yazınca ağacın nasıl boyandığını anlatıyor. Üçüncü yazı fetch cache. Dördüncü yazı örtü. Bu yazının cümlesi kısa kalsın diye değil, ölçülebilir kalsın diye duruyor: etkileşim yaprak, veri kök, gizli iş sunucu. Buton yüzünden makaleyi client yapmak, kök ile yaprağı aynı dosyada eritmek. Dosyayı ayırınca sayfa yine beğenilir. Sadece önce okunur.
İlk HTML'de ne durmalı
Okuyucunun ilk 3 saniyede ihtiyaç duyduğu şey başlık, fiyat, stok cümlesi, gövde. Beğeni sayısı orada durabilir; beğeni animasyonu durmayabilir. 'İlk 3 saniye'yi ben uydurmuyorum, LCP ve benim kendi sabırsızlığım. Sunucu bileşeni, o 3 saniyeyi tarayıcıya borçlu kalmadan yazmanın yolu. Client bileşeni, 3 saniyeden sonraki tıklamayı yazmanın yolu. İkisini ayıramayınca 3 saniyeyi JS kuyruğuna satıyorsun.
Bunu her sayfada tekrar etmek sıkıcı. Sıkıcı olması iyi. Sınır bir kez çizilince sonraki kartlar kopyalanır. Kötü olan, her yeni etkileşimde kökü tekrar client yapmak. Yeni bir paylaş butonu geldiğinde yeni bir yaprak açılır, sayfa dosyasına dokunulmaz. Bu alışkanlık, birinci yazının bütün iddiası. Gerisi örnek ve ölçü.
Yorumlar
Yorumlar (0)
Yorumlar üyelere açık. Üye ol · Giriş yap