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

İlk Blazor Web App: Template Seçenekleri, Proje Yapısı, App.razor, Routes.razor ve Hot Reload

Mert Özen 15 Kas 2026 14 dk 7 görüntülenme
İlk Blazor Web App: Template Seçenekleri, Proje Yapısı, App.razor, Routes.razor ve Hot Reload

dotnet new blazor'ın --interactivity seçeneği WebAssembly ya da Auto'da tarayıcıya inecek kodu ayrı bir .Client projesine koyar; Program.cs'teki iki kayıt ve MapRazorComponents<App> endpoint'leri üretir, App.razor ile Routes.razor sayfanın iskeletini ve routing'i kurar. Bookstore.Admin'i central package management kullanan Bookstore.slnx'e ekliyor, @page ile route parametresi ve constraint'leri, dotnet watch ile hot reload'u ve .NET 11 template farkları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; .NET 11 bölümündeki template karşılaştırması .NET 11 SDK 11.0.100 ile yapılmıştır.

Önce Tahmin Et

Önceki yazıda Bookstore.Admin'i tek bir komutla oluşturduk: dotnet new blazor -int Auto. Aynı komutu -int Server ile çalıştırsaydık kaç proje oluşurdu? --all-interactive ekleseydik template'in örnek Counter.razor sayfasında @rendermode satırı olur muydu? Bir de şu: Bookstore.Admin'deki Program.cs'in ilk satırı using Bookstore.Admin.Client.Pages;. Template'in örnek sayfalarını silip build alırsan ne olur? Cevaplarını not et; hepsini bu yazıda gerçek çıktılarla göreceğiz.

Template Seçenekleri

dotnet new blazor --help çıktısında Blazor'a özgü üç seçenek var: -int|--interactivity (None, Server, WebAssembly, Auto; varsayılan Server), -ai|--all-interactive ve -e|--empty. Hangisinin ne ürettiğini görmek için hepsini boş bir klasörde --no-restore ile oluşturup karşılaştırdım:

KomutProjeProgram.cs'teki interaktif kayıtlarFark
-int None1yok, yalnız AddRazorComponents()Counter ve ReconnectModal yok; her şey Static SSR (sunucuda bir kez üretilen düz HTML)
-int Server1AddInteractiveServerComponents()Counter.razor'da @rendermode InteractiveServer
-int WebAssembly2 (.Client ile)AddInteractiveWebAssemblyComponents()Counter .Client'ta; circuit olmadığı için ReconnectModal yok
-int Auto2 (.Client ile)ikisi deCounter .Client'ta, @rendermode InteractiveAuto
-int Server --all-interactive1AddInteractiveServerComponents()App.razor'da <Routes @rendermode="InteractiveServer" />, sayfalarda @rendermode yok
-int Auto --empty2ikisi deNavMenu, Counter, Weather ve Bootstrap'li örnek stil yok

Tablodan iki karar çıkıyor. Birincisi .Client projesi: WebAssembly ya da Auto seçersen tarayıcıya inecek kod ayrı bir projede durur, çünkü o proje farklı bir SDK ile (Microsoft.NET.Sdk.BlazorWebAssembly) derlenir ve çıktısı _framework/ altında tarayıcıya servis edilir. Sunucu projesi .Client'a referans verir; tersi mümkün değildir. Bu ayrım bir güvenlik sınırıdır da: veritabanı bağlantısı, secret ya da sunucuya özgü bir service .Client'a girerse tarayıcıya iner. İkincisi interaktivitenin nerede başladığı: varsayılan template'te sayfalar Static SSR'dır ve interaktif olacak sayfa ya da component kendi @rendermode'unu seçer. --all-interactive ise render mode'u en tepeye, Routes component'ine koyar ve uygulamanın tamamı interaktif olur. Bookstore.Admin'de ilk yolu izliyoruz; panelin listeleme ve detay sayfaları Static SSR ile yeterince iyi çalışıyor, interaktivite yalnız gerçekten gereken yerde gelecek. Bu kararın ayrıntısı 04. yazıda.

Mevcut Solution'a Eklemek

Template boş bir klasör için yazılmış; Bookstore reposunun ise kendi kuralları var. 01'de komutu reponun kökünde çalıştırdığımızda restore şu hatayla durdu (yollar kısaltıldı):

