Mert Özen Her Satırda Daha İleri
Backend ASP.NET Core MVC ve Razor Pages — #08

Razor Pages: PageModel, Handler Method'ları, Page Routing ve Ne Zaman MVC Yerine

Mert Özen 8 Kas 2026 13 dk 2 görüntülenme
Razor Pages: PageModel, Handler Method'ları, Page Routing ve Ne Zaman MVC Yerine

Razor Pages'te @page ile dosya kendisi endpoint olur, PageModel'deki OnGetAsync ve OnPostDeleteAsync gibi handler'lar action'ların yerini alır. Bookstore yönetim CRUD'unu yeniden yazıyor; SupportsGet, TempData, AuthorizeFolder ve page filter'ları, eşleşmeyen handler'ın neden 500 verdiğini ve MVC ile dosya düzenini gerçek çıktılarla inceliyoruz.

Sürüm notu: Bu yazıdaki kodlar .NET 10 (SDK 10.0.402) ve Entity Framework Core (EF Core) 10.0.14 (SQLite) ile derlenip denenmiştir.

Önce Tahmin Et

Bookstore'un yönetim panelinde kitap listesini gösteren bir Razor Page var. Sayfanın PageModel'inde iki handler bulunuyor: listeyi dolduran OnGetAsync ve silme işini yapan OnPostDeleteAsync. Oturum açmış bir editör, geçerli bir antiforgery token'ıyla şu isteği gönderiyor: POST /Manage/Books?handler=Archive. OnPostArchiveAsync diye bir method yok. Sence ne olur: 404 mü, 405 mi, yoksa sayfa sessizce yeniden mi çizilir? Bir de aynı isteği token olmadan, curl ile gönderdiğini düşün. Hangi status code'u beklersin? İki cevap da yazının içinde, gerçek koşudan.

@page: Dosyanın Kendisi Endpoint'tir

Serinin ilk yedi yazısında MVC (Model-View-Controller) tarafını kurduk: Areas/Admin/Controllers/BooksController, onun view'ları, BookFormViewModel ve PRG (Post-Redirect-Get) akışı. Şimdi aynı yönetim ekranını Razor Pages ile yazıyoruz ve iki modeli aynı projede yan yana tutuyoruz. MVC'deki admin area'sı /Admin/Books altında kalıyor, Razor Pages sürümü /Manage/Books altında yaşıyor.

Razor Pages'in temel fikri tek cümle: bir .cshtml dosyasının ilk satırına @page yazarsan dosya kendi başına bir endpoint olur. Arada controller yok; URL'i (Uniform Resource Locator) dosyanın Pages klasöründeki yeri belirliyor:

Pages/Manage/Books/Index.cshtml   ->  /Manage/Books, /Manage/Books/Index
Pages/Manage/Books/Create.cshtml  ->  /Manage/Books/Create
Pages/Manage/Books/Edit.cshtml    ->  /Manage/Books/Edit/{id:int}
Pages/Account/Login.cshtml        ->  /Account/Login

Index her klasörün varsayılan sayfası, o yüzden /Manage/Books doğrudan listeye düşüyor. Edit sayfasında ise route template'i dosyanın kendisine yazıyoruz:

@page "{id:int}"
@model Bookstore.Web.Pages.Manage.Books.EditModel
@{
    ViewData["Title"] = "Edit book";
}
<h1>Edit book</h1>
<partial name="_BookForm" for="Input" />

{id:int} constraint'i Web API (Application Programming Interface) serisinin routing yazısında gördüğümüz constraint'lerin aynısı. /Manage/Books/Edit/abc isteği handler'a hiç ulaşmıyor, routing seviyesinde eleniyor ve 404 dönüyor. @page her zaman dosyadaki ilk Razor directive'i olmalı. @model'in altına yazdığımda build şu hatayla durdu:

error RZ3906: The '@page' directive must precede all other elements defined in a Razor file.

Kurulum için Program.cs'te iki satır yeterli: service'lere AddRazorPages(), pipeline'a MapRazorPages(). Projemiz dotnet new mvc ile başladığı için AddControllersWithViews() zaten vardı; ikisi aynı routing altyapısını paylaşıyor ve çakışmıyor. Razor Pages layout'u ararken önce Pages/Shared'a, sonra Views/Shared'a baktığı için Pages/_ViewStart.cshtml içindeki Layout = "_Layout" satırı MVC tarafının _Layout.cshtml'ini bulur. İki tarafın görünümü aynı kalır.

PageModel ve Handler Method'ları

