Render Mode'lar Derinlemesine: Static SSR, InteractiveServer, InteractiveWebAssembly, InteractiveAuto ve Prerendering
Render mode component tanımında, kullanıldığı yerde ya da tüm uygulama için verilir, alt component'lere yayılır ve interaktif bir component'in içinde başka bir interaktif moda geçilemez; Static parent'tan giden parametreler JSON'a serileştirilip HTML'e yazılır. Bookstore paneline WebAssembly'de çalışan bir teklif hesaplayıcısı ekleyip ChildContent hatasını, sessizce kaybolan EventCallback'i, prerender'ı kapatmayı ve .Client'a konan her şeyin tarayıcıda göründüğünü gerçek çıktılarla gösteriyoruz.
Sürüm notu: Bu yazıdaki kodlar .NET 10 (SDK 10.0.404, ASP.NET Core 10.0.15) ile derlenip denenmiştir; tarayıcı davranışı Playwright 1.65'in headless Chromium'uyla gözlenmiştir.
Önce Tahmin Et
Bu yazıda yönetim paneline, tamamen tarayıcıda çalışan bir teklif hesaplayıcısı ekliyoruz. Sayfa Static SSR (sunucuda bir kez üretilen düz HTML); kitap fiyatlarını sunucuda API'den alıp WebAssembly ile çalışan bir component'e parametre olarak veriyor. Üç soru. Sayfanın HTML kaynağını açtığında 20 kitabın fiyat listesini görebilir misin? Aynı component'e <p>Prices include VAT.</p> gibi bir child content verirsen ne olur? Component'e @inject HttpClient Http eklersen hata nerede çıkar: build'de mi, sunucudaki prerender'da mı, tarayıcıda mı?
Render Mode'u Nereye Yazarsın?
Üç yer var. Birincisi component'in tanımı: 03'teki stok sayfası @rendermode InteractiveServer satırını kendi dosyasında taşıyor ve nerede kullanılırsa kullanılsın Interactive Server olarak çalışıyor. Bir sayfa için bu en okunaklı yol: sayfayı açan herkes modunu ilk satırda görüyor. İkincisi kullanıldığı yer: component mode'dan habersiz yazılır, onu kullanan parent mode'u verir. Bu yazının teklif sayfası böyle:
@page "/orders/preview"
@inject BookstoreApiClient Api
<PageTitle>Quote preview</PageTitle>
<h1>Quote preview</h1>
<p>Prices come from the catalog; the calculation runs in the browser.</p>
<CartPreview Books="prices" @rendermode="InteractiveWebAssembly" />
@code {
private BookPrice[] prices = [];
protected override async Task OnInitializedAsync()
{
PagedResult<BookSummary> page = await Api.GetBooksAsync(page: 1, pageSize: 50);
prices = [.. page.Items.Select(b => new BookPrice(b.Isbn, b.Title, b.Price))];
}
}
Üçüncüsü bütün uygulama. 02'deki --all-interactive template'i render mode'u App.razor'da en tepeye koyuyordu:
<HeadOutlet @rendermode="InteractiveServer" />
<Routes @rendermode="InteractiveServer" />
Bu durumda routing de interaktif olur: sayfa geçişleri yeni HTML isteği yapmadan, açık circuit ya da tarayıcıdaki runtime üzerinden gerçekleşir. Global interaktif bir uygulamada bir sayfanın Static SSR kalması gerekiyorsa (örneğin HTTP cookie'sine yazan bir giriş sayfası) o sayfaya @attribute [ExcludeFromInteractiveRouting] eklenir. Bookstore.Admin'de global mode kullanmıyoruz; listeleme sayfaları Static SSR, interaktivite sayfa ya da component bazında.
Teklif hesaplayıcısının kendisi .Client projesinde, çünkü WebAssembly ile çalışacak:
<div class="card p-3">
<p class="text-muted mb-2">Calculated on: <code>@RendererInfo.Name</code></p>
<div class="d-flex gap-2 mb-3">
<select class="form-select w-auto" @bind="selectedIsbn">
@foreach (BookPrice book in Books)
{
<option value="@book.Isbn">@book.Title (@book.Price.ToString("N2") TRY)</option>
}
</select>
<input type="number" min="1" max="500" class="form-control w-auto" @bind="quantity" />
<button class="btn btn-primary" disabled="@(!RendererInfo.IsInteractive)" @onclick="Add">Add</button>
</div>
<table class="table table-sm">
<tbody>
@foreach (CartLine line in lines)
{
<tr><td>@line.Quantity x @line.Title</td><td class="text-end">@line.Total.ToString("N2") TRY</td></tr>
}
</tbody>
</table>
<p>
@TotalQuantity items, subtotal @Subtotal.ToString("N2") TRY,
discount @Discount.ToString("N2") TRY, total <strong>@((Subtotal - Discount).ToString("N2")) TRY</strong>
</p>
</div>
@code {
// Kurumsal sipariş: en az 20 adette %10 indirim (Bookstore'un B2B kuralı).
private const int InstitutionalMinimum = 20;
private const decimal InstitutionalDiscountRate = 0.10m;
private readonly List<CartLine> lines = [];
private string selectedIsbn = "";
private int quantity = 1;
[Parameter, EditorRequired]
public IReadOnlyList<BookPrice> Books { get; set; } = [];
private int TotalQuantity => lines.Sum(l => l.Quantity);
private decimal Subtotal => lines.Sum(l => l.Total);
private decimal Discount =>
TotalQuantity >= InstitutionalMinimum ? Math.Round(Subtotal * InstitutionalDiscountRate, 2) : 0m;
protected override void OnParametersSet()
{
if (selectedIsbn.Length == 0 && Books.Count > 0)
{
selectedIsbn = Books[0].Isbn;
}
}
private void Add()
{
BookPrice book = Books.Single(b => b.Isbn == selectedIsbn);
int index = lines.FindIndex(l => l.Isbn == book.Isbn);
if (index >= 0)
{
lines[index] = lines[index] with { Quantity = lines[index].Quantity + quantity };
}
else
{
lines.Add(new CartLine(book.Isbn, book.Title, book.Price, quantity));
}
}
private sealed record CartLine(string Isbn, string Title, decimal Price, int Quantity)
{
public decimal Total => Price * Quantity;
}
}
Hesap Bookstore'un kurumsal sipariş kuralını uyguluyor: toplam 20 adet ve üstünde %10 indirim. Kurumsal satıştan Hakan bir okulla telefonda konuşurken adetleri değiştirip toplamı anında görüyor; hiçbir tıklama sunucuya gitmiyor. Playwright ile 12 Dune ve 8 The Hobbit eklediğimde:
right after load: Calculated on: Static, Add disabled: true
after start-up: Calculated on: WebAssembly, Add disabled: false
12 x Dune 5,868.00 TRY
8 x The Hobbit 2,639.20 TRY
20 items, subtotal 8,507.20 TRY, discount 850.72 TRY, total 7,656.48 TRY
websockets: none
Sayfada hiç WebSocket açılmadı: Interactive Server component'i olmayan bir sayfada circuit kurulmuyor. İlk satıra da dikkat: sayfa yüklendiği anda component Static diyor ve "Add" butonu devre dışı. Bu, prerender edilmiş HTML; WebAssembly runtime'ı inip component canlanana kadar butona basmak hiçbir şey yapmazdı. disabled="@(!RendererInfo.IsInteractive)" bu arayı kullanıcıya dürüstçe gösteriyor.
Render Mode Nasıl Yayılır?
Render mode alt component'lere geçer. Stok sayfası Interactive Server olduğu için içindeki BookList, BookRow ve LowStockBadge da Server'da çalışıyor; hiçbirinde @rendermode yok. Kardeş component'ler farklı modlarda olabilir; 01'deki sayfada dört mod yan yana duruyordu. Ama interaktif bir component'in içinde başka bir interaktif moda geçemezsin. Denemek için CartPreview'un içine 01'deki RenderModeProbe'u @rendermode="InteractiveServer" ile koydum. Build geçti, sunucudaki prerender da geçti; hata tarayıcıda, WebAssembly renderer'ında çıktı:
crit: Microsoft.AspNetCore.Components.WebAssembly.Rendering.WebAssemblyRenderer[100]
Unhandled exception rendering component: Cannot create a component of type 'Bookstore.Admin.Client.Diagnostics.RenderModeProbe' because its render mode 'Microsoft.AspNetCore.Components.Web.InteractiveServerRenderMode' is not supported by WebAssembly rendering.
Component bu yüzden hiç canlanmadı ve sayfa Static hâlinde, butonu devre dışı kaldı. Kuralın sebebi basit: WebAssembly'de çalışan bir component'in sunucudaki circuit'le bir bağı yok, içindeki bir parçayı oraya taşıyamaz. Tersi de geçerli: Server component'in içine WebAssembly component koyamazsın.
Parametreler Serileştirilebilir Olmalı
Static bir parent'tan interaktif bir child'a giden parametreler JSON'a serileştirilip HTML'e yazılır; component tarayıcıda (ya da circuit kurulunca sunucuda) bu JSON'dan yeniden oluşturulur. Delegate'ler serileştirilemez. Child content da bir RenderFragment, yani bir delegate. CartPreview'a ChildContent parametresi ekleyip teklif sayfasından bir paragraf verdiğimde istek 500 ile düştü:
System.InvalidOperationException: Cannot pass the parameter 'ChildContent' to component 'CartPreview' with rendermode 'InteractiveWebAssemblyRenderMode'. This is because the parameter is of the delegate type 'Microsoft.AspNetCore.Components.RenderFragment', which is arbitrary code and cannot be serialized.
EventCallback'te ise hata bile çıkmıyor. CartPreview'a bir EventCallback<decimal> OnTotalChanged parametresi ekleyip teklif sayfasındaki bir handler'a bağladım ve "Add"de çağırdım. Sayfa açıldı, hesap çalıştı, sunucudaki handler hiç çağrılmadı; tarayıcı konsoluna yazdırdığım değer:
browser console: OnTotalChanged.HasDelegate: False
Callback serileştirme sırasında sessizce boş bir değere dönüştü. Static bir sayfadan interaktif bir component'e geri bildirim kanalı açamazsın; component'in sonucu sunucuya iletmesi gerekiyorsa bunu bir HTTP çağrısıyla ya da sayfanın tamamını aynı interaktif moda alarak yaparsın. Daha sinsi bir hatayla ilk denemede karşılaştım. Teklif sayfasındaki field ilk hâlinde IReadOnlyList<BookPrice> tipindeydi ve [.. page.Items.Select(...)] collection expression'ıyla dolduruluyordu. Build geçti, prerender geçti, ama tarayıcı konsolunda şu çıktı:
ManagedError: AggregateException_ctor_DefaultMessage (The parameter 'Books' with type '<>z__ReadOnlyList`1[[Bookstore.Admin.Client.Contracts.BookPrice, Bookstore.Admin.Client, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null]]' in assembly 'Bookstore.Admin' could not be found.)
C# derleyicisi IReadOnlyList<T> hedefli bir collection expression için kendi ürettiği, adı <>z__ReadOnlyList olan bir tip kullanıyor ve bu tip Bookstore.Admin assembly'sinde duruyor. Blazor parametreyi serileştirirken çalışma anındaki tipin adını da yazıyor; tarayıcıda o assembly yok, tip bulunamıyor. Field'ı BookPrice[] yapınca sorun kayboldu. Kural: interaktif bir child'a sunucudan giden parametrelerin çalışma anı tipleri de iki tarafta bilinen tipler olmalı (array, List<T>, .Client'taki record'lar).
Prerendering'i Açıp Kapatmak
Interaktif modların hepsinde prerendering varsayılan olarak açık. Avantajı yukarıda gördüğümüz gibi kullanıcının içeriği hemen görmesi; bedeli de 03'te gördüğümüz çift çalışma ve "görünüyor ama henüz çalışmıyor" arası. Kapatmak için render mode'un instance'ını parametreyle oluşturursun. Teklif sayfasında @rendermode="new InteractiveWebAssemblyRenderMode(prerender: false)" yazdığımda curl yanıtında component'in yerinde yalnız işaretler kaldı (satır kısaltıldı):
<h1>Quote preview</h1>
<p>Prices come from the catalog; the calculation runs in the browser.</p>
<!--Blazor-WebAssembly:{"environmentName":"Development","environmentVariables":{}}--><!--Blazor:{"type":"webassembly","key":{"locationHash":"D8A099B26F34E03610CAFDCA07D8EF9C11F820BA5431849D1B09986B0D6D58B2:6","formattedComponentKey":""},"assembly":"Bookstore.Admin.Client","typeName":"Bookstore.Admin.Client.Ordering.CartPreview","parameterDefinitions":"W3sibmFtZSI6...","parameterValues":"W1t7ImlzYm4i..."}-->
Kullanıcı runtime inene kadar boş bir alan görür. Arama motorunun görmesi gerekmeyen, prerender'da çalışamayacak (örneğin tarayıcı API'sine ilk render'da ihtiyaç duyan) ya da prerender sonrası ikinci yüklemenin pahalı olduğu component'lerde mantıklı. Uygulamada sık kullanıyorsan _Imports.razor'a ya da bir static class'a InteractiveWebAssemblyRenderMode(prerender: false) değerini taşıyan kısa bir property yazıp her yerde onu kullanabilirsin.
Prerender sırasında da component kodu çalışıyor, ama sunucuda. Bu yüzden .Client'taki bir component iki ortamın ikisinde de çalışabilmeli. Ortamı çalışma anında RendererInfo.Name (Static, Server, WebAssembly, WebView), RendererInfo.IsInteractive ve AssignedRenderMode ile öğreniyorsun; üçü de .NET 9'da geldi. Prerender'da IsInteractive false, AssignedRenderMode ise component'e verilen modu gösteriyor.
Hangi Kod Nerede Çalışır?

Static SSR ve Interactive Server component'leri sunucuda çalışır ve sunucudaki her şeye erişebilir: DI (Dependency Injection; bağımlılıkların class'a dışarıdan verilmesi) container'ındaki service'ler, veritabanı, configuration'daki secret'lar. BookstoreApiClient bu yüzden sunucu projesinde ve stok sayfası onu doğrudan kullanıyor. İlerideki bir yazıda aynı sayfa BookstoreContext'e de doğrudan gidebilecek. WebAssembly component'i ise kullanıcının tarayıcısında çalışır; sunucuya ancak herkese açık bir HTTP endpoint'i üzerinden ulaşabilir ve sunucunun DI container'ını göremez. Bunu derleyici kısmen koruyor: .Client projesi sunucu projesine referans veremediği için CartPreview'da BookstoreApiClient'ı yazamazsın. Ama her şeyi korumuyor. CartPreview'a @inject HttpClient Http ekleyip denedim: build geçti, sunucudaki prerender da geçti, çünkü sunucuda AddHttpClient kaydı container'a varsayılan bir HttpClient da ekliyor. Tarayıcıda ise:
ManagedError: AggregateException_ctor_DefaultMessage (Cannot provide a value for property 'Http' on type 'Bookstore.Admin.Client.Ordering.CartPreview'. There is no registered service of type 'System.Net.Http.HttpClient'.)
Baştaki üçüncü sorunun cevabı: tarayıcıda. .Client'ın Program.cs'i ayrı bir DI container kuruyor ve şu an içinde neredeyse hiçbir şey yok. Bir WebAssembly component'inin ihtiyaç duyduğu her service iki container'a da kaydedilmeli ya da component'e parametreyle gelmeli. Server ve WebAssembly için aynı interface'in iki implementasyonu 09. yazının konusu.
Bir de görünürlük var. Baştaki ilk sorunun cevabı evet: teklif sayfasının HTML'indeki WebAssembly işaretinin parameterValues alanı base64 ile kodlanmış JSON. Çözünce:
[[{"isbn":"9780451524935","title":"1984","price":279.0},{"isbn":"9780143035008","title":"Anna Karenina","price":429.0},{"isbn":"9780060850524","title":"Brave Ne
Fiyat listesi zaten herkese açık bir bilgi, sorun yok. Ama aynı yoldan bir kullanıcının adresi, maliyet fiyatı ya da bir token geçerse, sayfa kaynağına bakan herkes görür. Interactive Server component'inin işaretinde ise parametreler Data Protection ile şifrelenmiş bir descriptor içinde gidiyor (01'deki curl çıktısında görmüştük). Aynı kural .Client'ın wwwroot/appsettings.json dosyası için geçerli: bu dosya tarayıcının runtime'ı başlatırken indirdiği public bir dosya. İçine sahte bir API key yazıp istediğimde:
HTTP/1.1 200 OK
Content-Type: application/json
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"BookstoreApi": {
"ApiKey": "fake-api-key-for-tests"
}
}
Kimlik doğrulaması gerektirmeyen, herkesin indirebildiği bir JSON. .Client projesine konan derlenmiş kod için de durum aynı: assembly'ler _framework/ altında iner ve decompile edilebilir. Tarayıcıya giden hiçbir yere secret konmaz; WebAssembly tarafının API'ye nasıl yetkiyle erişeceği 11. yazıda, BFF (Backend for Frontend; tarayıcının yalnız kendi sunucusuyla konuştuğu, token'ları sunucuda tutan katman) yaklaşımıyla.
Üretimde Dikkat
Bir sayfada farklı modlar karışınca maliyetler toplanır. Interactive Server ve WebAssembly component'lerini aynı sayfaya koyarsan kullanıcı hem bir circuit açar hem de WebAssembly runtime'ını indirir; 01'deki Auto davranışını hatırla, Auto da bu yüzden mevcut runtime'ı tercih ediyordu. Panelde bir sayfayı tek bir interaktif moda bağlı tutmak hem kullanıcıya hem sunucuya daha ucuz.
Prerender sırasında WebAssembly component'inin kodu sunucuda çalışıyor; yani o kodun ihtiyaç duyduğu service'ler sunucuda da kayıtlı olmalı ve sunucunun kaynaklarını kullanıyor. Prerender'ı açık bıraktığın her WebAssembly component'i için sunucunun da ne yaptığını bil. Son olarak, parametre olarak gönderdiğin verinin boyutu sayfanın boyutuna eklenir. 20 kitaplık fiyat listesi birkaç KB; 5.000 kitaplık bir katalog HTML'i şişirir. Büyük veri için component'in kendisinin API'den çekmesi daha doğru.
Sık Düşülen Tuzaklar
Component'i yanlış projeye koymak. WebAssembly ya da Auto ile çalışacak bir component sunucu projesinde durursa @rendermode InteractiveWebAssembly yazsan bile tarayıcıya inemez; sayfa çalışma anında hata verir. Kural: tarayıcıda çalışabilecek her component .Client'ta.
Static SSR sayfadaki bir alt component'e @rendermode verip sayfanın geri kalanının da interaktif olduğunu sanmak. Yalnız o component ve altındakiler interaktif; sayfanın kendi @onclick'leri hâlâ çalışmaz.
Interaktif child'a büyük ya da hassas nesneleri parametre olarak vermek. Parametre sayfanın HTML'ine yazılır ve WebAssembly'de açık metin olarak durur; yukarıdaki base64 kodlaması bir şifreleme değil.
Prerender'ı kapatıp sorunu çözdüğünü sanmak. prerender: false çift yüklemeyi ve prerender'daki service eksikliğini gizler ama kullanıcıya boş bir alan ve arama motorlarına içeriksiz bir sayfa bırakır. Çift yüklemenin asıl çözümü 06'daki kalıcı state.
Küçük Bir Deneme
★☆☆ QuotePreviewPage'de @rendermode="InteractiveWebAssembly"'i InteractiveServer yap. Hesaplayıcı hâlâ çalışıyor mu? Geliştirici araçlarında WebSocket mesajlarına bak: "Add" tıklaması kaç mesaj üretiyor?
★★☆ .Client'ta QuoteWorkspace adında bir component yaz: içinde CartPreview'u kullansın ve OnTotalChanged callback'ini kendi bir field'ına bağlasın. Teklif sayfasında CartPreview yerine <QuoteWorkspace @rendermode="InteractiveWebAssembly" /> kullan. HasDelegate şimdi ne yazıyor, neden?
★★★ .Client'ın Program.cs'ine builder.Services.AddScoped(sp => new HttpClient { BaseAddress = new Uri(builder.HostEnvironment.BaseAddress) }) ekle ve CartPreview'un fiyatları parametre yerine sunucudaki bir minimal API endpoint'inden (/admin-api/prices) çekmesini sağla. Prerender sırasında aynı kod sunucuda çalışırken HttpClient'ın BaseAddress'i ne oluyor ve bunu nasıl çözersin?
Teklif sayfasının kendisi Static SSR'dı ve bunun için hiçbir şey yapmadık. Bir sonraki yazıda tamamen interaktivitesiz Blazor'a bakıyoruz: kitap arama ve sayfalamayı Static SSR ile yazarken streaming rendering'i, enhanced navigation'ı, form post'larını ve .NET 10'daki NavigationManager.NotFound()'u göreceğiz.