src/Bookstore.Admin/Bookstore.Admin.csproj : error NU1008: The following PackageReference items cannot define a value for Version: Microsoft.AspNetCore.Components.WebAssembly.Server. Projects using Central Package Management must define a Version value on a PackageVersion item. For more information, visit https://aka.ms/nuget/cpm/gettingstarted
src/Bookstore.Admin.Client/Bookstore.Admin.Client.csproj : error NU1008: The following PackageReference items cannot define a value for Version: Microsoft.AspNetCore.Components.WebAssembly. Projects using Central Package Management must define a Version value on a PackageVersion item. For more information, visit https://aka.ms/nuget/cpm/gettingstarted
Restore failed.

Bookstore, CPM (Central Package Management; paket sürümlerinin tek bir Directory.Packages.props dosyasında toplanması) kullanıyor ve CPM açıkken bir csproj'daki PackageReference'ın kendi Version attribute'u olamaz. Template ise sürümü csproj'a yazıyor. Çözüm 01'de yaptığımız gibi sürümü merkezî dosyaya taşımak; dotnet new bunu bilmediği için bu adım her yeni projede elle yapılır. Reponun kökündeki Directory.Build.props'tan gelen TreatWarningsAsErrors da yeni projelere uygulanıyor; template'in kodu uyarısız derlendiği için sorun çıkmadı, ama kendi yazdığımız her uyarı artık build'i kıracak.

Template bir de src/Bookstore.Admin.sln üretti. Bookstore'un solution'ı Bookstore.slnx; ikinci bir solution dosyası istemiyoruz. .sln'i silip iki projeyi mevcut solution'a ekliyoruz:

dotnet sln Bookstore.slnx add src/Bookstore.Admin/Bookstore.Admin.csproj src/Bookstore.Admin.Client/Bookstore.Admin.Client.csproj
Project `src/Bookstore.Admin/Bookstore.Admin.csproj` added to the solution.
Project `src/Bookstore.Admin.Client/Bookstore.Admin.Client.csproj` added to the solution.
<Solution>
  <Folder Name="/src/">
    <Project Path="src/Bookstore.Admin.Client/Bookstore.Admin.Client.csproj" />
    <Project Path="src/Bookstore.Admin/Bookstore.Admin.csproj" />
    <Project Path="src/Bookstore.Api/Bookstore.Api.csproj" />
  </Folder>
  <Folder Name="/tests/">
    <Project Path="tests/Bookstore.Api.Tests/Bookstore.Api.Tests.csproj" />
  </Folder>
</Solution>

Artık dotnet build Bookstore.slnx API'yi, testleri ve paneli birlikte derliyor. Template'in örnek sayfalarını (Counter.razor, Weather.razor) silip ilk build'i aldığımda baştaki üçüncü sorunun cevabı geldi:

src/Bookstore.Admin/Program.cs(1,30): error CS0234: The type or namespace name 'Pages' does not exist in the namespace 'Bookstore.Admin.Client' (are you missing an assembly reference?) [src/Bookstore.Admin/Bookstore.Admin.csproj]

Bookstore.Admin.Client.Pages namespace'i yalnız Counter.razor sayesinde vardı; dosya gidince namespace de gitti ve Program.cs'teki using derlenmez oldu. Razor component'in namespace'i klasöründen gelir ve o klasörde hiç component kalmazsa namespace de kalmaz. using satırını silmek yeterli.

Program.cs: İki Kayıt, Bir Endpoint

Panelin şu anki Program.cs'i, template'in ürettiğine Bookstore API'si için bir typed HttpClient eklenmiş hâli:

using Bookstore.Admin.Components;
using Bookstore.Admin.Services;

WebApplicationBuilder builder = WebApplication.CreateBuilder(args);

builder.Services.AddRazorComponents()
    .AddInteractiveServerComponents()
    .AddInteractiveWebAssemblyComponents();

string apiBaseUrl = builder.Configuration["BookstoreApi:BaseUrl"]
    ?? throw new InvalidOperationException("BookstoreApi:BaseUrl is missing (see appsettings.json).");
builder.Services.AddHttpClient<BookstoreApiClient>(client => client.BaseAddress = new Uri(apiBaseUrl));

WebApplication app = builder.Build();

