Blazor Nedir? Blazor Web App, Render Mode'lar ve Angular/MVC Karşısında Ne Zaman Seçilir
Blazor, interaktif web arayüzünü C# ve Razor component'leriyle yazmanı sağlar ve .NET 8'den beri her component'in nerede çalışacağını render mode belirler: Static SSR, Interactive Server, Interactive WebAssembly ya da Auto. Aynı component'i dört modda yan yana çalıştırıp prerender çıktısını, SignalR circuit'ini, ilk ziyarette inen 2,2 MB'ı ve Bookstore yönetim paneli için Blazor ile Angular arasındaki kararı gerçek ölçümlerle 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ı gözlemleri Playwright 1.65'in headless Chromium'uyla, Angular karşılaştırması Angular 22.2 ile yapılmıştır.
Önce Tahmin Et
Bir sayfaya aynı Razor component'ini dört kez koyuyorsun. Kod birebir aynı: bir başlık, component'in nerede çalıştığını yazan bir satır ve tıklandıkça artan bir sayaç. Tek fark, dört kopyanın her birine farklı bir render mode vermen. Şimdi iki soru. Birincisi: sayfayı terminalden curl ile istediğinde, yani JavaScript çalıştırmayan bir istemciyle, dört component'in "nerede çalışıyorum" satırında ne yazar? İkincisi: aynı sayfayı tarayıcıda açıp dört butona birer kez tıkladığında hangi sayaçlar 1 olur, hangisi 0 kalır? Cevabını bir kenara yaz; birazdan gerçek çıktıyla karşılaştıracağız.
Blazor Nedir, Nereden Geldi?
Blazor, tarayıcıda çalışan interaktif arayüzü JavaScript yerine C# ve Razor ile yazmanı sağlayan ASP.NET Core'un UI framework'ü. Yapı taşı Razor component: .razor uzantılı, HTML ve C#'ı aynı dosyada taşıyan, parametre alan, event'lere tepki veren ve kendini yeniden render edebilen bir class. Angular'daki component'in karşılığı düşünülebilir; farkı, template dilinin Razor, mantığın C# olması ve aynı component'in sunucuda da tarayıcıda da çalışabilmesi.
Blazor iki ayrı hosting model olarak doğdu. Blazor Server 2019'da .NET Core 3.0 ile geldi: component'ler sunucuda çalışır, tarayıcı yalnız ince bir JavaScript katmanı taşır, kullanıcının tıklaması bir SignalR bağlantısı üzerinden sunucuya gider ve sunucu DOM (Document Object Model; tarayıcının sayfayı tuttuğu ağaç yapısı) için hesapladığı farkı geri yollar. Bu kalıcı bağlantıya ve sunucuda tutulan kullanıcı state'ine circuit denir. 2020'de Blazor WebAssembly geldi: .NET runtime'ı WebAssembly'ye derlenmiş hâliyle tarayıcıya iner, uygulamanın assembly'leri de onunla birlikte gelir ve component'ler tamamen kullanıcının makinesinde çalışır. Sunucu yalnızca dosya verir; bir SPA (Single Page Application; sayfa yenilemeden çalışan tek sayfalık web uygulaması) gibi.
Uzun süre bu iki model ayrı template, ayrı proje yapısı ve ayrı karar demekti: ya hepsi sunucuda ya hepsi tarayıcıda. .NET 8 bunu değiştirdi. Tek bir Blazor Web App template'i geldi ve render mode kavramı "uygulama nasıl host ediliyor" sorusunu "bu component nerede çalışıyor" sorusuna çevirdi. Artık aynı uygulamada bir sayfa sunucuda düz HTML olarak üretilebilir, bir başka sayfadaki tek bir grid SignalR üzerinden interaktif olabilir, bir hesaplama paneli de tarayıcıda WebAssembly ile çalışabilir. Bu serinin büyük kısmı bu esnekliği doğru kullanmak üzerine.
Sitedeki serilerin ortak örnek uygulaması Bookstore (bir online kitapçı) bu seride bir yönetim paneli kazanacak: Bookstore.Admin. Kitap ve yazar CRUD (Create, Read, Update, Delete; kayıt ekleme, okuma, güncelleme ve silme) ekranları, sipariş listesi ve stok uyarıları, hepsi Blazor ile ve mevcut Bookstore API'si üzerinde. Müşterinin gördüğü vitrin Angular'da kalıyor; yönetim paneli ise Blazor'un en güçlü olduğu senaryo: iç kullanıcılar, form ağırlıklı ekranlar, C# bilen bir ekip.
Projeyi Oluşturmak
Template seçeneklerini ve oluşan dosyaları bir sonraki yazıda tek tek açacağız. Şimdilik dört render mode'u da kullanabilen bir proje yeterli. Bookstore reposunun kökünde:
dotnet new blazor -int Auto -n Bookstore.Admin -o src
Bu komut iki proje üretir: sunucu tarafı src/Bookstore.Admin ve tarayıcıya inecek kodu taşıyan src/Bookstore.Admin.Client. Bookstore central package management kullandığı için template'in csproj'lara yazdığı Version attribute'larını silip sürümleri Directory.Packages.props'a taşıdım; restore aksi hâlde hata veriyor, nedenini 02'de göreceğiz:
<ItemGroup>
<PackageVersion Include="Microsoft.AspNetCore.OpenApi" Version="10.0.15" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.SqlServer" Version="10.0.15" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.15" />
<PackageVersion Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.15" />
<PackageVersion Include="Microsoft.AspNetCore.Components.WebAssembly" Version="10.0.15" />
<PackageVersion Include="Microsoft.AspNetCore.Components.WebAssembly.Server" Version="10.0.15" />
<PackageVersion Include="xunit.v3" Version="4.0.3" />
</ItemGroup>
Asıl deney için .Client projesine küçük bir component ekliyorum. Component kendi RendererInfo ve AssignedRenderMode property'lerini ekrana yazıyor; bu iki property .NET 9'dan beri her component'te var:
<div class="probe">
<h3>@Label</h3>
<p>Renderer: <code>@RendererInfo.Name</code>, interactive: <code>@RendererInfo.IsInteractive</code></p>
<p>Assigned mode: <code>@(AssignedRenderMode?.GetType().Name ?? "none")</code></p>
<button @onclick="() => clicks++">Clicked @clicks times</button>
</div>
@code {
private int clicks;
[Parameter, EditorRequired]
public string Label { get; set; } = "";
}
Component'in .Client projesinde olması şart: WebAssembly ya da Auto ile çalışacak her component'in tarayıcıya inebilmesi için o projede derlenmesi gerekiyor. Sunucu projesi .Client'a referans verdiği için aynı component'i sunucuda da kullanabiliyor. Şimdi sunucu projesine dört kopyayı yan yana koyan bir sayfa:
@page "/diagnostics/render-modes"
@using Bookstore.Admin.Client.Diagnostics
<PageTitle>Render modes</PageTitle>
<h1>One component, four render modes</h1>
<RenderModeProbe Label="Static SSR" />
<RenderModeProbe Label="Interactive Server" @rendermode="InteractiveServer" />
<RenderModeProbe Label="Interactive WebAssembly" @rendermode="InteractiveWebAssembly" />
<RenderModeProbe Label="Interactive Auto" @rendermode="InteractiveAuto" />
İlk kopyada @rendermode yok. Sayfanın kendisinin de render mode'u olmadığı için bu kopya Static SSR (Static Server-Side Rendering; component'in sunucuda bir kez render edilip düz HTML olarak gönderilmesi) ile çalışıyor. Diğer üçü @rendermode directive'iyle kendi modunu seçiyor.
Dört Render Mode, Tek Sayfa
Önce JavaScript'siz istemcinin gördüğü. curl yanıtında her component'in başlığı ve "Renderer" satırı:
<h3>Static SSR</h3>
<p>Renderer: <code>Static</code>, interactive: <code>False</code></p>
<h3>Interactive Server</h3>
<p>Renderer: <code>Static</code>, interactive: <code>False</code></p>
<h3>Interactive WebAssembly</h3>
<p>Renderer: <code>Static</code>, interactive: <code>False</code></p>
<h3>Interactive Auto</h3>
<p>Renderer: <code>Static</code>, interactive: <code>False</code></p>
Dördü de Static ve False yazıyor. Tahminin "ikinci satırda Server, üçüncüde WebAssembly" idiyse, prerendering'i hesaba katmamışsın. Interaktif render mode'lar varsayılan olarak önce sunucuda bir kez statik olarak render edilir ve bu HTML ilk yanıtın içinde gelir; kullanıcı boş bir sayfa yerine içeriği hemen görür. Interaktif olma işi sonra, tarayıcıda blazor.web.js çalışınca başlar. Yanıtın içinde her interaktif kopyanın yanında <!--Blazor:{"type":"server",...}-->, "type":"webassembly" ve "type":"auto" işaretli birer HTML yorumu var; script bu işaretlerden hangi component'i nerede canlandıracağını öğreniyor.
Şimdi aynı sayfayı Playwright ile headless Chromium'da açıp, birkaç saniye bekleyip her butona bir kez tıklayan küçük bir script'in çıktısı:
--- first visit
Static SSR | Renderer: Static, interactive: False | Assigned mode: none | Clicked 0 times
Interactive Server | Renderer: Server, interactive: True | Assigned mode: InteractiveServerRenderMode | Clicked 0 times
Interactive WebAssembly | Renderer: WebAssembly, interactive: True | Assigned mode: InteractiveWebAssemblyRenderMode | Clicked 0 times
Interactive Auto | Renderer: WebAssembly, interactive: True | Assigned mode: InteractiveWebAssemblyRenderMode | Clicked 0 times
after one click on each: Clicked 0 times / Clicked 1 times / Clicked 1 times / Clicked 1 times
websockets: /_blazor | _framework files: 202
--- second visit (same browser, cached)
Static SSR | Renderer: Static, interactive: False | Assigned mode: none | Clicked 0 times
Interactive Server | Renderer: Server, interactive: True | Assigned mode: InteractiveServerRenderMode | Clicked 0 times
Interactive WebAssembly | Renderer: WebAssembly, interactive: True | Assigned mode: InteractiveWebAssemblyRenderMode | Clicked 0 times
Interactive Auto | Renderer: WebAssembly, interactive: True | Assigned mode: InteractiveWebAssemblyRenderMode | Clicked 0 times
websockets: /_blazor
İkinci sorunun cevabı burada. Static SSR kopyasının sayacı 0'da kaldı: sunucu o HTML'i üretip işini bitirdi, butona bağlı bir event handler tarayıcıda hiç yok. Interactive Server kopyası artık Server diyor; tıklama /_blazor adresindeki WebSocket bağlantısından sunucuya gitti, sayaç sunucunun belleğinde arttı ve değişen HTML parçası geri geldi. Interactive WebAssembly kopyası için tarayıcı 202 dosya indirdi (bu Debug build; Release rakamına aşağıda bakacağız) ve tıklama hiç ağa çıkmadan tarayıcıda işlendi.
Auto kopyası ise ilginç: ilk ziyarette bile WebAssembly seçti. Sebebi aynı sayfada zaten bir WebAssembly component'i olması. Microsoft'un render mode dokümanına göre Auto, karar verirken sayfada çalışan interaktif component'lere bakar ve mümkünse onlarla aynı runtime'ı seçer; böylece birbirinden habersiz iki ayrı .NET runtime'ı aynı sayfada açılmaz. Bir başka ayrıntı: Auto kopyasının AssignedRenderMode değeri prerender sırasında InteractiveAutoRenderMode idi, interaktif olunca seçilen gerçek moda döndü.

