HTMX with ASP.NET Core
Key Points
- HTMX = HTML attributes (
hx-get,hx-post,hx-target,hx-swap) trigger AJAX; the server returns HTML fragments, browser swaps them in. Hypermedia, not JSON. - Pairs naturally with MVC, Razor Pages, Minimal APIs returning partials, Static SSR Blazor — every endpoint already produces HTML.
- Buys you most SPA interactivity at a fraction of the JS (~14 KB minified, no build step).
- Mental model is closer to Blazor Server than to React: server holds state, server emits UI. But: no SignalR, no circuit, just plain HTTP.
- Patterns: form posts that swap the result, polling (
hx-trigger="every 2s"),hx-boostfor progressively-enhanced anchors, out-of-band swaps for multi-region updates. - Server side: Razor partials, View Components, or Tag Helpers as fragment endpoints. Detect with
Request.IsHtmxRequest()to render a partial vs full layout. - Antiforgery still applies — wire the token via meta tag +
hx-headers. - Limits: heavy client-side state (charts, drag-and-drop, infinite canvases) still wants real JS — HTMX is happy to coexist.
Concepts (deep dive)
Hypermedia, not JSON
Classic SPA: HTMX:
[Browser] [Browser]
↓ JSON ↓ HTML
[API] returns data [Server] returns rendered fragment
↓ JS templating [Browser] swaps fragment into DOM
[Browser] re-renders
The wire format is the rendered DOM. The server is the single source of truth for both data and UI.
The four core attributes
<!-- 1. Trigger an HTTP method -->
<button hx-post="/cart/add/42">Add</button>
<!-- 2. Where the response goes -->
<button hx-get="/products" hx-target="#list">Load</button>
<!-- 3. How it's swapped in -->
<div hx-get="/news" hx-swap="innerHTML">…</div>
<!-- 4. What event triggers it -->
<input hx-post="/search" hx-trigger="keyup changed delay:300ms"
hx-target="#results" name="q" />
hx-swap values: innerHTML (default), outerHTML, beforebegin, afterbegin, beforeend, afterend, delete, none.
Detecting HTMX on the server
public static class HtmxExtensions
{
public static bool IsHtmxRequest(this HttpRequest req)
=> req.Headers.ContainsKey("HX-Request");
public static bool IsHtmxBoosted(this HttpRequest req)
=> req.Headers.TryGetValue("HX-Boosted", out var v) && v == "true";
}
public IActionResult Index()
{
var vm = _svc.Load();
return Request.IsHtmxRequest()
? PartialView("_List", vm)
: View(vm); // full layout
}
This single switch lets the same URL serve both a full page (refresh, deep link, SEO) and a fragment (HTMX swap). Progressive enhancement for free.
A todo list end-to-end (Minimal API)
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddSingleton<TodoStore>();
builder.Services.AddRazorPages();
var app = builder.Build();
app.MapGet("/", () => Results.Content(Layout(Render(Todos.All)), "text/html"));
app.MapPost("/todos", (TodoStore s, [FromForm] string text) =>
{
s.Add(text);
return Results.Content(Render(s.All), "text/html");
});
app.MapPost("/todos/{id:int}/toggle", (TodoStore s, int id) =>
{
s.Toggle(id);
return Results.Content(RenderRow(s.Get(id)), "text/html");
});
app.MapDelete("/todos/{id:int}", (TodoStore s, int id) =>
{
s.Remove(id);
return Results.Content("", "text/html"); // empty → swap removes the row
});
app.Run();
static string Render(IEnumerable<Todo> todos) => $$"""
<ul id="list">
{{string.Join("", todos.Select(RenderRow))}}
</ul>
""";
static string RenderRow(Todo t) => $$"""
<li id="todo-{{t.Id}}">
<input type="checkbox" {{(t.Done ? "checked" : "")}}
hx-post="/todos/{{t.Id}}/toggle"
hx-target="#todo-{{t.Id}}" hx-swap="outerHTML" />
<span>{{t.Text}}</span>
<button hx-delete="/todos/{{t.Id}}"
hx-target="#todo-{{t.Id}}" hx-swap="delete">×</button>
</li>
""";
<!-- Layout -->
<form hx-post="/todos" hx-target="#list" hx-swap="outerHTML"
hx-on::after-request="this.reset()">
<input name="text" required />
<button>Add</button>
</form>
<ul id="list"></ul>
<script src="https://unpkg.com/htmx.org@2"></script>
That's a fully reactive todo list in ~40 lines of server code, zero JS frameworks, zero build step.
Razor Pages with partials
// Pages/Todos/Index.cshtml.cs
public class IndexModel : PageModel
{
[BindProperty] public string? Text { get; set; }
public IActionResult OnPost([FromServices] TodoStore store)
{
store.Add(Text ?? "");
return Partial("_List", store.All);
}
}
@* Pages/Shared/_List.cshtml *@
@model IEnumerable<Todo>
<ul id="list">
@foreach (var t in Model) { <li>@t.Text</li> }
</ul>
View Components as fragment endpoints
public class CartViewComponent : ViewComponent
{
public IViewComponentResult Invoke() => View(_store.Load());
}
<div id="cart"
hx-get="@Url.Action("Get", "Cart")"
hx-trigger="cart:changed from:body"
hx-swap="innerHTML">
<vc:cart />
</div>
A View Component renders the initial server state; later cart:changed events swap fresh HTML in.
Out-of-band swaps (multi-region updates)
// One POST updates two regions of the page
return Content($$"""
<div id="row-{{id}}">{{newRow}}</div>
<div id="cart-count" hx-swap-oob="true">{{count}}</div>
""", "text/html");
The primary swap targets hx-target; any element with hx-swap-oob is swapped additionally into its matching id.
Polling and SSE
<!-- poll every 2s -->
<div hx-get="/build/status/42" hx-trigger="every 2s"
hx-swap="outerHTML">…</div>
<!-- server-sent events -->
<div hx-ext="sse" sse-connect="/events" sse-swap="news">…</div>
For real-time, SSE/WebSocket extensions exist (htmx.org/extensions/sse, ws).
hx-boost for progressive enhancement
Plain anchors and forms are upgraded to AJAX-with-history without changing markup. Disable per-element with hx-boost="false".
Antiforgery
Razor's antiforgery integrates without ceremony:
@inject IAntiforgery Antiforgery
@{
var token = Antiforgery.GetAndStoreTokens(Context).RequestToken;
}
<meta name="request-token" content="@token" />
<body hx-headers='{"RequestVerificationToken":"@token"}'>
Or the official htmx extension htmx-ext-csrf. Server validates with [ValidateAntiForgeryToken] or services.AddAntiforgery(o => o.HeaderName = "RequestVerificationToken").
History, URLs, deep links
hx-push-url="true" updates the address bar — refresh and bookmarks work because the same URL also returns a full page when not an HTMX request.
Comparison with Blazor Server
| Aspect | HTMX + ASP.NET Core | Blazor Server |
|---|---|---|
| Wire format | HTML fragments | Render diffs over SignalR |
| Connection | Plain HTTP per interaction | Persistent WebSocket |
| Server state | Per-request (typical) | Per-circuit (per user/tab) |
| Reactivity | Coarse (region swaps) | Fine-grained (component-level diffs) |
| Scaling | Stateless, horizontal trivial | Sticky LB or backplane needed |
| JS payload | ~14 KB | ~30 KB (blazor.web.js) + circuit |
| Programming model | Razor + sprinkles | C# components |
| Offline | No | No |
| Mental load | Low | Medium |
HTMX is the lighter of the two for "server holds the truth" UIs.
Comparison with React/Vue/Angular
For a CRUD admin or LOB site that is not highly interactive, HTMX ships the same UX with 5-10× less JS and zero build step. For Notion-class editors or design tools, real SPAs still win.
Code: correct vs wrong
❌ Wrong: returning JSON from an HTMX endpoint
[HttpPost] public IActionResult Add() => Ok(new { ok = true });
@* HTMX swaps the JSON text into the DOM literally *@
✅ Correct: return HTML fragment
❌ Wrong: full page on every HTMX request
public IActionResult Index() => View(model); // ← always renders layout
@* fragment is wrapped in <html><body>... — duplicate content *@
✅ Correct: branch on IsHtmxRequest()
❌ Wrong: forgetting antiforgery
✅ Correct: token in headers or hidden field
❌ Wrong: chasing SPA-style optimistic UI
✅ Correct: trust the server fragment
The server returns the new button HTML (now showing "Liked"). One source of truth.
Design patterns for this topic
Pattern 1 — "Same URL, two responses"
- Intent: one route serves full page or partial, branched by
HX-Requestheader.
Pattern 2 — "Partials per region"
- Intent: small
_X.cshtmlpartials map 1:1 to swappable regions.
Pattern 3 — "Out-of-band swaps for cross-cutting state"
- Intent: cart count, toast, header avatar update on any action.
Pattern 4 — "Server-driven events"
- Intent: server sets
HX-Trigger: cart:changedresponse header; other regions listening reload themselves.
Pattern 5 — "Progressive enhancement via hx-boost"
- Intent: plain HTML works with JS off; HTMX upgrades on the client.
Pattern 6 — "HTMX + tiny Alpine for client-only widgets"
- Intent: dropdowns, modals, tooltips don't need a server roundtrip; pair with Alpine.js.
Pros & cons / trade-offs
| Aspect | Pro | Con |
|---|---|---|
| JS payload | ~14 KB, no build | Less than SPA but still adds attributes everywhere |
| Mental model | Hypermedia / REST-ish | Foreign to SPA-trained devs |
| Server state | Stateless HTTP | More server CPU per interaction than client-rendered |
| Refactoring | Just HTML | No type system across client/server boundary |
| Offline / real-time | Limited | SSE/WS extensions help but partial |
| Tooling | Browser DevTools | No Redux DevTools-style time travel |
When to use / when to avoid
- ✅ Use for CRUD apps, internal tools, content-heavy sites with sprinkles of interactivity.
- ✅ Use when the team is .NET-strong and React-light.
- ✅ Use to modernize a Razor Pages or MVC app without a full SPA rewrite.
- ✅ Use alongside Blazor Static SSR — they stack beautifully.
- ❌ Avoid for editors / design canvases / drag-and-drop heavy apps.
- ❌ Avoid when you need rich offline.
- ❌ Avoid if requirements demand client-side state machines (XState etc.).
Interview Q&A
Q1. What is HTMX? A small library that adds attributes for AJAX, where servers return HTML fragments and the browser swaps them in. Hypermedia in the browser.
Q2. Why does HTMX pair well with ASP.NET Core? Razor Pages / MVC / Static SSR Blazor already render HTML — the server is already in the right format.
Q3. How do you detect HTMX on the server? The HX-Request header is sent on every HTMX request. Branch your action to return a partial vs full view.
Q4. What does hx-swap="outerHTML" do? Replaces the entire target element with the response. innerHTML (default) replaces only its contents.
Q5. What's an out-of-band swap? Server returns multiple fragments; ones with hx-swap-oob="true" are swapped into matching ids alongside the primary target.
Q6. How do you handle antiforgery? Render the token in a meta tag and pass via hx-headers (or [FromHeader]). Server validates as usual.
Q7. HTMX vs Blazor Server? Both keep state on the server. HTMX uses plain HTTP and HTML fragments; Blazor Server uses SignalR and component diffs. HTMX is lighter; Blazor is finer-grained.
Q8. HTMX vs React? React: rich client state, JSON APIs, big build, big payload. HTMX: server-rendered, tiny payload, no build. Pick by use case.
Q9. How does hx-boost work? Upgrades anchors/forms to AJAX with pushState history. Same URL serves both refreshed full-page and partial content.
Q10. How do you handle real-time? SSE extension or WebSockets extension. For most needs, polling with hx-trigger="every Ns" is enough.
Q11. Can HTMX coexist with React? Yes — HTMX for navigation/regions, React/Vue islands for complex widgets. Common in .NET shops modernizing a legacy app.
Q12. How do you debug a swap? Browser DevTools Network tab — request shows HX-Request: true, response is HTML. Inspect the fragment exactly as the server emits it.
Gotchas / common mistakes
- ⚠️ Returning JSON from HTMX endpoints — fragments must be HTML.
- ⚠️ Returning full layout to HTMX — wraps
<html><body>inside an existing page. - ⚠️ Forgetting antiforgery — silent 400s.
- ⚠️
hx-swap="innerHTML"when you meantouterHTML— element doubles. - ⚠️ Massive fragments — defeats the point; keep regions small.
- ⚠️ Stale state in OOB swaps — make sure server emits fresh values.
- ⚠️ Routing without
hx-push-url— back button breaks for "navigation"-style swaps. - ⚠️ Mixing optimistic UI patterns — HTMX is server-truth; don't fight it.