if (app.Environment.IsDevelopment())
{
    app.UseWebAssemblyDebugging();
}
else
{
    app.UseExceptionHandler("/Error", createScopeForErrors: true);
    app.UseHsts();
}
app.UseStatusCodePagesWithReExecute("/not-found", createScopeForStatusCodePages: true);
app.UseHttpsRedirection();

app.UseAntiforgery();

app.MapStaticAssets();
app.MapRazorComponents<App>()
    .AddInteractiveServerRenderMode()
    .AddInteractiveWebAssemblyRenderMode()
    .AddAdditionalAssemblies(typeof(Bookstore.Admin.Client._Imports).Assembly);

app.Run();

Blazor'a ait üç parça var. AddRazorComponents() Razor component'leri sunucuda render etmek için gereken service'leri kaydeder; Static SSR için tek başına yeterlidir. AddInteractiveServerComponents() circuit altyapısını, yani SignalR hub'ını ve circuit yönetimini ekler; AddInteractiveWebAssemblyComponents() WebAssembly component'lerinin prerender edilebilmesi ve tarayıcıya gönderilebilmesi için gerekenleri. Pipeline tarafında MapRazorComponents<App>() uygulamadaki her routable component (yani @page taşıyan her .razor dosyası) için bir endpoint üretir ve kök component olarak App'i kullanır. Zincirdeki AddInteractiveServerRenderMode() ve AddInteractiveWebAssemblyRenderMode() ise o modları bu endpoint'lerde etkinleştirir; örneğin ilki /_blazor hub'ını map eder. Service kaydını yapıp render mode'u eklemeyi unutursan uygulama açılır, ama o moddaki ilk component render edilirken istek 500 ile düşer. AddInteractiveServerRenderMode() satırını silip /diagnostics/render-modes'u istediğimde log'daki exception:

System.InvalidOperationException: A component of type 'Bookstore.Admin.Client.Diagnostics.RenderModeProbe' has render mode 'InteractiveServerRenderMode', but the required endpoints are not mapped on the server. When calling 'MapRazorComponents', add a call to 'AddInteractiveServerRenderMode'. For example, 'builder.MapRazorComponents<...>.AddInteractiveServerRenderMode()'

Mesaj ne yapman gerektiğini de söylüyor; Blazor'un hata mesajları genelde bu kadar açık.

AddAdditionalAssemblies satırı .Client assembly'sindeki sayfaların da endpoint olarak bulunmasını sağlar; /diagnostics/render-modes sunucu projesinde ama 01'deki RenderModeProbe .Client'ta. UseAntiforgery() form post'larında CSRF (kullanıcının açık oturumu üzerinden ona habersiz istek yaptırma saldırısı) token'ını doğrular; 05'te form post'larında göreceğiz. MapStaticAssets() ise MVC (Model-View-Controller; controller ve view'larla sunucuda sayfa üreten model) uygulamalarında da kullanılan fingerprint'li ve sıkıştırılmış dosya servisini sağlar, ayrıntısı ASP.NET Core MVC ve Razor Pages serisinin static asset yazısında; blazor.web.js de .NET 10'dan beri bu yolla, static web asset olarak geliyor.

UseStatusCodePagesWithReExecute("/not-found", ...) satırı .NET 10 template'iyle gelen NotFound.razor sayfasıyla birlikte çalışıyor: hiçbir endpoint'e uymayan bir istek 404 durum koduyla /not-found sayfasını render eder. UseWebAssemblyDebugging() yalnız Development'ta, tarayıcıdaki WebAssembly kodunu IDE'den debug edebilmek için.

App.razor, Routes.razor ve Layout

MVC'de _Layout.cshtml neyse, Blazor Web App'te sayfanın HTML iskeleti App.razor:

<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <base href="/" />
    <ResourcePreloader />
    <link rel="stylesheet" href="@Assets["lib/bootstrap/dist/css/bootstrap.min.css"]" />
    <link rel="stylesheet" href="@Assets["app.css"]" />
    <link rel="stylesheet" href="@Assets["Bookstore.Admin.styles.css"]" />
    <ImportMap />
    <link rel="icon" type="image/png" href="favicon.png" />
    <HeadOutlet />
</head>

<body>
    <Routes />
    <ReconnectModal />
    <script src="@Assets["_framework/blazor.web.js"]"></script>
