Mert Özen Her Satırda Daha İleri
Frontend Blazor — #05

Interaktivite Olmadan Blazor: Static SSR, Streaming Rendering, Enhanced Navigation ve Form Post'ları

Mert Özen 15 Kas 2026 12 dk 7 görüntülenme
Interaktivite Olmadan Blazor: Static SSR, Streaming Rendering, Enhanced Navigation ve Form Post'ları

Static SSR'da Blazor component'i her istekte sunucuda render edilip düz HTML olarak gönderilir; etkileşim link, query string ve form post'larıyla kurulur, [StreamRendering] iskeleti hemen gönderip veriyi aynı yanıtın sonuna ekler. Bookstore panelinde kitap arama ve sayfalamayı, enhanced navigation'ı, antiforgery korumalı stok sayım formunu, .NET 10'daki NotFound() ile 404'ü ve .NET 11'de gelen TempData'yı 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ş, .NET 11 bölümündeki örnek .NET 11 SDK 11.0.100 ile çalıştırılmıştır.

Önce Tahmin Et

Panelin kitap listesi bu yazıda arama ve sayfalama kazanıyor ve hiçbir render mode'u yok, tamamen Static SSR (Static Server-Side Rendering; component'in her istekte sunucuda render edilip düz HTML olarak gönderilmesi). Sayfada [StreamRendering] attribute'u var: önce "Loading books..." yazan bir iskelet, veri gelince tablo. Sayfayı curl ile istediğinde ne görürsün: yalnız iskeleti mi, yalnız tabloyu mu, ikisini birden mi? İkinci soru: sayfada circuit ya da WebAssembly yok. Sayfalama link'ine tıkladığında tarayıcı sayfayı baştan yükler mi? Üçüncüsü: olmayan bir ISBN (International Standard Book Number; kitabın uluslararası standart numarası) ile kitap detayını açtığında yanıtın durum kodu ne olur? 02'deki hâlinde 200'dü.

Static SSR: İstek Gelir, HTML Gider

