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

htmx ile Razor Pages: SPA Yazmadan Etkileşimli Arayüz, Partial Yanıtlar ve Antiforgery

Mert Özen 8 Kas 2026 10 dk 2 görüntülenme
htmx ile Razor Pages: SPA Yazmadan Etkileşimli Arayüz, Partial Yanıtlar ve Antiforgery

htmx ile Razor Pages handler'ları HX-Request header'ına göre tam sayfa ya da yalnız partial döner; canlı arama, sonsuz scroll ve satır içi düzenleme JSON ve SPA olmadan çalışır. Antiforgery token'ını hx-headers ile taşıyor, Vary ve history restore tuzaklarını, htmx 2'nin 4xx davranışını ve htmx 4 farklarını gerçek isteklerle gösteriyoruz.

Sürüm notu: Bu yazıdaki kodlar .NET 10 (SDK 10.0.402) ve htmx 2.0.11 ile derlenip denenmiştir.

Önce Tahmin Et

Yönetim panelindeki kitap listesine canlı arama ekliyoruz. Kullanıcı arama kutusuna "Clean" yazıyor; htmx sunucudan yalnız tablo satırlarını istiyor, tabloyu güncelliyor ve adres çubuğunu /Manage/Books?Search=Clean yapıyor. Sonra kullanıcı sayfayı yeniliyor (F5). Aynı URL'e (Uniform Resource Locator) giden bu istek ne döner: tam sayfa mı, yoksa layout'suz, <html> etiketi bile olmayan çıplak bir <tr> listesi mi? Bir de şunu düşün: htmx ile gönderilen bir POST'ta form yok, gizli __RequestVerificationToken input'u da yok. Razor Pages'in antiforgery doğrulaması bu isteğe ne yapar?

htmx Ne Yapıyor?

htmx, HTML (HyperText Markup Language) attribute'larıyla sayfayı yenilemeden istek gönderen ve gelen HTML parçasını sayfanın bir yerine yerleştiren, minify edilmiş hali 52 KB, gzip ile yaklaşık 17 KB olan bir JavaScript library'si. Fikir şu: sunucu zaten HTML üretiyor; JSON (JavaScript Object Notation) dönüp tarayıcıda yeniden HTML'e çevirmek yerine sunucu doğrudan HTML parçası dönsün. Dört temel attribute çoğu işi görüyor:

  • hx-get, hx-post: hangi adrese hangi verb'le istek gidecek.
  • hx-trigger: istek ne zaman gidecek (click, input changed delay:300ms, revealed...).
  • hx-target: gelen HTML nereye konacak: bir CSS (Cascading Style Sheets) selector'ü ya da closest tr gibi göreli bir ifade.
  • hx-swap: nasıl konacak (innerHTML varsayılan, outerHTML, beforeend, afterend...).

Razor Pages bu modele çok iyi oturuyor: her handler zaten bir IActionResult dönüyor ve PageModel.Partial() bir partial view'ı layout'suz render ediyor. Static asset yazısında htmx'i LibMan ile wwwroot/lib/htmx altına koymuştuk; layout'a script'i eklemek yeterli:

<script src="~/lib/htmx/dist/htmx.min.js"></script>

MapStaticAssets sayesinde bu satır /lib/htmx/dist/htmx.min.6dpfg1qi0s.js olarak render ediliyor; fingerprint'li ve immutable.

Canlı Arama: Aynı Handler, İki Yanıt

Razor Pages yazısındaki listeyi htmx'e uyarlıyoruz. Arama kutusu artık bir form'un parçası değil, kendi isteğini kendisi gönderiyor:

<input type="search" name="Search" value="@Model.Search" placeholder="Search by title"
       hx-get="@Url.Page("Index")" hx-trigger="input changed delay:300ms, search"
       hx-target="#book-rows" hx-push-url="true" hx-indicator="#loading" />
<span id="loading" class="htmx-indicator">Searching...</span>
<a asp-page="Create">New book</a>

<table class="table">
    <tbody id="book-rows">
        <partial name="_BookRows" model="Model.Catalog" />
    </tbody>
</table>