</body>

</html>

App her zaman Static SSR'dır; render mode dokümanı kök component'i interaktif yapmanın desteklenmediğini açıkça söyler. Bu yüzden --all-interactive render mode'u App'e değil, onun içindeki Routes ve HeadOutlet'e verir. HeadOutlet, sayfaların <PageTitle> ve <HeadContent> ile yazdığı içeriği <head>'e taşır. ResourcePreloader (.NET 10) WebAssembly'nin ihtiyaç duyacağı framework dosyaları için preload link'leri üretir; ImportMap fingerprint'li JavaScript module'lerinin import map'ini yazar; @Assets["..."] ifadesi bir dosyanın fingerprint'li adını döndürür. ReconnectModal ise Interactive Server bağlantısı koptuğunda görünen pencere; .NET 10 template'iyle birlikte kendi .razor.css ve .razor.js dosyalarıyla geliyor ve stilini inline enjekte etmediği için CSP (Content Security Policy; sayfanın hangi kaynaktan script ve stil yükleyebileceğini sınırlayan HTTP header'ı) ile uyumlu.

<Router AppAssembly="typeof(Program).Assembly" AdditionalAssemblies="new[] { typeof(Client._Imports).Assembly }" NotFoundPage="typeof(Pages.NotFound)">
    <Found Context="routeData">
        <RouteView RouteData="routeData" DefaultLayout="typeof(Layout.MainLayout)" />
        <FocusOnNavigate RouteData="routeData" Selector="h1" />
    </Found>
</Router>

Router, isteğin adresine uyan component'i bulup RouteView ile render eder; sayfa kendi layout'unu @layout ile seçmediyse DefaultLayout, yani MainLayout kullanılır. AdditionalAssemblies burada da .Client'ı gösteriyor; Program.cs'teki satır endpoint'ler için, bu satır interaktif routing için. NotFoundPage parametresi .NET 10'da geldi ve eski <NotFound> render fragment'inin yerini aldı. FocusOnNavigate her sayfa geçişinde odağı h1'e taşır; ekran okuyucu kullanıcısı yeni sayfaya geldiğini böyle anlar.

MainLayout.razor bir LayoutComponentBase: sayfanın içeriği @Body'nin olduğu yere gelir. Altındaki blazor-error-ui bloğu, interaktif bir component'te yakalanmamış exception olduğunda gösterilir. _Imports.razor ise klasöründeki ve alt klasörlerindeki bütün .razor dosyalarına @using satırlarını uygular; Razor Pages'teki _ViewImports.cshtml'in karşılığı. Bookstore.Admin'de buraya Bookstore.Admin.Client.Contracts ve Bookstore.Admin.Services'i ekledim.

@page ile Routing

Bir component'i sayfa yapan tek şey @page directive'i. Panelin kitap detay sayfası, route parametresi alan bir component:

@page "/books/{Isbn}"
@inject BookstoreApiClient Api

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

@if (book is null)
{
    <h1>Book not found</h1>
    <p>No book with ISBN <code>@Isbn</code>.</p>
}
else
{
    <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>
}

@code {
    private BookDetails? book;

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

    protected override async Task OnParametersSetAsync() => book = await Api.GetBookAsync(Isbn);
}

{Isbn} segmenti adı aynı olan [Parameter] property'sine bağlanır; eşleşme büyük-küçük harfe duyarlı değil. Veriyi OnParametersSetAsync'te yüklüyorum, çünkü kullanıcı interaktif bir sayfada başka bir kitaba geçtiğinde component yeniden oluşturulmaz, yalnız parametresi değişir; OnInitializedAsync o durumda tekrar çalışmaz. Lifecycle'ın tamamı bir sonraki yazının konusu. Sipariş sayfasında ise bir route constraint var:

@page "/orders/{OrderId:int}"
@inject BookstoreApiClient Api

<PageTitle>Order @OrderId</PageTitle>

<h1>Order @OrderId</h1>

@if (order is null)
{
    <p>No order with this number.</p>
}
else
{
    <p>@order.Status, placed @order.PlacedAtUtc.ToString("u")</p>
    <ul>
        @foreach (OrderLineView line in order.Items)
        {
            <li>@line.Quantity x @line.Title = @line.LineTotal.ToString("N2") @order.Currency</li>
        }
    </ul>
    <p>Total: <strong>@order.Total.ToString("N2") @order.Currency</strong></p>
}

