This is a viewer only at the moment see the article on how this works.
To update the preview hit Ctrl-Alt-R (or ⌘-Alt-R on Mac) or Enter to refresh. The Save icon lets you save the markdown file to disk
This is a preview from the server running through my markdig pipeline
Friday, 28 November 2025
If you've built traditional ASP.NET Core MVC applications, you know the problem: that dreaded "click flash" when users navigate between pages. Full page reloads, the browser chrome flickering, content jumping around as the new page renders. It works, but it doesn't feel modern.
The technique of returning partial HTML fragments from the server and swapping them into the DOM solves this - and it isn't new. I've been using this pattern since the jQuery days, and even before that with vanilla JavaScript and XMLHttpRequest. What's changed is how elegant it's become with HTMX.
HTMX gives us a declarative way to do what we've always done: return server-rendered HTML and swap it into the page. No more writing custom JavaScript for every interaction. No more choosing between "proper" server-side development and smooth user experience. With HTMX, we get both.
Companion Article: This article focuses on the ASP.NET Core integration side. For a deep dive into HTMX events, lifecycle, and custom extensions, see my companion article: A Whistle-stop Tour of HTMX Extensions and Using HTMX with ASP.NET Core.
In this article, I'll show you how HTMX integrates beautifully with ASP.NET Core partials, how the excellent HTMX.NET library makes it even better, and how my mostlylucid.pagingtaghelper NuGet package uses HTMX provides powerful pagination with minimal configuration.
The approach is framework-agnostic too - Django added template fragments in version 6.0, Rails has Turbo Frames, and the broader web ecosystem is embracing HTML-over-the-wire patterns. It's a good time to be building server-rendered applications.
HTMX is a library that lets you access modern browser features directly from HTML, rather than writing JavaScript. It extends HTML with attributes that allow you to make AJAX requests, swap content, and create rich interactions - all without leaving your markup.
The key attributes you'll use most often:
hx-get, hx-post, hx-put, hx-delete - Make HTTP requestshx-target - Specify where to put the responsehx-swap - Control how content is swapped (innerHTML, outerHTML, etc.)hx-trigger - Define what triggers the request (click, change, load, etc.)hx-push-url - Update the browser URL without a full page reloadHere's the beauty of it: you're still writing server-side code, returning server-rendered HTML. No JSON APIs, no client-side templates, no build pipelines. Just good old-fashioned HTML over the wire.
Before HTMX, we achieved the same effect with considerably more ceremony. Here's what partial updates looked like in the jQuery era:
// jQuery circa 2010
$('#load-more').click(function() {
$.ajax({
url: '/posts/page/' + currentPage,
success: function(html) {
$('#posts-container').append(html);
currentPage++;
}
});
});
And even earlier, with vanilla JavaScript:
// Vanilla JS circa 2005
var xhr = new XMLHttpRequest();
xhr.onreadystatechange = function() {
if (xhr.readyState === 4 && xhr.status === 200) {
document.getElementById('content').innerHTML = xhr.responseText;
}
};
xhr.open('GET', '/partial-content', true);
xhr.send();
The server-side pattern was identical - return HTML fragments, swap them into the DOM. HTMX just moves this logic from JavaScript into HTML attributes, making it declarative, discoverable, and far less error-prone. The innovation isn't the technique; it's the interface.
ASP.NET developers have been doing this for years. UpdatePanels in WebForms (2005), PartialView() in MVC since day one, Html.RenderAction() for composable fragments - the capability has always been there. What we lacked was an elegant, standardised way to wire it up on the client side. HTMX fills that gap perfectly.
The broader industry has been rediscovering these patterns too. Terms like "SSR" (Server-Side Rendering), "hybrid rendering", and "islands architecture" are essentially describing what server-side frameworks have always done, but with fresh eyes. It's validation that the server-rendered approach scales, performs, and - with the right tools - provides excellent user experience.
First, include HTMX in your layout. You can use a CDN or serve it locally:
<script src="https://unpkg.com/htmx.org@2.0.0"></script>
That's it. No build step, no npm install, no webpack configuration. Just drop in a script tag and you're off to the races.
For client-side reactivity (showing/hiding elements, toggling states, local UI state), Alpine.js complements HTMX perfectly. At just 15KB gzipped, it provides Vue/React-like declarative reactivity without the bloat.
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
Here's how they work together:
<div x-data="{ open: false }">
<button x-on:click="open = !open">Toggle</button>
<div x-show="open" x-transition>
<button hx-get="/api/data" hx-target="#results">Load Data</button>
</div>
</div>
Alpine handles local UI (the toggle), HTMX handles server calls (the data fetch). Throughout this article, you'll see this pattern - Alpine for client reactivity, HTMX for server interactions.
Khalid Abuhakmeh's HTMX.NET library provides first-class ASP.NET Core integration. Available as Htmx and Htmx.TagHelpers NuGet packages, it feels native to .NET and makes working with HTMX an absolute pleasure. You'll find more of Khalid's excellent open-source work on his GitHub.
dotnet add package Htmx
dotnet add package Htmx.TagHelpers
In your _ViewImports.cshtml:
@addTagHelper *, Htmx.TagHelpers
The most useful feature is the Request.IsHtmx() extension method, which tells you whether the request came from HTMX. This lets you return either a full view or just a partial:
[HttpGet]
public async Task<IActionResult> Index(int page = 1, int pageSize = 20)
{
var posts = await blogViewService.GetPagedPosts(page, pageSize);
if (Request.IsHtmx())
return PartialView("_BlogSummaryList", posts);
return View("Index", posts);
}
This pattern is absolutely brilliant. A single controller action serves both:
No separate API endpoints, no duplicated logic, no JSON serialisation overhead.
A common misconception: Many ASP.NET developers think you need the
_prefix (like_BlogSummaryList.cshtml) to get partial rendering. You don't! ThePartialView()method itself tells ASP.NET Core to skip the layout - it's saying "forget the layout, just render this bit". You can usereturn PartialView("SearchResults", model)with a regular view file and it works perfectly. The underscore convention originated from ASP.NET Web Pages (WebMatrix) where it prevented files from being served directly via URL - but MVC has always protected all views from direct access anyway. It's purely a naming convention to help identify views intended as partials.
HTMX.NET provides tag helpers that make working with controllers cleaner. Instead of writing route strings, you can use strongly-typed references:
<button
hx-controller="Comment"
hx-action="GetCommentForm"
hx-post
hx-target="#commentform">
Reply
</button>
This generates the correct route using ASP.NET Core's routing system. If you rename your controller or action, your IDE will catch it. Much better than magic strings!
Here's a real example from this blog's comment system:
<button
class="btn btn-outline btn-sm mb-4"
hx-action="Comment"
hx-controller="Comment"
hx-post
hx-vals
x-on:click.prevent="window.mostlylucid.comments.setValues($event)"
hx-on="htmx:afterSwap: window.scrollTo({top: 0, behavior: 'smooth'})"
hx-swap="outerHTML"
hx-target="#commentform">
Comment
</button>
Notice how HTMX plays nicely with Alpine.js (x-on:click.prevent) for those occasional bits of client-side interactivity.
The library also provides:
Request.IsHtmxNonBoosted() - Check if it's an HTMX request but not boostedRequest.IsHtmxRefresh() - Check if it's a history restore requestHere's the search controller from this blog, showing the three-tier return pattern:
[HttpGet]
[OutputCache(Duration = 3600, VaryByHeaderNames = new[] { "hx-request", "pagerequest" })]
public async Task<IActionResult> Search(
string? query,
int page = 1,
int pageSize = 10,
[FromHeader] bool pagerequest = false)
{
var searchModel = await BuildSearchModel(query, page, pageSize);
if (pagerequest && Request.IsHtmx())
return PartialView("_SearchResultsPartial", searchModel.SearchResults);
if (Request.IsHtmx())
return PartialView("SearchResults", searchModel);
return View("SearchResults", searchModel);
}
Three return paths for three scenarios:
The partial view (_SearchResultsPartial.cshtml) uses the paging tag helper:
@model Mostlylucid.Models.Blog.PostListViewModel
<div class="pt-2" id="content">
@if (Model.Data?.Any() is true)
{
<div class="inline-flex w-full items-center justify-center pb-4">
@if (Model.TotalItems > Model.PageSize)
{
<pager
x-ref="pager"
link-url="@Model.LinkUrl"
hx-boost="true"
hx-target="#content"
hx-swap="show:none"
page="@Model.Page"
page-size="@Model.PageSize"
total-items="@Model.TotalItems"
hx-headers='{"pagerequest": "true"}'>
</pager>
}
</div>
@foreach (var post in Model.Data)
{
<partial name="_ListPost" model="post"/>
}
}
</div>
Breaking down the pager tag helper:
hx-boost="true" - Intercepts links, converts to AJAXhx-target="#content" - Where to inject the responsehx-headers='{"pagerequest": "true"}' - Custom header tells the controller it's paginationRequest.IsHtmx() && pagerequest to return just the minimal partialI wrote mostlylucid.pagingtaghelper to avoid repetitive pagination code. It's HTMX-first but works without JavaScript too.
dotnet add package mostlylucid.pagingtaghelper
Add to _ViewImports.cshtml:
@addTagHelper *, mostlylucid.pagingtaghelper
Implement IPagingModel<T> and you're done:
public class BasePagingModel<T> : IPagingModel<T> where T : class
{
public int Page { get; set; }
public int TotalItems { get; set; }
public int PageSize { get; set; }
public string LinkUrl { get; set; }
public List<T> Data { get; set; }
}
What you get:
The tag helper generates links that preserve query strings, support custom headers, and integrate seamlessly with HTMX (see the example above).
Here's how the whole system fits together:
graph TB
A[Browser] -->|"Initial page load"| B[Controller Action]
B -->|"Request.IsHtmx() = false"| C[Return Full View]
C --> D[Render Layout + Partial]
A -->|"User clicks pagination/filter"| E[HTMX Request]
E -->|"hx-get with headers"| F[Same Controller Action]
F -->|"Request.IsHtmx() = true"| G{Check Headers}
G -->|"pagerequest header"| H[Return Minimal Partial]
G -->|"No special header"| I[Return Section Partial]
H --> J[Swap Content in Target]
I --> J
J -->|"User clicks another link"| E
style B stroke:#333,stroke-width:2px
style F stroke:#333,stroke-width:2px
style C stroke:#0066cc,stroke-width:2px
style H stroke:#00cc66,stroke-width:2px
style I stroke:#00cc66,stroke-width:2px
Django added proper partial template rendering in version 6.0 (December 2024) with template fragments. Before that, Django developers typically used inclusion tags or third-party packages like django-render-block. ASP.NET Core has had PartialView() since version 1.0 in 2016 - different frameworks, different timelines, but the same destination: HTML fragments for HTMX.
Ruby on Rails has Turbo Frames (part of Hotwire), which is similar in spirit:
<%= turbo_frame_tag "posts" do %>
<%= render @posts %>
<% end %>
The difference is that Turbo requires specific frame markers on both request and response. HTMX is more flexible - any endpoint can return any HTML, and you decide where it goes with hx-target.
Elixir's Phoenix LiveView takes a different approach with persistent WebSocket connections and server-side state:
def handle_event("load_more", _params, socket) do
{:noreply, assign(socket, posts: load_more_posts())}
end
LiveView is brilliant for real-time applications, but it requires WebSocket infrastructure and server memory for connections. HTMX uses plain old HTTP - stateless, cacheable, scaleable. For a blog, that's perfect.
Output Caching: The OutputCache attribute varies by hx-request header, caching full pages and partials separately:
[OutputCache(Duration = 3600, VaryByHeaderNames = new[] { "hx-request", "pagerequest" })]
Network Efficiency: Server-rendered HTML is often smaller than JSON + client-side templates, requires fewer round trips, and caches properly.
Bundle Size: HTMX (14KB) + optional Alpine.js (15KB) + paging tag helper (0KB, server-side) = under 30KB total. Compare that to a typical React app (200KB+).
Optimistic UI Updates - Combine HTMX and Alpine for instant feedback:
<div x-data="{ count: @Model.CommentCount }">
<button hx-post="/comment/like" x-on:click="count++" hx-on::after-request="count = $event.detail.xhr.response">
Likes: <span x-text="count"></span>
</button>
</div>
The count updates immediately (optimistic), then syncs with the server response.
Out-of-Band Swaps - Update multiple page sections from one response:
<div id="main-content"><!-- Main response --></div>
<div id="notification-count" hx-swap-oob="true"><span>5 new</span></div>
Perfect for notification badges, cart counts, etc. I cover this pattern in depth in Showing Toast and Swapping with HTMX.
Client-Side Templates with WebAPI - Sometimes you want a mid-way experience: server-rendered HTML for most things, but JSON from a WebAPI for specific dynamic content. HTMX's client-side-templates extension lets you do exactly this:
<script src="https://unpkg.com/htmx-ext-client-side-templates@2.0.0/client-side-templates.js"></script>
<script src="https://unpkg.com/mustache@latest"></script>
<div hx-ext="client-side-templates">
<template id="post-template" type="text/mustache">
{{#posts}}
<div class="post">
<h3>{{title}}</h3>
<p>{{excerpt}}</p>
</div>
{{/posts}}
</template>
<button hx-get="/api/posts"
hx-target="#post-list"
mustache-template="post-template">
Load Posts
</button>
<div id="post-list"></div>
</div>
This approach works with Mustache, Handlebars, or Nunjucks templates. Your WebAPI returns JSON, but HTMX handles the rendering client-side. It's particularly useful when you have an existing API or need to share data with mobile apps. For more details on HTMX extensions including client-side-templates, see the companion article.
Debugging Tool: Install the HTMX debugger extension - it shows every request, response, and swap in real-time.
ASP.NET Core's antiforgery tokens need special handling with AJAX. HTMX.NET provides several clean options. The recommended way uses HtmxAntiforgeryScriptEndpoint:
// In Program.cs
app.MapHtmxAntiforgeryScript();
<!-- In your layout head -->
<script src="@HtmxAntiforgeryScriptEndpoints.Path" defer></script>
Or use the tag helper: <meta name="htmx-config" includeAspNetAntiforgeryToken="true" />. See Khalid's article on HTMX Anti-Forgery Tokens for full details.
Alpine's @click shorthand conflicts with Razor's @ syntax. Use the explicit form instead:
<button x-on:click="doSomething()">Click me</button> <!-- Instead of @click -->
<div x-bind:class="isOpen ? 'block' : 'hidden'"></div> <!-- Instead of :class -->
Or escape with @@click, but explicit syntax is clearer.
Controlling history entries: By default, HTMX pushes every request to history. For pagination/filters where you don't want history pollution:
<paging model="@Model" hx-push-url="false"> <!-- Don't add history entries -->
Or use hx-replace-url="true" to update the URL without adding entries.
The Phantom Partial problem: Browser back/forward shows only a partial fragment with no layout. Fix with hx-history-elt on your content container:
<div class="container mx-auto" id="contentcontainer" hx-history-elt>
@RenderBody()
</div>
This tells HTMX which element to snapshot, preserving the surrounding layout when restoring history.
Symptom: HTMX works locally but breaks behind a CDN - wrong content gets cached.
Root Cause: CDNs ignore the HX-Request header. Your server returns different content based on this header, but the CDN caches them identically.
ASP.NET Core fix: Use VaryByHeaderNames:
[OutputCache(Duration = 3600, VaryByHeaderNames = new[] { "hx-request" })]
CDN fix: Configure cache rules to include HX-Request in the cache key. For Cloudflare: Dashboard → Caching → Cache Rules → Custom cache key → Headers → Include HX-Request.
hx-boost converts normal links into AJAX requests. The HTMX quirks page notes some core team members recommend avoiding it (discards <head> content, affects locality of behavior), while others find it fine for quick wins.
<div hx-boost="true" hx-target="#contentcontainer">
<a asp-action="Show" asp-route-slug="@post.Slug">Read More</a>
</div>
This blog uses it extensively. When targeting specific containers, explicit hx-get is clearer, but hx-boost works if you're consistent. Disable selectively with hx-boost="false" on child elements.
HTMX with ASP.NET Core partials represents a return to server-side simplicity without sacrificing modern UX. You get:
The server-rendered approach has stood the test of time, and with HTMX, it finally has the elegant client-side tooling it deserves. You can build robust, performant web applications using patterns that have worked for decades - they've just been waiting for the right tool to make them shine again.
This article is part of a two-part series on HTMX with ASP.NET Core:
Official Documentation:
Libraries & Tools:
Community Resources:
© 2025 Scott Galloway — Unlicense — All content and source code on this site is free to use, copy, modify, and sell.