Skip to content

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-boost for 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

<body hx-boost="true">
    <a href="/about">About</a>
</body>

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").

<a hx-get="/details/42" hx-target="#main"
   hx-push-url="true">Details</a>

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

[HttpPost] public IActionResult Add() => PartialView("_Row", model);

❌ 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()

return Request.IsHtmxRequest()
    ? PartialView("_List", model)
    : View(model);

❌ Wrong: forgetting antiforgery

<form hx-post="/todos"></form>   <!-- 400: missing token -->

✅ Correct: token in headers or hidden field

<body hx-headers='{"RequestVerificationToken":"@token"}'>

❌ Wrong: chasing SPA-style optimistic UI

// trying to mutate DOM client-side then reconcile

✅ Correct: trust the server fragment

<button hx-post="/like/42" hx-swap="outerHTML">Like</button>

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-Request header.

Pattern 2 — "Partials per region"

  • Intent: small _X.cshtml partials 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:changed response 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 meant outerHTML — 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.

Further reading