Auto: Önce Sunucu, Sonra Tarayıcı
Auto'nun asıl davranışını görmek için onu tek başına bir sayfaya koydum (/diagnostics/auto, içinde yalnız <RenderModeProbe Label="Interactive Auto" @rendermode="InteractiveAuto" /> var) ve aynı tarayıcıda iki kez açtım:
--- first visit
Interactive Auto | Renderer: Server, interactive: True | Assigned mode: InteractiveServerRenderMode | Clicked 0 times
after one click on each: Clicked 1 times
websockets: /_blazor | _framework files: 202
--- second visit (same browser, cached)
Interactive Auto | Renderer: WebAssembly, interactive: True | Assigned mode: InteractiveWebAssemblyRenderMode | Clicked 0 times
websockets: none
İlk ziyarette WebAssembly dosyaları henüz tarayıcıda olmadığı için Auto, Interactive Server'ı seçti: circuit açıldı, component hemen interaktif oldu ve WebAssembly dosyaları arka planda indi. İkinci ziyarette dosyalar cache'te olduğu için aynı component tarayıcıda çalıştı ve hiç WebSocket açılmadı. Auto'nun vaadi bu: ilk ziyarette Server'ın hızlı açılışı, sonraki ziyaretlerde sunucuya yük bindirmeyen WebAssembly. Bedeli de var: component iki ortamda da çalışabilecek şekilde yazılmak zorunda. Sunucuda veritabanına doğrudan gidebilen kod tarayıcıda gidemez; bu ayrımı 04 ve 09. yazılarda çözeceğiz.
Bedel: İndirme Boyutu ve Açık Bağlantı
Her interaktif mod bir bedelle geliyor ve karar bu bedellerin hangisine katlanabileceğine göre veriliyor. WebAssembly tarafında bedel indirme boyutu. Projeyi dotnet publish -c Release ile yayınlayıp aynı Auto sayfasını boş bir tarayıcıyla açtım ve ağdan geçen gerçek byte'ları topladım:
requests: 47, total: 2220 KB, _framework: 38 files, 2182 KB
954 KB br /_framework/dotnet.native.wasm
473 KB br /_framework/System.Private.CoreLib.wasm
141 KB br /_framework/icudt_EFIGS.dat
122 KB br /_framework/System.Text.Json.wasm
61 KB br /_framework/Microsoft.AspNetCore.Components.wasm
Release build'de trimming (kullanılmayan kodun build sırasında atılması) ve Brotli sıkıştırması devrede, yine de ilk ziyarette yaklaşık 2,2 MB iniyor ve bunun neredeyse yarısı .NET runtime'ının kendisi (dotnet.native.wasm). Karşılaştırma için Bookstore'un Angular istemcisinin production build çıktısı:
Initial chunk files | Names | Raw size | Estimated transfer size
main-BDBHRKZT.js | main | 264.83 kB | 73.11 kB
styles-S5NLN6H7.css | styles | 930 bytes | 930 bytes
| Initial total | 265.76 kB | 74.05 kB
Angular'ın başlangıç paketi tahmini 74 KB. Aynı sayfanın Static SSR ile üretilmiş hâli ise yaklaşık 12 KB HTML; hiç script indirmeden okunabiliyor. Dosyalar fingerprint'li ve cache'lenebilir olduğu için 2,2 MB'ı bir kullanıcı genelde bir kez öder; ama vitrine ilk kez gelen, mobil veriyle bağlanan bir müşteri için bu fark hissedilir.
Interactive Server'ın bedeli başka yerde. Her tıklama sunucuya gidip gelir; kullanıcı ile sunucu arasındaki gecikme 150 ms ise her etkileşim en az o kadar sürer. Her açık sekme sunucuda bir circuit ve onun bellekteki state'i demek; bin eş zamanlı kullanıcı bin circuit demek. Bağlantı koparsa (Wi-Fi değişti, laptop uyudu) kullanıcı .NET 10 template'iyle gelen ReconnectModal penceresini görür ve circuit kaybolduysa sayfayı yenilemek zorunda kalır. Circuit'in kendisi ve ölçeklemesi Real-time Uygulamalar serisinde SignalR tarafından anlatıldı; bu seride yalnız Blazor'u ilgilendiren kısmına bakacağız.
Ne Zaman Blazor, Ne Zaman Angular ya da MVC?
Sunucuda render etme kararını MVC (Model-View-Controller; sayfayı controller ve view ile sunucuda üreten model), Razor Pages ve Angular arasında ASP.NET Core MVC ve Razor Pages serisinin ilk yazısı, "Server-Side Rendering Ne Zaman?", tartışmıştı; burada o tartışmayı tekrar etmiyorum. Bu yazının sorusu daha dar: interaktif bir arayüz gerekiyor, Blazor mı Angular mı?
| Durum | Daha uygun | Neden |
|---|---|---|
| İç uygulama, yönetim paneli, form ve grid ağırlıklı ekranlar | Blazor (Server ya da Auto) | Kullanıcı sayısı sınırlı, ağ kurum içinde; validation ve modeller C#'ta bir kez yazılır |
| Ekip C# biliyor, ayrı bir frontend ekibi yok | Blazor | Tek dil, tek build, tek debug deneyimi; TypeScript ve npm araç zinciri gerekmez |
| Büyük, halka açık, ilk açılışı ve SEO (arama motorlarında bulunabilirlik) açısından kritik bir SPA | Angular, ya da içerik sayfaları için Static SSR / MVC | 74 KB ile 2,2 MB arasındaki ilk yükleme farkı ve bot'ların gördüğü HTML önemli |
| Zengin JavaScript ekosistemine (harita, editör, grafik kütüphaneleri) yoğun bağımlılık | Angular | Blazor'da bunlar JS interop ile sarılır; çok sayıda kütüphanede bu ek iş birikir |
| Çevrimdışı çalışma, sunucuya bağlantının sürekli olmadığı kullanıcılar | Blazor WebAssembly ya da Angular | Interactive Server açık bir bağlantı olmadan çalışamaz |
| Çok sayıda eş zamanlı anonim kullanıcı | Static SSR, WebAssembly ya da Angular | Interactive Server'da her sekme sunucuda bellek ve bağlantı tutar |
SEO ve TTFB (Time To First Byte; isteğe ilk byte'ın dönme süresi) ön plandaysa Blazor'un da Static SSR modu var; 05. yazı tamamen bunun üzerine. Yani "Blazor seçersem her şey interaktif olmak zorunda" diye bir kural yok. Bookstore için kararımız belli: müşteri vitrini Angular'da kalıyor, yönetim paneli Blazor ile yazılıyor. Panelin kullanıcıları mağaza ve depo çalışanları, sayıları onlarla ifade ediliyor ve ekranların çoğu "listele, filtrele, düzenle, kaydet" döngüsü.
Bir not: Blazor component'leri .NET MAUI (Multi-platform App UI; .NET'in masaüstü ve mobil uygulama framework'ü) içinde BlazorWebView ile masaüstü ve mobil uygulamada da çalışabiliyor (Blazor Hybrid; RendererInfo.Name bu durumda WebView döner). Orada WebAssembly yok, kod doğrudan cihazdaki .NET üzerinde çalışır. Bu seri web'e odaklanıyor; mobil taraf için ilerideki Flutter ile Mobil serisine bakabilirsin.
Üretimde Dikkat
Render mode seçimini sezgiyle değil ölçerek yap. WebAssembly için ölçülecek şey yukarıdaki gibi ilk ziyaretteki toplam transfer ve tarayıcının runtime'ı başlatma süresi; ikisi de Release publish üzerinde ölçülmeli, Debug build'deki 202 dosyalık tablo gerçeği yansıtmaz. Interactive Server için ölçülecek şey eş zamanlı circuit sayısı, circuit başına bellek ve kullanıcıların sunucuya gecikmesi. Admin panelini İstanbul'daki ofisten Frankfurt'taki sunucuya bağlanan on kişi kullanıyorsa Server sorunsuzdur; aynı panel yurt dışındaki bayilere açılırsa her tıklamadaki gecikme şikâyet olarak döner.
İkinci nokta sunucu kaynakları. Static SSR'da sunucu her istekte HTML üretip unutur; MVC gibi stateless'tır ve load balancer arkasına instance eklemek yeter. Interactive Server ise stateful'dır: kullanıcının circuit'i hangi sunucudaysa sonraki mesajları da oraya gitmek zorundadır, bu da sticky session demek. 12. yazıda circuit seçeneklerine, reconnection'a ve Azure SignalR Service seçeneğine döneceğiz.
Üçüncüsü güvenlik sınırı. .Client projesine koyduğun her şey tarayıcıya iner: kod, sabitler, wwwroot/appsettings.json. Prerender çıktısında bile WebAssembly component'inin parametreleri base64 olarak, açık hâlde duruyor; Server component'inin işareti ise şifreli bir descriptor taşıyor. 04. yazıda bunu ayrıntılı göstereceğiz; şimdilik kuralı aklında tut: .Client'a secret konmaz.
Sık Düşülen Tuzaklar
Static SSR component'inde butonun neden çalışmadığını aramak. Yukarıdaki ilk kopya gibi, render mode verilmemiş bir component'te @onclick hiçbir şey yapmaz ve hata da vermez. Template'te yeni bir sayfa açıp event handler yazan herkes bu duvara bir kez çarpıyor. Ya sayfaya interaktif bir render mode verirsin ya da etkileşimi form post'uyla çözersin.
Prerender çıktısını gerçek durum sanmak. OnInitializedAsync içinde veri yükleyen interaktif bir component, prerender sırasında bir kez, interaktif olunca bir kez daha çalışır. Log'da iki satır görünce şaşırma; bu çift yüklemeyi 03'te gözleyecek, 06'da kalıcı state ile çözeceğiz.
Auto'yu "en iyisi, hep onu seçeyim" diye kullanmak. Auto iki runtime'ın da bedelini taşır: component hem sunucuda hem tarayıcıda çalışabilmeli, veri erişimi iki ortam için soyutlanmalı ve ilk ziyaret yine de bir circuit açar. Gerçekten iki ortamın avantajına ihtiyacın yoksa Server ya da WebAssembly'den birini seçmek daha basit.
Blazor'u "Angular'ın C# hâli" diye görmek. Component modeli benziyor ama çalışma modeli farklı: Interactive Server'da kodun tarayıcıda değil sunucuda çalışıyor, state sunucunun belleğinde. Angular'dan gelen biri için en büyük zihinsel geçiş bu.
Küçük Bir Deneme
★☆☆ RenderModes.razor sayfasından WebAssembly kopyasını sil ve sayfayı yeni bir tarayıcı profiliyle aç. Auto kopyası artık hangi modu seçiyor? Sonra yalnız Server kopyasını sil; ne değişti?
★★☆ Auto kopyasına @rendermode="new InteractiveAutoRenderMode(prerender: false)" ver ve curl çıktısına tekrar bak. O component'in HTML'i yanıtta hâlâ var mı? Kullanıcı bunu nasıl fark eder?
★★★ Projeyi dotnet publish -c Release ile yayınla ve tarayıcının geliştirici araçlarında Network sekmesini açarak /diagnostics/auto sayfasını boş cache ile yükle. dotnet.native.wasm ve System.Private.CoreLib dosyalarının sıkıştırılmış boyutu yukarıdaki rakamlarla tutuyor mu? Aynı ölçümü Debug build'de yap ve farkı açıklamaya çalış.
Projeyi tek komutla oluşturup dört render mode'u yan yana gördük. Bir sonraki yazıda bu komutun arkasına geçiyoruz: template'in diğer seçenekleri, Program.cs, App.razor, Routes.razor ve layout'lar, @page ile routing ve hot reload; sonunda da Bookstore.Admin'i Bookstore solution'ına ekleyip panelin iskeletini kuruyoruz.