@code {
    private OrderView? order;

    [Parameter]
    public int OrderId { get; set; }

    protected override async Task OnParametersSetAsync() => order = await Api.GetOrderAsync(OrderId);
}

{OrderId:int} segmentin tamsayı olmasını şart koşar ve değeri int'e çevirir. Blazor'un desteklediği constraint'ler tip constraint'leri: bool, datetime, decimal, double, float, guid, int, long ve nonfile. MVC'deki minlength ya da regex gibi constraint'ler yok; ISBN'in (International Standard Book Number; kitabın uluslararası standart numarası) 13 hane olup olmadığını sayfanın içinde kontrol edersin. İsteğe bağlı parametre ({Text?}) ve URL'nin geri kalanını yakalayan catch-all parametre ({*Path}) de destekleniyor.

API'nin yerel kopyasında 1 numaralı bir sipariş oluşturup paneldeki adreslere curl ile istek attım:

GET / -> 200
GET /books -> 200
GET /books/9780441172719 -> 200
GET /books/123 -> 200
GET /orders/1 -> 200
GET /orders/abc -> 404
GET /nope -> 404
<h1>Dune</h1>
    <dl><dt>Author</dt><dd>Frank Herbert</dd>
        <dt>ISBN</dt><dd>9780441172719</dd>
        <dt>Price</dt><dd>489.00 TRY</dd>
        <dt>Available</dt><dd>23</dd></dl>

/orders/abc constraint'e uymadığı için hiçbir endpoint'le eşleşmedi; UseStatusCodePagesWithReExecute 404 durum koduyla NotFound.razor'u render etti. /books/123 ise 200 döndü: route eşleşti, sayfa render edildi ve API'den gelen 404'ü "Book not found" mesajıyla gösterdi. Arama motoru ya da monitoring açısından bu yanlış; olmayan bir kitabın adresi 404 dönmeli. .NET 10'daki NavigationManager.NotFound() ile bunu 05'te düzelteceğiz.

API'ye Bağlanmak

Panelin verisi Bookstore API'sinden geliyor. API'nin döndürdüğü JSON'un karşılığı olan record'ları .Client projesine, Contracts klasörüne koydum; 04'te tarayıcıda çalışan bir component de aynı tiplere ihtiyaç duyacak:

namespace Bookstore.Admin.Client.Contracts;

// Bookstore.Api'nin döndürdüğü JSON'un karşılığı; WebAssembly component'leri de kullanabilsin diye .Client'ta.
public sealed record BookSummary(string Isbn, string Title, string Author, string Category, decimal Price, int PublishedYear);

public sealed record BookDetails(
    string Isbn, string Title, string Author, string Category, decimal Price, int PublishedYear, string Description, int Available);

public sealed record PagedResult<T>(IReadOnlyList<T> Items, int Total, int Page, int PageSize);

Sunucu tarafında bir typed HttpClient. BaseAddress configuration'dan geliyor; appsettings.json'da API'nin README'deki varsayılan adresi http://localhost:5080/ duruyor, farklı bir adres için BookstoreApi__BaseUrl environment variable'ı yeterli:

using System.Net;
using Bookstore.Admin.Client.Contracts;

namespace Bookstore.Admin.Services;

// Bookstore.Api için typed HttpClient; base address configuration'dan gelir (BookstoreApi:BaseUrl).
public sealed class BookstoreApiClient(HttpClient http)
{
    public async Task<PagedResult<BookSummary>> GetBooksAsync(int page, int pageSize, CancellationToken ct = default) =>
        await http.GetFromJsonAsync<PagedResult<BookSummary>>($"api/books?page={page}&pageSize={pageSize}", ct)
        ?? new PagedResult<BookSummary>([], 0, page, pageSize);

    public Task<BookDetails?> GetBookAsync(string isbn, CancellationToken ct = default) =>
        GetOrNullAsync<BookDetails>($"api/books/{Uri.EscapeDataString(isbn)}", ct);

    public Task<OrderView?> GetOrderAsync(int orderId, CancellationToken ct = default) =>
        GetOrNullAsync<OrderView>($"api/orders/{orderId}", ct);