input changed delay:300ms: değer değiştiğinde, ama kullanıcı 300 ms yazmayı bıraktıktan sonra istek gönder. "Clean" yazan biri beş istek değil bir istek üretiyor. search event'i, kutudaki çarpı işaretine basıp temizlemeyi de yakalıyor. Input'un name'i ve değeri isteğe otomatik ekleniyor, yani istek GET /Manage/Books?Search=Clean. hx-push-url="true" bu adresi tarayıcı geçmişine ve adres çubuğuna yazıyor.

Şimdi sunucu tarafı. Aynı OnGetAsync, isteği kimin gönderdiğine göre iki farklı şey dönüyor:

public class IndexModel(IBookCatalog catalog, IBookAdminService admin) : PageModel
{
    private const int PageSize = 3;

    [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!;

    // htmx her istekte HX-Request gönderir; history restore ise tam sayfa ister
    private bool IsHtmxFragmentRequest =>
        Request.Headers["HX-Request"] == "true" && Request.Headers["HX-History-Restore-Request"] != "true";

    public async Task<IActionResult> OnGetAsync(CancellationToken ct)
    {
        Response.Headers.Vary = "HX-Request";
        Catalog = await catalog.ListAsync(Search, PageNumber, PageSize, ct);
        return IsHtmxFragmentRequest ? Partial("_BookRows", Catalog) : Page();
    }
}

htmx her isteğe HX-Request: true header'ı ekliyor. Header varsa yalnız satırları, yoksa tam sayfayı dönüyoruz. Baştaki ilk sorunun cevabı burada: sayfa yenilendiğinde tarayıcı normal bir GET gönderiyor, HX-Request yok ve kullanıcı tam sayfayı alıyor. Gerçek istekler:

$ curl -s -b cookies.txt -H 'HX-Request: true' "http://localhost:17810/Manage/Books?Search=Clean"
    <tr id="book-4">
    <td>Clean Lines</td>
    <td>Ada Lane</td>
    <td>39.99</td>
    <td>
        <button hx-get="/Manage/Books?id=4&amp;handler=EditRow"
                hx-target="closest tr" hx-swap="outerHTML">Edit price</button>
        <button hx-post="/Manage/Books?id=4&amp;handler=Delete"
                hx-target="closest tr" hx-swap="outerHTML"
                hx-confirm="Delete 'Clean Lines'?">Delete</button>
    </td>
</tr>

$ curl -s -b cookies.txt "http://localhost:17810/Manage/Books?Search=Clean" | wc -c
    5529

Parça yalnız bir satır; tam sayfa 5.529 byte. İki ayrıntı önemli. Birincisi Vary: HX-Request: aynı URL header'a göre iki farklı içerik dönüyorsa bunu cache'lere söylemek zorundasın. Söylemezsen bir proxy ya da tarayıcı cache'i, htmx için üretilen çıplak parçayı daha sonra tam sayfa isteyen kullanıcıya sunabilir. htmx'in dokümanı da bu durumda Vary kullanmayı öneriyor.

İkincisi HX-History-Restore-Request. htmx geri tuşuna basıldığında sayfanın önceki halini kendi history cache'inden (varsayılan 10 kayıt) geri yüklüyor. Cache'te bulamazsa aynı URL'e HX-Request: true ve HX-History-Restore-Request: true ile istek atıyor ve bu sefer tam sayfa bekliyor. Yalnız HX-Request'e bakan bir handler bu durumda satırları döner ve kullanıcı geri tuşunda layout'suz bir tabloyla karşılaşır. Bu header'la gelen istek bizim kodda tam sayfayı alıyor:

$ curl -s -b cookies.txt -H 'HX-Request: true' -H 'HX-History-Restore-Request: true' "http://localhost:17810/Manage/Books?Search=cl" | wc -c
    5080

Bu istekteki cl araması bir şey daha gösteriyor: sonuç boş. "Clean Lines"ın içinde "Cl" var, ama Entity Framework Core'un (EF Core) SQLite provider'ı Contains'i büyük/küçük harfe duyarlı instr ile çeviriyor. Boş sonuç için _BookRows bir "No books found." satırı dönüyor; boş bir yanıt dönseydik tablo sessizce boşalırdı ve kullanıcı aramanın çalışıp çalışmadığını anlayamazdı.

Sonsuz Scroll: revealed Trigger'ı

Sayfa boyutunu bilerek 3 yaptım ki kaydırma görünsün. _BookRows partial'ı, sonraki sayfa varsa listenin sonuna bir "nöbetçi" satır ekliyor:

@model BookCatalogViewModel
@if (Model.Books.Count == 0)
{
    <tr><td colspan="4">No books found.</td></tr>
}
@foreach (BookListItemViewModel book in Model.Books)
{
    <partial name="_BookRow" model="book" />
}
@if (Model.Page < Model.TotalPages)
{
    <tr hx-get="@Url.Page("Index", new { Model.Search, PageNumber = Model.Page + 1 })"
        hx-trigger="revealed" hx-swap="outerHTML">
        <td colspan="4">Loading more...</td>
    </tr>
}

revealed trigger'ı, eleman ekranda görünür olduğu anda bir kez tetikleniyor. Gelen yanıt nöbetçi satırın yerine geçiyor (outerHTML): üç yeni satır ve gerekiyorsa yeni bir nöbetçi. Render edilen nöbetçi hx-get="/Manage/Books?PageNumber=2"; aynı OnGetAsync handler'ı, aynı partial. Sonsuz scroll için ayrı bir endpoint, ayrı bir JSON sözleşmesi ya da client-side template yazmadık. Arama filtresi de Model.Search ile nöbetçinin URL'ine taşındığı için filtrelenmiş listenin ikinci sayfası doğru geliyor.

Satır İçi Düzenleme ve Antiforgery

Her satırda bir "Edit price" butonu var. Tıklanınca satır, input'lu bir düzenleme satırına dönüşüyor; kaydedilince geri normal satıra:

@model BookPriceEditViewModel
<tr id="book-@Model.Id">
    <td>@Model.Title</td>
    <td colspan="2">
        <input name="price" value="@Model.Price" aria-label="Price" autofocus />
        @if (Model.Error is not null)
        {
            <span class="text-danger">@Model.Error</span>
        }
    </td>
    <td>
        <button hx-post="@Url.Page("Index", "SaveRow", new { id = Model.Id })" hx-include="closest tr"
                hx-target="closest tr" hx-swap="outerHTML">Save</button>
        <button hx-get="@Url.Page("Index", "Row", new { id = Model.Id })"
                hx-target="closest tr" hx-swap="outerHTML">Cancel</button>
    </td>
</tr>

Bir <form> yok; hx-include="closest tr" satırdaki input'ları isteğe ekliyor. Handler'lar Razor Pages yazısında gördüğümüz named handler'lar:

public async Task<IActionResult> OnGetEditRowAsync(int id, CancellationToken ct)
{
    BookListItemViewModel? book = await catalog.GetListItemAsync(id, ct);
    return book is null ? NotFound() : Partial("_BookEditRow", new BookPriceEditViewModel(book.Id, book.Title, book.Price));
}

public async Task<IActionResult> OnPostSaveRowAsync(int id, decimal price, CancellationToken ct)
{
    BookFormViewModel? form = await admin.GetFormAsync(id, ct);
    if (form is null)
    {
        return NotFound();
    }

    if (price is <= 0 or > 10000)
    {
        // htmx 2, 4xx yanıtlarını swap etmez: hatayı 200 ile dön
        return Partial("_BookEditRow", new BookPriceEditViewModel(id, form.Title, price, "Price must be between 0.01 and 10000."));
    }

    form.Price = price;
    await admin.UpdateAsync(id, form, ct);
    return await OnGetRowAsync(id, ct);
}

Form olmadığı için gizli token input'u da yok. Baştaki ikinci sorunun cevabı, Razor Pages yazısındakiyle aynı:

$ curl -s -i -b cookies.txt -H 'HX-Request: true' -X POST "http://localhost:17810/Manage/Books?id=2&handler=SaveRow" -d "price=21.5"
HTTP/1.1 400 Bad Request

Antiforgery token'ı form alanı yerine bir header'da da kabul ediliyor; varsayılan header adı RequestVerificationToken. htmx'in hx-headers attribute'u bir elemanın altındaki bütün isteklere header ekliyor, o yüzden token'ı bir kez <body>'ye koymak yetiyor:

@inject Microsoft.AspNetCore.Antiforgery.IAntiforgery Antiforgery
@{
    string? requestToken = Antiforgery.GetAndStoreTokens(Context).RequestToken;
}
<!DOCTYPE html>
<html lang="en">
<head>
    ...
</head>
<body hx-headers='{"RequestVerificationToken": "@requestToken"}'>

GetAndStoreTokens hem request token'ı üretiyor hem de eşi olan cookie'yi yanıta yazıyor; doğrulama ikisini karşılaştırıyor. Header'la gönderilen istek:

$ curl -s -i -b cookies.txt -H 'HX-Request: true' -H "RequestVerificationToken: $TOKEN" -X POST "http://localhost:17810/Manage/Books?id=2&handler=SaveRow" -d "price=21.5"
HTTP/1.1 200 OK
    <td>Rivers of Ink</td>
    <td>Alan Brooks</td>
    <td>21.50</td>

$ curl -s -i ... -d "price=0"
HTTP/1.1 200 OK
            <span class="text-danger">Price must be between 0.01 and 10000.</span>

Geçersiz fiyatta 200 dönmemizin sebebi htmx 2'nin varsayılan davranışı: 4xx ve 5xx yanıtları hata sayılıyor ve swap edilmiyor. 422 dönseydik kullanıcı butona basar, hiçbir şey olmazdı. Silme de aynı mantıkla çalışıyor: OnPostDeleteAsync boş bir 200 (Content(string.Empty)) dönüyor ve hx-swap="outerHTML" satırı boş içerikle değiştirip kaldırıyor. hx-confirm silmeden önce tarayıcının onay penceresini gösteriyor.

Bu akışı curl'ün yanında gerçek bir tarayıcıda da denedim: "Clean" yazınca adres çubuğu /Manage/Books?Search=Clean oldu, ikinci sayfa nöbetçi satır görünür olur olmaz kendiliğinden yüklendi, fiyatı 35.00 yapıp "Save"e basınca POST /Manage/Books?id=4&handler=SaveRow 200 döndü ve satır 35.00 ile yenilendi. Token'ı body'deki hx-headers taşıdı.

Ne Zaman Yeterli, Ne Zaman Değil?

htmx'in güçlü olduğu yer, etkileşimin "sunucuya sor, HTML'i yerleştir" şeklinde olduğu ekranlar: arama ve filtre, sayfalama, satır içi düzenleme, sekmeler, modal içerikleri, bildirim sayacı. Yönetim panelleri ve iç uygulamalar bu tanımın içine çoğunlukla sığıyor. Bütün iş mantığı ve validation sunucuda kalıyor, iki ayrı model (C# DTO (Data Transfer Object) ve TypeScript tipi) senkron tutulmuyor, deploy tek bir uygulama.

Sınırlar da net. Her etkileşim bir round trip; sürükle-bırak bir kanban panosu, offline çalışma, yoğun client-side state (çok adımlı bir sihirbazın bütün adımlarını sunucuya gitmeden yönetmek) ya da bir grafik editörü htmx'le zorlanır. Aynı veriyi mobil uygulama da tüketecekse bir Web API (Application Programming Interface) zaten yazılacak demektir; o noktada Angular gibi bir SPA (Single-Page Application) framework'ü ya da Blazor daha doğal. İkisi bir arada da olur: panelin çoğu htmx'le, tek bir ağır ekran ayrı bir component'le. Karar soruları serinin ilk yazısındaki tabloyla aynı.

Üretimde Dikkat

Erişilebilirlik htmx'in otomatik çözdüğü bir şey değil. Ekran okuyucu, içeriği değişen tabloyu kendiliğinden duyurmuyor; sonuç bölgesine aria-live="polite" eklemek ve odak yönetimini düşünmek gerekiyor. Düzenleme satırındaki autofocus'u htmx swap'ten sonra uyguluyor, böylece klavye kullanıcısı doğrudan input'a düşüyor. Butonların gerçek <button> olması da klavyeyle erişim için önemli; <div hx-get> ile yazılmış "buton"a Tab ile ulaşılmaz.

hx-push-url kullanıyorsan, push ettiğin her URL tek başına açıldığında anlamlı bir tam sayfa dönmeli. Bizim örnekte ?Search=Clean doğrudan açılınca filtrelenmiş tam sayfa geliyor. Handler'ın sorgu parametrelerinden durumu kurabilmesi bunun için şart.

Log ve metriklerde fragment isteklerini ayır. Aynı endpoint artık iki tür yanıt dönüyor; yanıt süresi ve boyutu dağılımları ikiye bölünüyor. HX-Request header'ını log scope'una eklemek analiz sırasında işe yarıyor.

htmx 4.0, Ağustos 2026'da yayımlandı, ama npm'deki latest etiketi bu yazıyı yazarken hâlâ 2.0.11'i gösteriyor; 4.x next etiketinde. Migration guide'a göre 4.x XHR (XMLHttpRequest) yerine fetch() kullanıyor, attribute'lar artık örtük olarak miras alınmıyor (hx-headers:inherited gibi açık işaret gerekiyor), 4xx/5xx yanıtları varsayılan olarak swap ediliyor ve history için localStorage cache'i kalkıyor. Body'deki hx-headers ve 200 dönen validation hatası bu farklardan doğrudan etkileniyor; sürüm yükseltirken bu yazıdaki iki kararı yeniden gözden geçir.

Sık Düşülen Tuzaklar

HX-Request'e bakıp Vary header'ını unutmak. Cache'lenen bir parça tam sayfa yerine sunulabilir; hata nadiren ve yalnız cache'li ortamda çıkar.

History restore isteğine de parça dönmek. Geri tuşu, cache'te kayıt yoksa layout'suz bir tablo gösterir.

Validation hatasında 4xx dönmek. htmx 2 bu yanıtları swap etmez; kullanıcı hiçbir geri bildirim almaz. Ya 200 ile hata HTML'i dön ya da htmx:responseError event'ini dinleyip ayrıca ele al.

Token'ı her isteğe elle eklemeye çalışmak ya da [IgnoreAntiforgeryToken] ile kapatmak. Tek bir hx-headers yeterli.

hx-boost ile body'yi değiştirmeyen sayfa geçişlerinde body'deki hx-headers'a güvenmek. htmx'in dokümanına göre boost edilen gezinmede <html> ve <body> güncellenmiyor; token'ı değiştirilen bir elemanın üstüne koymak gerekiyor.

Küçük Bir Deneme

★☆☆ Sonuç bölgesine (tbody) aria-live="polite" ekle ve macOS'ta VoiceOver ya da Windows'ta NVDA (NonVisual Desktop Access) ile arama yaparken neyin okunduğunu dinle. Sonra hx-indicator'ı kaldır ve yavaş bir ağda (tarayıcının Network throttling ayarı) kullanıcı deneyimini karşılaştır.

★★☆ IsHtmxFragmentRequest kontrolünü her handler'da tekrarlamamak için bir page filter yaz: htmx isteğinde PageResult'ı aynı modelle bir partial'a çeviren IAsyncPageFilter. Hangi bilgiyi (partial adı) nereden alacağına karar ver.

★★★ Silme işlemini "geri al" destekli yap: silinen satırın yerine "Deleted. Undo" yazan bir satır dön, 5 saniye içinde hx-post ile geri alınabilsin. Bunun için soft delete gerekecek; EF Core serisindeki global query filter yazısındaki yöntemi kullan.

Bu yazıyla birlikte modern bir ASP.NET Core uygulamasının view tarafını baştan sona kurmuş olduk. Sıradaki yazı geriye dönüyor: Türkiye'de hâlâ çok sayıda kurumsal uygulama ASP.NET MVC (Model-View-Controller) 5 ve .NET Framework üzerinde çalışıyor. Global.asax, Web.config, HtmlHelper ve bundling'in ASP.NET Core karşılıklarını ve böyle bir uygulamayı parça parça taşımanın yolunu konuşacağız.