Sayfanın arkasındaki class PageModel'den türüyor ve dosya adının sonuna .cs eklenmiş halinde duruyor (Index.cshtml.cs). Controller'daki action'ların yerini handler method'ları alıyor:

namespace Bookstore.Web.Pages.Manage.Books;

public class IndexModel(IBookCatalog catalog, IBookAdminService admin) : PageModel
{
    [BindProperty(SupportsGet = true)]
    public string? Search { get; set; }

    [BindProperty(SupportsGet = true)]
    public int PageNumber { get; set; } = 1;

    [TempData]
    public string? StatusMessage { get; set; }

    public BookCatalogViewModel Catalog { get; private set; } = null!;

    public async Task OnGetAsync(CancellationToken ct)
    {
        Catalog = await catalog.ListAsync(Search, PageNumber, ct: ct);
    }

    public async Task<IActionResult> OnPostDeleteAsync(int id, CancellationToken ct)
    {
        bool deleted = await admin.DeleteAsync(id, ct);
        if (!deleted)
        {
            return NotFound();
        }

        StatusMessage = $"Book #{id} deleted.";
        return RedirectToPage(new { Search, PageNumber });
    }
}

İsimlendirme kuralı On + HTTP (Hypertext Transfer Protocol) verb'ü + isteğe bağlı handler adı + isteğe bağlı Async. OnGetAsync adsız GET handler'ı, OnPostDeleteAsync ise adı Delete olan POST handler'ı. Dependency'ler primary constructor ile geliyor; DI (Dependency Injection) controller'dakiyle birebir aynı çalışıyor, çünkü her istekte yeni bir IndexModel instance'ı oluşturuluyor.

View tarafında handler'ı asp-page-handler tag helper attribute'u seçiyor:

<form method="post" asp-page-handler="Delete" asp-route-id="@book.Id">
    <button type="submit">Delete</button>
</form>

Render edilen HTML (HyperText Markup Language) şöyle (token kısaltıldı):

<form method="post" action="/Manage/Books?id=1&amp;handler=Delete">
    <button type="submit">Delete</button>
<input name="__RequestVerificationToken" type="hidden" value="CfDJ8EINq_wm4TFC..." /></form>

Handler adı query string'e handler=Delete olarak gidiyor. URL'de görünmesini istemezsen @page "{handler?}" ile route segment'ine taşıyabilirsin; bu durumda adres /Manage/Books/Delete?id=1 olur. Dikkat edilecek yer şurası: form tag helper, method="post" gördüğü anda antiforgery token'ını gizli input olarak ekliyor ve Razor Pages bu token'ı GET, HEAD, OPTIONS ve TRACE dışındaki isteklerde varsayılan olarak doğruluyor. MVC'de bunun için [ValidateAntiForgeryToken] ya da global AutoValidateAntiforgeryTokenAttribute gerekiyordu; Razor Pages'te ekstra bir şey yazmıyorsun.

Baştaki ikinci sorunun cevabı tam olarak bu. Token'sız curl isteği handler'a hiç ulaşmıyor:

$ curl -i -X POST "http://localhost:17810/Manage/Books?id=6&handler=Delete"
HTTP/1.1 400 Bad Request
Content-Length: 0

Gövdesi boş bir 400. Template'in appsettings.Development.json'ı Microsoft.AspNetCore kategorisini Warning'e çektiği için log'da da hiçbir şey görünmüyor. Bu kategoriyi Information'a indirince sebep ortaya çıkıyor:

info: Microsoft.AspNetCore.Mvc.ViewFeatures.Filters.AutoValidateAntiforgeryTokenAuthorizationFilter[1]
      Antiforgery token validation failed. The required antiforgery cookie ".AspNetCore.Antiforgery.k61VYwP4eJ0" is not present.

Filter'ın adındaki AutoValidate kelimesine dikkat: Razor Pages'in kendiliğinden eklediği şey, MVC'de elle eklediğin global filter'ın aynısı. Bu yüzden ilk kez curl ya da Postman ile Razor Pages endpoint'i deneyen herkes bir süre "neden 400 alıyorum" diye bakıyor.

Handler Seçimi Nasıl Çalışıyor?

İlk soruya gelelim. Framework önce HTTP method'una uyan handler'ları topluyor, sonra handler değerine bakıyor. Geçerli token ve oturumla ?handler=Archive gönderdiğimde gerçek sonuç şu oldu:

HTTP/1.1 500 Internal Server Error