Static SSR, MVC (Model-View-Controller; controller ve view'larla sunucuda sayfa üreten model) ve Razor Pages'in çalışma modeliyle aynı: istek gelir, component oluşturulur, lifecycle çalışır, HTML yanıta yazılır, component atılır. Circuit yok, sunucuda kullanıcı başına tutulan state yok, tarayıcıya .NET runtime'ı inmiyor. Bunun üç sonucu var. İlk yanıtta içerik tam olarak geliyor; arama motoru da, link önizlemesi üreten bir uygulama da sayfayı eksiksiz görüyor. Sunucu stateless kalıyor; load balancer arkasına instance eklemek yetiyor. Kaynak tüketimi istek süresiyle sınırlı; 01'deki ölçümde bir Auto sayfasının ilk ziyaretinde 2,2 MB indirildiğini görmüştük, aynı sayfa Static SSR'da 12 KB HTML'di.

Bedeli de belli: component tarayıcıda yaşamadığı için @onclick gibi event handler'lar çalışmıyor. Etkileşim, web'in eski ve sağlam araçlarıyla kuruluyor: link'ler, query string ve form post'ları. Blazor bunların üzerine iki şey ekliyor: streaming rendering ve enhanced navigation. Panelin kitap listesi şimdi şöyle:

@page "/books"
@attribute [StreamRendering]
@inject BookstoreApiClient Api
@inject NavigationManager Navigation

<PageTitle>Books</PageTitle>

<h1>Books</h1>

<form method="get" data-enhance class="d-flex gap-2 mb-3">
    <input name="search" value="@Search" placeholder="Title, author or ISBN" class="form-control w-auto" />
    <select name="category" class="form-select w-auto">
        <option value="">All categories</option>
        @foreach (CategoryView category in categories)
        {
            <option value="@category.Slug" selected="@(category.Slug == Category)">@category.Name</option>
        }
    </select>
    <button type="submit" class="btn btn-primary">Search</button>
    <a href="books" class="btn btn-link">Clear</a>
</form>

@if (result is null)
{
    <p><em>Loading books...</em></p>
}
else if (result.Items.Count == 0)
{
    <p>No books match your search.</p>
}
else
{
    <table class="table">
        <thead>
            <tr><th>Title</th><th>Author</th><th>Category</th><th class="text-end">Price</th></tr>
        </thead>
        <tbody>
            @foreach (BookSummary book in result.Items)
            {
                <tr>
                    <td><a href="books/@book.Isbn">@book.Title</a></td>
                    <td>@book.Author</td>
                    <td>@book.Category</td>
                    <td class="text-end">@book.Price.ToString("N2") TRY</td>
                </tr>
            }
        </tbody>
    </table>
    <nav class="d-flex gap-2">
        @for (int number = 1; number <= pageCount; number++)
        {
            <a class="btn btn-sm @(number == result.Page ? "btn-primary" : "btn-outline-primary")"
               href="@Navigation.GetUriWithQueryParameter("page", number)">@number</a>
        }
    </nav>
    <p class="text-muted mt-2">@result.Total books, page @result.Page of @pageCount</p>
}

@code {
    private const int PageSize = 5;
    private PagedResult<BookSummary>? result;
    private IReadOnlyList<CategoryView> categories = [];
    private int pageCount;

    [SupplyParameterFromQuery]
    public string? Search { get; set; }

    [SupplyParameterFromQuery]
    public string? Category { get; set; }

    [SupplyParameterFromQuery(Name = "page")]
    public int? PageNumber { get; set; }

    protected override async Task OnInitializedAsync()
    {
        Task<IReadOnlyList<CategoryView>> categoriesTask = Api.GetCategoriesAsync();
        Task<PagedResult<BookSummary>> booksTask = Api.GetBooksAsync(Search, Category, PageNumber ?? 1, PageSize);
        await Task.WhenAll(categoriesTask, booksTask);

        categories = categoriesTask.Result;
        result = booksTask.Result;
        pageCount = (int)Math.Ceiling(result.Total / (double)PageSize);
    }
}

Query String ile Arama ve Sayfalama

[SupplyParameterFromQuery] query string'deki değeri property'ye bağlıyor: /books?search=Austen&page=2 isteğinde Search "Austen", PageNumber 2 oluyor. Adı property'den farklıysa Name ile veriliyor; page parametresini PageNumber'a bu yüzden bağladım. Desteklenen tipler string, sayılar, bool, DateTime, Guid ve bunların nullable ve array hâlleri. Arama kutusu ise sıradan bir HTML formu: method="get" olduğu için gönderilince alanlar query string'e yazılıyor ve aynı sayfa yeni parametrelerle yeniden render ediliyor. Sayfalama link'leri NavigationManager.GetUriWithQueryParameter("page", number) ile üretiliyor; bu method mevcut query string'i koruyup yalnız page'i değiştiriyor, böylece arama terimi sayfa değişince kaybolmuyor.

OnInitializedAsync'te iki API çağrısı var ve ikisi Task.WhenAll ile paralel çalışıyor; sıralı iki await her istekte iki ağ gidiş-dönüşünü toplar. Static SSR'da sayfanın süresi doğrudan kullanıcının beklediği süre olduğu için bu fark önemli. API'nin pageSize sınırı ve sıralaması zaten Bookstore.Api'de; panel yalnız parametreleri iletiyor.

Streaming Rendering

Normalde Static SSR'da sunucu, component'in bütün async işleri bitene kadar yanıtı göndermez. API yavaşsa kullanıcı boş bir sekmeye bakar. [StreamRendering] bunu değiştiriyor: sunucu ilk render'ı, yani OnInitializedAsync'in ilk await'ine kadar olan hâli hemen gönderiyor, bağlantıyı açık tutuyor ve iş bitince güncel HTML'i aynı yanıtın sonuna ekliyor. curl -N "/books?search=Austen" yanıtının ilgili kısımları (kısaltılmış):

<p><em>Loading books...</em></p><!--/bl:16--></article>
...
    <script src="_framework/blazor.web.whefm3rrpu.js"></script></body></html>
<blazor-ssr><template blazor-component-id="24">
...
        <tbody><tr><td><a href="books/9780141439587">Emma</a></td>
...
    <p class="text-muted mt-2">2 books, page 1 of 1</p></template><blazor-ssr-end></blazor-ssr-end></blazor-ssr>

Baştaki ilk sorunun cevabı: ikisi birden, tek bir yanıtta. Önce iskeletli sayfa </html>'e kadar geliyor; tarayıcı bunu hemen gösteriyor. Sonra aynı yanıtın içinde bir <blazor-ssr> elementi ve içinde <template> geliyor; blazor.web.js bu template'teki HTML'i blazor-component-id'si 24 olan component'in yerine yerleştiriyor. JavaScript çalışmayan bir istemci de tabloyu alıyor, yalnız sayfanın sonunda. Arama motorları için içerik hâlâ ilk yanıtın içinde.

Streaming'in bir sonucu var: yanıt başladıktan sonra HTTP header'ı ya da cookie yazamazsın. Durum kodu, redirect ya da cookie gerektiren her şey ilk await'ten önce olmalı. Bu yüzden form post'u işleyen kitap detay sayfasında [StreamRendering] yok.

Enhanced Navigation

Static SSR sayfası tarayıcıda yine de blazor.web.js'i yüklüyor ve bu script link tıklamalarını yakalıyor. Hedef uygulamanın içindeyse tarayıcının normal sayfa geçişi yerine bir fetch isteği atıyor, gelen HTML'i mevcut DOM (Document Object Model; tarayıcının sayfayı tuttuğu ağaç yapısı) ile karşılaştırıp yalnız değişen kısımları güncelliyor. Playwright ile sayfaya bir işaret koyup (window.__sameDocument = true) sayfalama link'ine, arama formuna ve data-enhance-nav="false" verilmiş "Clear" link'ine tıkladığımda:

click page 2: url=/books?page=2, same document=true
  GET /books?page=2 fetch (enhanced)
search form: url=/books?search=Dune&category=, same document=true
  GET /books?search=Dune&category= fetch (enhanced)
Clear link with data-enhance-nav="false": url=/books, same document=false
  GET /books document

İkinci sorunun cevabı: hayır. Sayfalama link'i ve arama formu birer fetch isteği oldu ve sayfa aynı belge olarak kaldı; scroll pozisyonu ve sayfada çalışan JavaScript state'i korunuyor. Bu istekler Accept header'ında blazor-enhanced-nav=on taşıyor; sunucu yanıtı buna göre üretiyor. data-enhance-nav="false" olan link'te ise tarayıcı klasik bir belge isteği yaptı ve işaret kayboldu. Bu attribute'u, Blazor'un yönetmediği bir sayfaya (örneğin bir Razor Pages ekranına ya da dosya indirme endpoint'ine) giden link'lerde ve sayfa geçişinde baştan yüklenmesi gereken script'lerin olduğu sayfalarda kullanırsın. Hedef Blazor endpoint'i değilse script zaten tam sayfa yüklemesine geri dönüyor.

Arama formu da enhanced oldu, çünkü <form>'da data-enhance var. Form'lar için bu varsayılan değil ve hiyerarşik de değil: attribute'u form'un kendisine yazmak gerekiyor, üstteki bir div'e yazmak işe yaramıyor.

Form Post'ları

Kitap detay sayfası, depo sayımından sonra eldeki adedi girmek için bir form kazandı:

@page "/books/{Isbn}"
@inject BookstoreApiClient Api
@inject NavigationManager Navigation
@inject ILogger<BookDetailsPage> Logger

<PageTitle>@(book?.Title ?? "Book")</PageTitle>

@if (book is not null)
{
    <h1>@book.Title</h1>
    <dl>
        <dt>Author</dt><dd>@book.Author</dd>
        <dt>ISBN</dt><dd>@book.Isbn</dd>
        <dt>Price</dt><dd>@book.Price.ToString("N2") TRY</dd>
        <dt>Available</dt><dd>@book.Available</dd>
    </dl>

    <h2>Stock count</h2>
    @if (Saved)
    {
        <p class="alert alert-success">Stock saved.</p>
    }
    @if (error is not null)
    {
        <p class="alert alert-danger">@error</p>
    }
    <EditForm Model="StockCount" FormName="stock-count" OnSubmit="SaveStockAsync" Enhance>
        <label>
            Counted quantity on hand
            <InputNumber @bind-Value="StockCount!.QuantityOnHand" class="form-control w-auto" />
        </label>
        <button type="submit" class="btn btn-primary">Save</button>
    </EditForm>
}

@code {
    private BookDetails? book;
    private string? error;

    [Parameter]
    public string Isbn { get; set; } = "";

    [SupplyParameterFromQuery]
    public bool Saved { get; set; }

    [SupplyParameterFromForm(FormName = "stock-count")]
    private StockCountForm? StockCount { get; set; }

    protected override async Task OnInitializedAsync()
    {
        StockCount ??= new();
        book = await Api.GetBookAsync(Isbn);
        if (book is null)
        {
            Navigation.NotFound();
        }
    }

    private async Task SaveStockAsync()
    {
        if (!await Api.SetQuantityOnHandAsync(Isbn, StockCount!.QuantityOnHand))
        {
            error = "The counted quantity cannot be lower than the reserved quantity.";
            return;
        }

        Navigation.NavigateTo($"books/{Isbn}?saved=true");
        // BlazorDisableThrowNavigationException açıkken (.NET 10 template'inde açık) bu satır yine çalışır.
        Logger.LogInformation("Stock count for {Isbn} saved, redirecting", Isbn);
    }

    public sealed class StockCountForm
    {
        public int QuantityOnHand { get; set; }
    }
}

Static SSR'da form gönderimi bir HTTP POST. Blazor gelen form alanlarını [SupplyParameterFromForm] ile işaretli property'ye bağlıyor ve FormName'i eşleşen formun OnSubmit handler'ını çağırıyor. FormName zorunlu; aynı sayfada birden fazla form olabileceği için Blazor POST'un hangi forma ait olduğunu gizli _handler alanından öğreniyor. StockCount ??= new(); satırı da bu yüzden var: GET isteğinde form boş bir modelle çiziliyor, POST'ta ise model gelen değerlerle zaten dolu. Validation ve input component'lerinin ayrıntısı 07. yazıda.

EditForm içine antiforgery token'ını kendisi ekliyor. Token'sız bir POST'u curl ile gönderdiğimde:

HTTP/1.1 400 Bad Request
A valid antiforgery token was not provided with the request. Add an antiforgery token, or disable antiforgery validation for this endpoint.

ASP.NET Core MVC ve Razor Pages serisinin form yazısındaki CSRF (kullanıcının açık oturumu üzerinden ona habersiz istek yaptırma saldırısı) korumasının aynısı; Program.cs'teki UseAntiforgery() bunu sağlıyor. Tarayıcıdan sayım 1 ve 30 ile gönderdiğimde (Middlemarch'ın 2 adedi bir siparişte rezerve):

stock count 1: The counted quantity cannot be lower than the reserved quantity.
  after POST: url=/books/9780141439549, same document=true
  POST /books/9780141439549 fetch (enhanced)
stock count 30: Stock saved., available now 28
  after POST: url=/books/9780141439549?saved=true, same document=true
  POST /books/9780141439549 fetch (enhanced)
  GET /books/9780141439549?saved=true fetch
info: Bookstore.Admin.Components.Pages.Books.BookDetailsPage[0]
      Stock count for 9780141439549 saved, redirecting

İlk gönderimde API 400 döndü ve sayfa hata mesajıyla yeniden render edildi. İkincisinde NavigateTo bir redirect üretti ve tarayıcı ?saved=true adresini GET ile istedi; yani PRG (Post-Redirect-Get; POST'tan sonra GET'e yönlendirerek sayfa yenilendiğinde formun tekrar gönderilmesini önlemek) pattern'i. İki gönderim de enhanced, sayfa baştan yüklenmedi. Log satırı .NET 10'daki bir değişikliği gösteriyor: eskiden Static SSR'da NavigateTo bir NavigationException fırlatıp method'u yarıda keserdi. .NET 10 template'i BlazorDisableThrowNavigationException MSBuild property'sini true yapıyor; artık NavigateTo'dan sonraki satır da çalışıyor, davranış interaktif render ile aynı.

Olmayan Kitap İçin 404

02'de /books/123 200 dönüyor ve sayfada "Book not found" yazıyordu. .NET 10'daki NavigationManager.NotFound() bunu düzeltiyor: Static SSR'da yanıtın durum kodunu 404 yapıyor ve Router'a verilen NotFoundPage'i, yoksa status code re-execute middleware'inin sayfasını render ediyor:

HTTP/1.1 404 Not Found
<h3>Not Found</h3>
<p>Sorry, the content you are looking for does not exist.</p>

Üçüncü sorunun cevabı artık 404. NotFound() çağrısından sonra sayfanın kendi markup'ı render edilmiyor; @if (book is not null) kontrolü yalnız component'in geri kalanının null ile çalışmaması için. Interaktif render'da aynı çağrı router'a Not Found içeriğini göstermesini söylüyor, streaming rendering açıksa da sayfayı yeniden yüklemeden. Bu sayede aynı kod üç modda da doğru davranıyor.

.NET 11'de Static SSR İçin Gelenler

Bu bölüm .NET 11 içindir; serinin kodu .NET 10 LTS (Long Term Support; üç yıl desteklenen sürüm) üzerinde kalıyor. Static SSR'da form gönderiminden sonra "kaydedildi" mesajını taşımak için yukarıda ?saved=true query string'ini kullandık. .NET 11, MVC'den bildiğimiz TempData'yı Static SSR'a getiriyor. .NET 11 SDK'sıyla -int None template'inden açtığım küçük bir projede:

@page "/stock-count"
@inject NavigationManager Navigation

<h1>Stock count</h1>

@if (message is not null)
{
    <p class="flash">@message</p>
}

<EditForm Model="Form" FormName="stock-count" OnSubmit="Save" Enhance>
    <InputNumber @bind-Value="Form!.QuantityOnHand" />
    <button type="submit">Save</button>
</EditForm>

@code {
    [CascadingParameter]
    public ITempData? TempData { get; set; }

    private string? message;

    [SupplyParameterFromForm(FormName = "stock-count")]
    private StockCountForm? Form { get; set; }

    protected override void OnInitialized()
    {
        Form ??= new();
        // .NET 11: Get değeri okuduktan sonra siler; değer yalnız bir redirect'i atlatır.
        message = TempData?.Get("Message") as string;
    }

    private void Save()
    {
        TempData!["Message"] = $"Saved: {Form!.QuantityOnHand} copies on hand.";
        Navigation.NavigateTo("stock-count");
    }

    public sealed class StockCountForm
    {
        public int QuantityOnHand { get; set; }
    }
}
first visit:      (no message)
after the POST:   Saved: 30 copies on hand.  url=/stock-count
after a reload:   (no message)

Mesaj redirect'ten sonraki GET'te göründü, sayfa yenilenince kayboldu: tek seferlik bir flash mesaj. Değer varsayılan olarak Data Protection ile şifrelenmiş bir cookie'de taşınıyor. Aynı örneği önce dokümandaki daha kısa yolla, property'ye [SupplyParameterFromTempData] vererek denedim; benim denememde mesaj yenilemeden sonra da göründü (after a reload: Saved: 30 copies on hand.). Tek seferlik davranış istiyorsan değeri Get ile okumak garanti. .NET 11'de ayrıca session'a bağlı değerler için [SupplyParameterFromSession], bir component alt ağacının HTML çıktısını cache'leyen CacheView ve QuickGrid için URL tabanlı sayfalama geliyor; bunlara 13. yazıda toplu bakacağız. Bir uyarı: dokümana göre cookie tabanlı TempData, streaming rendering yapan bir sayfada yanıt başladıktan sonra yazılamıyor.

Üretimde Dikkat

Static SSR sayfası her istekte sıfırdan render edildiği için maliyeti istek başına; sayfanın süresi API'nin ve veritabanının süresi demek. Kitap listesinde iki API çağrısını paralel yapmamız bu yüzden. Herkese aynı görünen sayfalar için output caching de bir seçenek; kullanıcıya özel sayfalarda değil.

Streaming rendering'in bağlantıyı açık tuttuğunu unutma. Arada response'u buffer'layan bir reverse proxy, CDN (Content Delivery Network; statik içeriği kullanıcıya yakın sunuculardan dağıtan ağ) ya da sıkıştırma middleware'i varsa streaming çalışmaz ve sayfa yine sonunda gelir; doküman bu durumda sayfanın streaming olmadan yüklendiğini söylüyor. Production ortamında curl -N ile yanıtın gerçekten iki parça hâlinde geldiğini kontrol et.

Enhanced navigation sayfayı baştan yüklemediği için, sayfaya özel <script> etiketleri ikinci ziyarette tekrar çalışmayabilir. Static SSR sayfalarında JavaScript gerekiyorsa Blazor'un enhancedload event'ini ya da dokümandaki page script yaklaşımını kullan; ayrıntısı JS interop yazısında.

Sık Düşülen Tuzaklar

Static SSR sayfasında @onclick kullanmak. 01'deki ilk denemede gördüğümüz gibi hata vermez, sadece hiçbir şey olmaz. Kitap listesindeki "Clear" düğmesi bu yüzden bir buton değil, href="books" olan bir link. Etkileşim gerekiyorsa link, GET formu ya da POST formu kullan; gerçekten tarayıcıda çalışan bir davranış gerekiyorsa o parçayı ayrı bir interaktif component'e taşı.

FormName'i unutmak. Form'dan FormName'i silip gönderdiğimde gizli _handler alanı render edilmedi ve POST şu yanıtla döndü:

POST status: 400
The POST request does not specify which form is being submitted. To fix this, ensure <form> elements have a @formname attribute with any unique value, or pass a FormName parameter if using <EditForm>.

Aynı sayfada iki form varsa adları da farklı olmalı.

[SupplyParameterFromForm] property'sini OnInitialized'da koşulsuz new() ile ezmek. StockCount = new(); yazarsan POST'ta gelen değer kaybolur ve form her zaman 0 kaydeder. Doğrusu ??=.

Streaming sayfasında yanıt başladıktan sonra redirect ya da cookie yazmaya çalışmak. Header'lar gönderildikten sonra bunlar yapılamaz; işlem yapan sayfayı streaming'siz bırak ya da işi ilk await'ten önce bitir.

Küçük Bir Deneme

★☆☆ BookListPage'ten [StreamRendering]'i kaldır ve curl -N "/books?search=Austen" çıktısına tekrar bak. "Loading books..." hâlâ yanıtta mı? <blazor-ssr> elementi?

★★☆ Arama formundan data-enhance'i kaldır ve Playwright ya da tarayıcının Network sekmesiyle aynı aramayı yap. İstek türü ne oldu? Sayfadaki scroll pozisyonu korunuyor mu?

★★★ Kitap detay sayfasına ikinci bir form ekle: fiyat güncelleme (FormName="price"). Bookstore.Api'ye uygun bir endpoint yaz, iki formun aynı sayfada birbirini etkilemeden çalıştığını doğrula. Bir formda hata olduğunda diğer formdaki girilmiş değer ne oluyor?

Static SSR ile interaktiviteye ihtiyaç duymadan bir ekranın ne kadarını çözebildiğimizi gördük. Interaktif sayfalarda ise 03'ten beri bir sorun bekliyor: prerendering yüzünden veri iki kez yükleniyor. Bir sonraki yazıda bu çift yüklemeyi .NET 10'daki [PersistentState] ile çözüyor, circuit state persistence'a ve kalıcı state'in güvenlik tarafına bakıyoruz.