    private async Task<T?> GetOrNullAsync<T>(string uri, CancellationToken ct) where T : class
    {
        using HttpResponseMessage response = await http.GetAsync(uri, ct);
        if (response.StatusCode == HttpStatusCode.NotFound)
        {
            return null;
        }

        response.EnsureSuccessStatusCode();
        return await response.Content.ReadFromJsonAsync<T>(ct);
    }
}

Component bu class'ı @inject BookstoreApiClient Api ile alıyor; HttpClient'ı component'in içinde new'lemiyoruz. Bu client şimdilik yalnız sunucuda çalışan kodda kullanılabilir. Tarayıcıda çalışan bir component Bookstore API'sine nasıl ulaşacak sorusunu 04'te, Server ve WebAssembly için ortak bir abstraction'ı 09'da ele alacağız.

Hot Reload

Geliştirme sırasında uygulamayı dotnet run yerine dotnet watch ile çalıştırınca, kaydettiğin değişiklikler çalışan uygulamaya yeniden başlatmadan uygulanır:

dotnet watch --project src/Bookstore.Admin

Home.razor'daki metni değiştirip kaydettiğimde watch çıktısı:

dotnet watch 🔥 Hot reload enabled. For a list of supported edits, see https://aka.ms/dotnet/hot-reload.
dotnet watch 💡 Press Ctrl+R to restart.
dotnet watch ⌚ Loading projects ...
dotnet watch ⌚ Loaded 2 project(s) in 0.5s.
dotnet watch ⌚ Waiting for changes
dotnet watch ⌚ File updated: ./src/Bookstore.Admin/Components/Pages/Home.razor
dotnet watch 🔥 C# and Razor changes applied in 108ms.

Değişiklik 108 ms'de çalışan process'e uygulandı ve sonraki istek yeni metni döndürdü. Hot reload markup'ta, method gövdelerinde ve CSS'te çoğu değişikliği kaldırır. Program.cs gibi yalnız açılışta bir kez çalışan kodu değiştirirsen değişiklik derlenir ama etkisi görünmez; çıktıdaki ipucunun dediği gibi Ctrl+R ile uygulamayı yeniden başlatman gerekir. Interactive Server sayfası açıkken yapılan değişiklik tarayıcıdaki sayfaya da yansır, ama bu sırada circuit'teki state korunur; bazen eski state ile yeni kodun uyumsuzluğu tuhaf sonuçlar verir, öyle bir durumda sayfayı yenilemek en kısa yol.

.NET 11'de Template'te Neler Değişti?

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. Aynı komutu .NET 11 SDK'sıyla (11.0.100) çalıştırıp Program.cs'leri karşılaştırdım:

--- net10/Program.cs
+++ net11/Program.cs
@@ -11,20 +11,14 @@
 var app = builder.Build();
 
 // Configure the HTTP request pipeline.
-if (app.Environment.IsDevelopment())
+if (!app.Environment.IsDevelopment())
 {
-    app.UseWebAssemblyDebugging();
-}
-else
-{
     app.UseExceptionHandler("/Error", createScopeForErrors: true);
     // The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts.
     app.UseHsts();
 }
 app.UseStatusCodePagesWithReExecute("/not-found", createScopeForStatusCodePages: true);
 app.UseHttpsRedirection();
-
-app.UseAntiforgery();
 
 app.MapStaticAssets();
 app.MapRazorComponents<App>()

İki satır gitti. UseAntiforgery() artık template'te yok; .NET 11 sürüm notlarına göre CSRF koruması otomatik eklenen bir middleware ile varsayılan olarak açık ve açık çağrı yalnız özel durumlarda gerekiyor. UseWebAssemblyDebugging() da template'ten çıkmış. App.razor'da <base href="/" /> yerine yeni <BasePath /> component'i var; uygulamanın base path'ini kendisi yazıyor. NavMenu.razor'daki inline onclick handler'ı da kaldırılmış, menüyü açıp kapatan kod collocated bir NavMenu.razor.js module'üne taşınmış; amaç yine CSP'ye inline script için istisna yazmak zorunda kalmamak. .NET 10'da kalan bir projede bu değişiklikleri almak zorunda değilsin, ama CSP kullanıyorsan NavMenu'deki inline handler'ı kendin de module'e taşıyabilirsin.