warn: Bookstore.Web.Filters.AuditPageFilter[0]
      No handler matched POST /Manage/Books/Index
fail: Microsoft.AspNetCore.Diagnostics.DeveloperExceptionPageMiddleware[1]
      An unhandled exception has occurred while executing the request.
      System.NullReferenceException: Object reference not set to an instance of an object.
         at Bookstore.Web.Pages.Manage.Books.Pages_Manage_Books_Index.ExecuteAsync() in .../Pages/Manage/Books/Index.cshtml:line 20

404 değil, 405 değil: 500. Eşleşen handler bulunamayınca Razor Pages hata vermiyor, hiçbir handler çalıştırmadan sayfayı render ediyor. Catalog property'si yalnız OnGetAsync içinde dolduğu için @foreach (... in Model.Catalog.Books) satırı patlıyor. MVC'de olmayan bir action'a istek 404 alırdı; Razor Pages'te sayfanın kendisi var, eksik olan yalnızca handler.

Bunun tersi de ilginç: GET /Manage/Books?handler=Delete&id=1 isteği 200 dönüyor ve listeyi gösteriyor. OnGetDelete diye bir method olmadığı için adsız OnGetAsync seçiliyor. Adsız handler, adı tutmayan istekler için bir yedek gibi davranıyor. Pratik sonuç: sayfanın ihtiyaç duyduğu veriyi yalnız tek bir handler'a emanet etme ya da Catalog gibi property'leri null olmayacak şekilde başlat. Biz AuditPageFilter içinde HandlerMethod is null durumunu warn seviyesinde logluyoruz; üretimde bu satır, birinin form'daki handler adını yanlış yazdığını ilk gösteren şey oluyor.

[BindProperty], SupportsGet ve TempData

MVC'de input action parametresi olarak gelir. Razor Pages'te genelde PageModel property'sine bind edilir: [BindProperty] işaretli property, form değerleriyle dolar. Önemli varsayılan şu: [BindProperty] yalnız GET dışındaki isteklerde çalışır. GET isteğinde query string'den bind etmek istiyorsan SupportsGet = true ile açıkça izin veriyorsun. Microsoft'un dokümanı bunu güvenlik gerekçesiyle opt-in yapıyor; query string'i kullanıcı istediği gibi yazabilir ve "GET'te de bind olsun" kararını sen bilinçli vermelisin. Arama kutusu ve sayfa numarası gibi zararsız değerler için uygun; entity'nin kendisi için değil.

Sayfa numarası property'sine Page yerine PageNumber dememin bir nedeni var. PageModel'in zaten Page() adında bir method'u var ve property'ye bu adı verdiğimde derleyici şu uyarıyı veriyor:

warning CS0108: 'PModel.Page' hides inherited member 'PageModel.Page()'. Use the new keyword if hiding was intended.

[TempData] attribute'u property'yi TempData'ya bağlıyor. Silme handler'ı StatusMessage'ı yazıp RedirectToPage dönüyor; değer cookie tabanlı TempData provider'ı ile bir sonraki GET isteğine taşınıyor ve okunduğu anda siliniyor:

HTTP/1.1 302 Found
Location: /Manage/Books?Search=Draft&PageNumber=1
Set-Cookie: .AspNetCore.Mvc.CookieTempDataProvider=CfDJ8EINq_wm4TFC...; path=/; samesite=lax; httponly

<p class="alert alert-success">Book #6 deleted.</p>

RedirectToPage(new { Search, PageNumber }) mevcut sayfaya route değerleriyle döndüğü için kullanıcı silmeden sonra aynı arama sonucunu ve aynı sayfayı görmeye devam ediyor. PRG akışının mantığını serinin form yazısında anlatmıştık; burada değişen yalnızca kullandığımız helper'ların adı: RedirectToAction yerine RedirectToPage, View() yerine Page().

Create ve Edit: Form, Validation ve Page()

Yeni kitap sayfası, MVC'deki Create GET ve POST action çiftinin tek bir dosyaya toplanmış hali:

public class CreateModel(IBookAdminService admin) : PageModel
{
    [BindProperty]
    public BookFormViewModel Input { get; set; } = new();

    [TempData]
    public string? StatusMessage { get; set; }

    public async Task OnGetAsync(CancellationToken ct)
    {
        Input.Authors = await admin.GetAuthorOptionsAsync(ct);
    }

