Razor Pages with htmx: Interactive UI Without an SPA, Partial Responses and Antiforgery
With htmx, Razor Pages handlers return the full page or just a partial depending on the HX-Request header; live search, infinite scroll and inline editing work without JSON or an SPA. We carry the antiforgery token with hx-headers and show the Vary and history restore pitfalls, htmx 2's 4xx behavior and htmx 4 differences with real requests.
Version note: The code in this post was built and run with .NET 10 (SDK 10.0.402) and htmx 2.0.11.
Guess First
We're adding live search to the book list in the admin panel. The user types "Clean" into the search box; htmx asks the server for just the table rows, updates the table and sets the address bar to /Manage/Books?Search=Clean. Then the user reloads the page (F5). What does this request to the same URL (Uniform Resource Locator) return: the full page, or a bare list of <tr> elements with no layout and not even an <html> tag? And consider this too: a POST sent by htmx has no form and no hidden __RequestVerificationToken input. What does Razor Pages' antiforgery validation do with that request?
What Does htmx Do?
htmx is a JavaScript library, 52 KB minified and about 17 KB gzipped, that sends requests from HTML (HyperText Markup Language) attributes without reloading the page and places the returned HTML fragment somewhere on the page. The idea: the server already produces HTML; instead of returning JSON (JavaScript Object Notation) and turning it back into HTML in the browser, let the server return the HTML fragment directly. Four basic attributes do most of the work:
hx-get,hx-post: which address the request goes to, with which verb.hx-trigger: when the request goes out (click,input changed delay:300ms,revealed...).hx-target: where the returned HTML goes: a CSS (Cascading Style Sheets) selector or a relative expression likeclosest tr.hx-swap: how it goes there (innerHTMLby default,outerHTML,beforeend,afterend...).
Razor Pages fits this model very well: every handler already returns an IActionResult, and PageModel.Partial() renders a partial view without a layout. In the static assets post we put htmx under wwwroot/lib/htmx with LibMan; adding the script to the layout is enough:
<script src="~/lib/htmx/dist/htmx.min.js"></script>
Thanks to MapStaticAssets, this line renders as /lib/htmx/dist/htmx.min.6dpfg1qi0s.js; fingerprinted and immutable.
Live Search: One Handler, Two Responses
We adapt the list from the Razor Pages post to htmx. The search box is no longer part of a form; it sends its own request:
<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: send the request when the value changes, but only after the user stops typing for 300 ms. Someone typing "Clean" produces one request, not five. The search event also catches clearing the box with its cross icon. The input's name and value are added to the request automatically, so the request is GET /Manage/Books?Search=Clean. hx-push-url="true" writes this address into the browser history and the address bar.
Now the server side. The same OnGetAsync returns two different things depending on who sent the request:
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 sends HX-Request on every request; a history restore needs the full page
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 adds an HX-Request: true header to every request. If the header is there we return just the rows; otherwise the full page. Here is the answer to the first question: when the page is reloaded the browser sends a normal GET, there's no HX-Request, and the user gets the full page. Real requests:
$ 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&handler=EditRow"
hx-target="closest tr" hx-swap="outerHTML">Edit price</button>
<button hx-post="/Manage/Books?id=4&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
The fragment is a single row; the full page is 5,529 bytes. Two details matter. First, Vary: HX-Request: if the same URL returns two different contents depending on a header, you have to tell caches. If you don't, a proxy or the browser cache may later serve the bare fragment produced for htmx to a user asking for the full page. htmx's documentation also recommends Vary in this situation.
Second, HX-History-Restore-Request. When the back button is pressed, htmx restores the page's previous state from its own history cache (10 entries by default). If the entry isn't in the cache, it requests the same URL with HX-Request: true and HX-History-Restore-Request: true, and this time it expects the full page. A handler that only looks at HX-Request returns rows in that case, and the user meets a layout-less table on the back button. A request with this header gets the full page in our code:
$ 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
The cl search in this request shows something else too: the result is empty. "Clean Lines" contains "Cl", but the SQLite provider of Entity Framework Core (EF Core) translates Contains to case-sensitive instr. For an empty result _BookRows returns a "No books found." row; had we returned an empty response, the table would silently empty and the user couldn't tell whether the search worked.
Infinite Scroll: The revealed Trigger
I set the page size to 3 on purpose so scrolling shows. If there's a next page, the _BookRows partial adds a "sentinel" row at the end of the list:
@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>
}
The revealed trigger fires once, as soon as the element becomes visible on screen. The response replaces the sentinel row (outerHTML): three new rows and, if needed, a new sentinel. The rendered sentinel is hx-get="/Manage/Books?PageNumber=2"; the same OnGetAsync handler, the same partial. For infinite scroll we didn't write a separate endpoint, a separate JSON contract or a client-side template. Because the search filter travels into the sentinel's URL through Model.Search, the second page of a filtered list comes back correctly.
Inline Editing and Antiforgery
Each row has an "Edit price" button. When clicked, the row turns into an edit row with an input; after saving, back into a normal row:
@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>
There's no <form>; hx-include="closest tr" adds the inputs in the row to the request. The handlers are the named handlers we saw in the Razor Pages post:
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 doesn't swap 4xx responses: return the error with 200
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);
}
With no form, there's no hidden token input either. The answer to the second question is the same as in the Razor Pages post:
$ 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
The antiforgery token is also accepted in a header instead of a form field; the default header name is RequestVerificationToken. htmx's hx-headers attribute adds headers to every request beneath an element, so putting the token on <body> once is enough:
@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 both produces the request token and writes its paired cookie to the response; validation compares the two. A request sent with the header:
$ 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>
We return 200 for an invalid price because of htmx 2's default behavior: 4xx and 5xx responses count as errors and aren't swapped. Had we returned 422, the user would press the button and nothing would happen. Delete works the same way: OnPostDeleteAsync returns an empty 200 (Content(string.Empty)), and hx-swap="outerHTML" replaces the row with empty content, removing it. hx-confirm shows the browser's confirmation dialog before deleting.
Besides curl, I also tried this flow in a real browser: typing "Clean" changed the address bar to /Manage/Books?Search=Clean, the second page loaded on its own as soon as the sentinel row became visible, and setting the price to 35.00 and pressing "Save" made POST /Manage/Books?id=4&handler=SaveRow return 200 and refreshed the row with 35.00. The token was carried by the hx-headers on the body.
When Is It Enough, and When Not?
htmx is strong on screens where interaction takes the shape "ask the server, place the HTML": search and filters, paging, inline editing, tabs, modal content, a notification counter. Admin panels and internal apps mostly fit this description. All business logic and validation stay on the server, two separate models (a C# DTO (Data Transfer Object) and a TypeScript type) don't need to be kept in sync, and the deployment is a single app.
The limits are clear too. Every interaction is a round trip; a drag-and-drop kanban board, offline operation, heavy client-side state (managing every step of a multi-step wizard without going to the server) or a graphics editor will struggle with htmx. If a mobile app will consume the same data, a Web API (Application Programming Interface) will be written anyway; at that point an SPA (Single-Page Application) framework like Angular, or Blazor, is more natural. Both can coexist: most of the panel with htmx, a single heavy screen as a separate component. The decision questions are the same as the table in the first post of this series.
Production Notes
Accessibility isn't something htmx solves automatically. Screen readers don't announce a table whose content changes on their own; you need to add aria-live="polite" to the results region and think about focus management. htmx applies the autofocus on the edit row after the swap, so keyboard users land straight in the input. Buttons being real <button> elements matters for keyboard access too; a "button" written as <div hx-get> can't be reached with Tab.
If you use hx-push-url, every URL you push must return a meaningful full page when opened on its own. In our example, opening ?Search=Clean directly returns the filtered full page. That requires the handler to be able to build its state from the query parameters.
Separate fragment requests in logs and metrics. The same endpoint now returns two kinds of response; response time and size distributions split in two. Adding the HX-Request header to the log scope helps during analysis.
htmx 4.0 was released in August 2026, but at the time of writing npm's latest tag still points to 2.0.11; 4.x is on the next tag. According to the migration guide, 4.x uses fetch() instead of XHR (XMLHttpRequest), attributes are no longer inherited implicitly (an explicit marker like hx-headers:inherited is needed), 4xx/5xx responses are swapped by default, and the localStorage history cache is gone. The hx-headers on the body and the validation error returned with 200 are directly affected by these differences; revisit both decisions from this post when you upgrade.
Common Pitfalls
Checking HX-Request and forgetting the Vary header. A cached fragment can be served instead of the full page; the bug appears rarely, and only in cached environments.
Returning a fragment to a history restore request too. If there's no cache entry, the back button shows a table without a layout.
Returning 4xx on a validation error. htmx 2 doesn't swap these responses; the user gets no feedback. Either return the error HTML with 200, or listen to the htmx:responseError event and handle it separately.
Trying to add the token to every request by hand, or turning it off with [IgnoreAntiforgeryToken]. A single hx-headers is enough.
Relying on hx-headers on the body with hx-boost page transitions. According to htmx's documentation, boosted navigation doesn't update <html> and <body>; the token has to go on an element that gets replaced.
A Small Experiment
★☆☆ Add aria-live="polite" to the results region (tbody) and listen to what is read while searching, with VoiceOver on macOS or NVDA (NonVisual Desktop Access) on Windows. Then remove hx-indicator and compare the user experience on a slow network (the browser's Network throttling setting).
★★☆ To avoid repeating the IsHtmxFragmentRequest check in every handler, write a page filter: an IAsyncPageFilter that turns a PageResult into a partial with the same model on htmx requests. Decide where it gets the information it needs (the partial name).
★★★ Make delete support "undo": return a row saying "Deleted. Undo" in place of the deleted row, which can be undone with hx-post within 5 seconds. You'll need soft delete for this; use the approach from the global query filter post in the EF Core series.
With this post we've built the view side of a modern ASP.NET Core app from start to finish. The next post looks back: in Turkey, many enterprise apps still run on ASP.NET MVC (Model-View-Controller) 5 and .NET Framework. We'll discuss the ASP.NET Core equivalents of Global.asax, Web.config, HtmlHelper and bundling, and how to move such an app over piece by piece.