Üretimde Dikkat

Template'in launchSettings.json'u her projede rastgele bir port seçiyor (bizde http://localhost:5037) ve https profili de var. Production'da bu dosya kullanılmaz; adresler ASPNETCORE_URLS ya da Kestrel configuration'ından gelir. Panelin API'ye giden adresi de configuration'da; Development ve Production için ayrı değerleri environment variable ile vermek, appsettings.json'u ortamlara göre çoğaltmaktan daha az hata çıkarır.

UseStatusCodePagesWithReExecute bütün 404'leri /not-found sayfasına yönlendirir; API benzeri endpoint'ler ekleyeceksen onların 404'ünün HTML sayfaya dönüşmesini istemezsin. Bu middleware'in hangi isteklere uygulanacağını erken düşün. Son olarak wwwroot/lib/bootstrap klasörü template'le birlikte repoya giriyor: map dosyalarıyla 8,4 MB'lık bir klasör ve sayfaya yüklenen 230 KB'lık bootstrap.min.css. Panelde kendi tasarım sistemini kullanacaksan baştan çıkarmak, sonradan temizlemekten kolay.

Sık Düşülen Tuzaklar

Sayfa component'ine, kullandığı record'la aynı adı vermek. Detay sayfasını ilk denemede BookDetails.razor olarak yazdım; dosya adı class adı olduğu için component, Contracts'taki BookDetails record'unu gölgeledi:

src/Bookstore.Admin/Components/Pages/Books/BookDetails.razor(28,68): error CS0029: Cannot implicitly convert type 'Bookstore.Admin.Client.Contracts.BookDetails' to 'Bookstore.Admin.Components.Pages.Books.BookDetails'

Hata mesajı iki tam adı da veriyor ama ilk okumada anlamak zor. Sayfalara Page son ekini vermek (BookDetailsPage, BookListPage) bu çakışmayı baştan engelliyor.

Route'u yalnız @page'de değil, link'te de yazmak. <a href="books/@book.Isbn"> göreceli bir adres; <base href="/" /> sayesinde doğru çalışıyor. Başında / olan mutlak adresler uygulama bir alt path'te (/admin gibi) yayınlandığında kırılır.

Static SSR sayfasında veriyi OnInitializedAsync'te yükleyip interaktif sayfada aynı kodun yeni parametreyle tekrar çalışacağını sanmak. Static SSR'da her istek yeni bir component demek, fark görünmez; aynı sayfa interaktif olduğunda parametre değişimi yalnız OnParametersSet{Async}'i tetikler.

.Client projesine sunucuya ait bir şey koymak. BookstoreApiClient sunucu projesinde; içine bir gün bir API key girerse bu key tarayıcıya inmesin. Contract'lar ise bilerek .Client'ta, çünkü onlar zaten API'nin herkese açık şekli.

Küçük Bir Deneme

★☆☆ dotnet new blazor -int Server --all-interactive -o Demo ile bir proje oluştur, çalıştır ve tarayıcıda sayfalar arasında gezin. Geliştirici araçlarının Network sekmesinde her sayfa geçişinde yeni bir HTML isteği görüyor musun? Aynısını --all-interactive olmadan dene.

★★☆ OrderDetailsPage'e @page "/orders/{OrderId:int}/{Section?}" diye ikinci bir route ekle ve Section değerine göre yalnız kalemleri ya da yalnız toplamı göster. /orders/1/items ve /orders/1 adreslerini dene.

★★★ BookDetailsPage'teki route'u "/books/{Isbn:long}" yap ve Isbn property'sini long'a çevir. Hangi ISBN'ler artık açılmıyor? (İpucu: ISBN-13'ler 978 ya da 979 ile başlar, ama ISBN-10'larda son hane X olabilir.) Sonra aynı şeyi bir string parametre ve sayfa içi kontrolle yap; hangisi daha doğru 404 üretiyor?

Panelin iskeleti hazır: solution'da, API'ye bağlı, üç sayfası var ve hepsi Static SSR. Bir sonraki yazıda interaktiviteye geçiyoruz: stok ekranında BookList, BookRow ve düşük stok uyarısı component'lerini yazarken parametreleri, EventCallback'i, data binding'i ve component lifecycle'ını Angular karşılıklarıyla birlikte göreceğiz.