    public async Task<IActionResult> OnPostAsync(CancellationToken ct)
    {
        if (!ModelState.IsValid)
        {
            // select list'i POST'ta tekrar doldurmazsak dropdown boş gelir
            Input.Authors = await admin.GetAuthorOptionsAsync(ct);
            return Page();
        }

        int id = await admin.CreateAsync(Input, ct);
        StatusMessage = $"Book #{id} created.";
        return RedirectToPage("Index");
    }
}

BookFormViewModel'i MVC tarafından olduğu gibi kullanıyoruz. Validation attribute'ları, client-side validation için üretilen data-val-* attribute'ları ve ModelState davranışı validation yazısında anlattığımızla aynı. Form'u iki sayfa arasında paylaşmak için _BookForm.cshtml partial'ını for="Input" ile çağırıyoruz. model="Model.Input" yazsaydık input'lar name="Title" diye render edilirdi; for kullanınca property yolu prefix olarak korunuyor:

<input type="text" data-val="true" data-val-length="The field Title must be a string with a maximum length of 120." data-val-length-max="120" data-val-required="The Title field is required." id="Input_Title" maxlength="120" name="Input.Title" value="Rivers of Ink" />

Prefix'siz isimler bu sayfada şans eseri yine çalışıyordu: model binder Input. prefix'iyle değer bulamayınca boş prefix'e düşüyor. Ama aynı sayfaya Search gibi başka bir bind edilen property eklediğin gün isimler çakışabilir. Geçersiz bir POST'ta sayfa Page() ile aynı URL'de yeniden çiziliyor ve hata mesajları doğru alanların altına düşüyor:

<span class="field-validation-error" data-valmsg-for="Input.Title" data-valmsg-replace="true">The Title field is required.</span>
<span class="field-validation-error" data-valmsg-for="Input.Isbn" data-valmsg-replace="true">ISBN must be 13 digits.</span>
<span class="field-validation-error" data-valmsg-for="Input.Price" data-valmsg-replace="true">The field Price must be between 0.01 and 10000.</span>

Page Convention'ları: AuthorizeFolder ve Page Filter'lar

Controller'da yetkiyi genelde class'ın üstüne [Authorize] yazarak verirsin. Razor Pages'te de PageModel'e [Authorize] koyabilirsin, ama asıl güçlü araç convention'lar: bir klasörün tamamına Program.cs'ten tek satırla kural uygulamak.

builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
    .AddCookie(options => options.LoginPath = "/Account/Login");
builder.Services.AddScoped<AuditPageFilter>();
builder.Services.AddRazorPages(options =>
{
    // /Manage altındaki her sayfa oturum ister
    options.Conventions.AuthorizeFolder("/Manage");
    // aynı klasöre DI'dan gelen bir page filter
    options.Conventions.AddFolderApplicationModelConvention("/Manage",
        model => model.Filters.Add(new ServiceFilterAttribute(typeof(AuditPageFilter))));
});

Yeni bir sayfa Pages/Manage altına eklendiği anda korumalı oluyor; birinin [Authorize] yazmayı unutma ihtimali ortadan kalkıyor. Oturumsuz istek login sayfasına yönleniyor:

$ curl -i http://localhost:17810/Manage/Books/Edit/3
HTTP/1.1 302 Found
Location: http://localhost:17810/Account/Login?ReturnUrl=%2FManage%2FBooks%2FEdit%2F3

Login sayfasının kendisi demo amaçlı, sabit bir kullanıcıyla cookie oluşturuyor. Authentication'ın (kimlik doğrulama) ve cookie ayarlarının derinliği ilerideki Authentication ve Authorization serisinin konusu. Burada bilmen gereken bir kural daha var: AuthorizeFolder("/Private") ile korunan klasörde AllowAnonymousToPage("/Private/Public") ile tek bir sayfayı açabilirsin, ama tersi çalışmıyor. Anonim erişime açılmış bir klasörün içindeki sayfaya AuthorizePage uygularsan AllowAnonymousFilter öncelik kazanıyor ve sayfa açık kalıyor.

Page filter'lar MVC'deki action filter'ların Razor Pages karşılığı. Web API serisinin filter yazısındaki action filter'lar handler method'larına uygulanmıyor; Razor Pages'in kendi interface'leri var:

namespace Bookstore.Web.Filters;

public class AuditPageFilter(ILogger<AuditPageFilter> logger) : IAsyncPageFilter
{
    public Task OnPageHandlerSelectionAsync(PageHandlerSelectedContext context)
    {
        // handler seçildi ama model binding henüz çalışmadı
        if (context.HandlerMethod is null)
        {
            logger.LogWarning("No handler matched {Method} {Path}", context.HttpContext.Request.Method,
                context.ActionDescriptor.ViewEnginePath);
        }

        return Task.CompletedTask;
    }

    public async Task OnPageHandlerExecutionAsync(PageHandlerExecutingContext context, PageHandlerExecutionDelegate next)
    {
        PageHandlerExecutedContext executed = await next();
        logger.LogInformation("{User} ran {Handler} on {Page} -> {Result}",
            context.HttpContext.User.Identity?.Name ?? "anonymous",
            context.HandlerMethod?.MethodInfo.Name,
            context.ActionDescriptor.ViewEnginePath,
            executed.Result?.GetType().Name ?? "PageResult");
    }
}

Bir oturumdaki gerçek log satırları:

info: Bookstore.Web.Filters.AuditPageFilter[0]
      ada ran OnGetAsync on /Manage/Books/Index -> PageResult
info: Bookstore.Web.Filters.AuditPageFilter[0]
      ada ran OnPostDeleteAsync on /Manage/Books/Index -> RedirectToPageResult
warn: Bookstore.Web.Filters.AuditPageFilter[0]
      No handler matched POST /Manage/Books/Index
info: Bookstore.Web.Filters.AuditPageFilter[0]
      ada ran OnPostAsync on /Manage/Books/Edit -> PageResult

İki sınırlamayı bil. Birincisi, page filter tek bir handler method'una uygulanamıyor; sayfanın tamamına, bir klasöre ya da global olarak uygulanıyor. Silme handler'ına özel bir şey gerekiyorsa filter içinde context.HandlerMethod?.Name'e bakıyorsun. İkincisi, aynı class'ta hem IPageFilter hem IAsyncPageFilter implement edersen framework yalnızca async olanı çağırıyor; birini seç.

Dosya Düzeni: MVC mi, Razor Pages mi?

Aynı CRUD (Create, Read, Update, Delete) ekranının iki modeldeki dosya dağılımı:

İşMVC (Admin area)Razor Pages (Manage)
ListeBooksController.Index + Views/Books/Index.cshtmlPages/Manage/Books/Index.cshtml + .cs
EkleCreate GET/POST action'ları + Create.cshtmlCreate.cshtml + CreateModel (OnGetAsync, OnPostAsync)
DüzenleEdit GET/POST action'ları + Edit.cshtmlEdit.cshtml (@page "{id:int}") + EditModel
SilDelete POST action'ıOnPostDeleteAsync named handler
URL'i belirleyenconventional route ya da attribute routedosyanın klasördeki yeri + @page template'i
Yetki[Authorize] controller/action üstündeAuthorizeFolder convention'ı veya [Authorize]

MVC'de bir ekranı değiştirmek için genelde üç yere gidersin: controller, view, ViewModel. Razor Pages'te iki dosya yan yana durur ve sayfanın bütün davranışı orada. Microsoft'un UI (User Interface) teknolojisi seçimini anlatan dokümanı da Razor Pages'in faydalarını sayarken sayfaya özgü mantığın ve ViewModel'lerin kendi klasöründe, MVC'ye göre daha basit bir düzenle durmasını öne çıkarıyor. Aynı doküman yeni projeler için öncelikle Blazor'ı öneriyor; bu tercihin ne zaman mantıklı olduğunu serinin ilk yazısında tartışmıştık.

Benim pratikteki ayrımım şöyle: ekranlar birbirinden bağımsızsa ve her biri "bir form, bir liste" kadar basitse (yönetim paneli, iç araçlar, ayarlar sayfaları) Razor Pages daha az tören demek. Aynı kaynağın üzerinde çok sayıda action varsa, aynı controller hem HTML hem JSON (JavaScript Object Notation) dönüyorsa ya da ekip Web API tarafındaki controller alışkanlıklarını korumak istiyorsa MVC daha doğal. İkisini aynı projede karıştırmak tamamen meşru; bizim Bookstore'umuzda public katalog MVC, /Manage Razor Pages.

Üretimde Dikkat

Handler'ları ince tut. PageModel, controller'ın yaşadığı "fat controller" sorununu aynen yaşayabilir. Bizim örnekte veri erişimi IBookCatalog ve IBookAdminService içinde; PageModel yalnız HTTP'yi service çağrısına çeviriyor. Web API serisinin katmanlı yapı yazısındaki ayrım burada da geçerli.

Named handler'ları gözlemle. Yukarıdaki No handler matched uyarısı gibi bir log satırı, yanlış yazılmış bir asp-page-handler değerini ya da eski bir JavaScript'in hâlâ kaldırılmış bir handler'a istek attığını gösterir. Bu istekler, sayfa veriye bağlıysa 500'e, değilse fark edilmeden boş bir sayfaya dönüşüyor.

[BindProperty(SupportsGet = true)]'yu yalnızca filtre gibi zararsız alanlara aç. Genel bir kural olarak bind edilen her property, kullanıcı tarafından değiştirilebilir bir giriş noktası. Overposting'in mantığı form yazısında anlatıldı; Razor Pages'te risk biraz daha büyük, çünkü [BindProperty] class seviyesinde ([BindProperties]) de verilebiliyor ve o zaman PageModel'in bütün public property'leri bind edilir hale geliyor.

Antiforgery'yi kapatma. Razor Pages'te bir handler'ı JavaScript'ten (fetch ya da htmx ile) çağırıyorsan token'ı header olarak göndermek gerekir; bunu htmx yazısında yapacağız. [IgnoreAntiforgeryToken] ile "şimdilik çalışsın" demek, CSRF (Cross-Site Request Forgery) korumasını o sayfada tamamen kaldırmak demek.

Sık Düşülen Tuzaklar

Veriyi yalnız OnGet'te doldurup POST'ta Page() dönmek. Validation hatasında sayfa yeniden render edilir ama OnGetAsync çalışmaz. Dropdown'ın listesi, sayfadaki tablo, sidebar verisi null kalır. Ya POST handler'ında yeniden doldur ya da ortak bir private method'a taşı.

Property'ye Page adını vermek. CS0108 uyarısıyla Page() method'unu gizler ve return Page(); satırı artık beklediğin şeyi çağırmaz. PageNumber, CurrentPage gibi bir ad seç.

Token'sız istekle test edip 400'ü bug sanmak. Razor Pages POST'u varsayılan olarak doğruluyor. Integration testlerinde önce GET ile token'ı ve cookie'yi alıp sonra POST etmen gerekiyor.

Klasör convention'ı ile [AllowAnonymous]'u ters sırada kullanmak. Açık klasörün içindeki tek sayfayı kapatamazsın; gerekirse sayfayı korunan bir klasöre taşı.

Sayfa adındaki eğik çizgiyi önemsememek. RedirectToPage("Index") aynı klasördeki Index'e gider (Location: /Manage/Books). RedirectToPage("/Index") ise kökteki Pages/Index.cshtml'i arar. Bizim projede kök sayfa MVC'nin HomeController'ı ve öyle bir Razor Page yok. Exception beklersin, ama gerçek yanıt şu:

HTTP/1.1 302 Found
Location: /?page=%2FIndex

URL üretimi eşleşen bir sayfa bulamayınca conventional route'a düşüyor ve page değerini query string'e ekliyor. Kullanıcı ana sayfaya gidiyor, kimse bir şey fark etmiyor, ta ki birisi adres çubuğundaki tuhaf ?page= parametresini sorana kadar.

Küçük Bir Deneme

★☆☆ Index.cshtml'in ilk satırını @page "{handler?}" yap. Render edilen silme form'unun action attribute'u nasıl değişiyor? curl ile hem eski hem yeni adresi dene.

★★☆ Catalog property'sini null! yerine boş bir BookCatalogViewModel ile başlat ve ?handler=Archive isteğini tekrar gönder. Artık 500 yerine ne görüyorsun? Bu davranış sence daha mı iyi, yoksa hatayı mı saklıyor? AuditPageFilter'ı, handler bulunamadığında 404 dönecek şekilde değiştir (OnPageHandlerExecutionAsync içinde context.Result ataması).

★★★ /Manage altına Authors klasörü ekleyip yazar CRUD'unu yaz. Bu sefer AuthorizeFolder("/Manage", "EditorsOnly") ile bir policy kullan ve EditorsOnly policy'sini claim kontrolüyle tanımla. Oturumu olan ama claim'i olmayan kullanıcı hangi status code'u alıyor?

Bu yazıda ekranlarımızın server tarafını iki modelle de yazdık. Tarayıcıya giden her sayfa CSS (Cascading Style Sheets) ve JavaScript dosyalarını da çekiyor ve _Layout'taki o asp-append-version attribute'larının ne yaptığını henüz konuşmadık. Bir sonraki yazıda static asset'lere geçiyoruz: MapStaticAssets, fingerprint'li URL'ler, build-time compression, CSS isolation ve client library yönetimi.