Swap.Htmx
0.2.0-dev
See the version list below for details.
dotnet add package Swap.Htmx --version 0.2.0-dev
NuGet\Install-Package Swap.Htmx -Version 0.2.0-dev
<PackageReference Include="Swap.Htmx" Version="0.2.0-dev" />
<PackageVersion Include="Swap.Htmx" Version="0.2.0-dev" />
<PackageReference Include="Swap.Htmx" />
paket add Swap.Htmx --version 0.2.0-dev
#r "nuget: Swap.Htmx, 0.2.0-dev"
#:package Swap.Htmx@0.2.0-dev
#addin nuget:?package=Swap.Htmx&version=0.2.0-dev&prerelease
#tool nuget:?package=Swap.Htmx&version=0.2.0-dev&prerelease
Swap.Htmx
HTMX navigation framework for ASP.NET Core MVC applications. Provides a rigid, opinionated structure for building HTMX-powered applications with automatic page/partial detection, middleware enforcement, and extension methods.
Features
- SwapController Base Class: Automatically handles page vs partial rendering based on HX-Request header
- SwapView() Helper: Single method that returns full page or partial view based on request type
- Middleware Enforcement: Catches and reports full page responses when partials are expected
- Extension Methods: Fluent API for working with HTMX request/response headers
- Zero Configuration: Works out of the box with sensible defaults
Installation
dotnet add package Swap.Htmx
Quick Start
1. Register Services and Middleware
In your Program.cs:
var builder = WebApplication.CreateBuilder(args);
// Add MVC and Swap.Htmx
builder.Services.AddControllersWithViews();
builder.Services.AddSwapHtmx();
var app = builder.Build();
// Add middleware (after UseRouting, before MapControllers)
app.UseRouting();
app.UseSwapHtmxShell(); // Enforces partial responses for HTMX requests
app.MapControllerRoute(
name: "default",
pattern: "{controller=Home}/{action=Index}/{id?}");
app.Run();
2. Update Controllers
Change your controllers to inherit from SwapController and use SwapView():
using Microsoft.AspNetCore.Mvc;
using Swap.Htmx;
public class ArticlesController : SwapController
{
private readonly AppDbContext _context;
public ArticlesController(AppDbContext context)
{
_context = context;
}
public async Task<IActionResult> Index()
{
var articles = await _context.Articles.ToListAsync();
return SwapView(articles); // Automatically returns partial or full view
}
public async Task<IActionResult> Details(int id)
{
var article = await _context.Articles.FindAsync(id);
if (article == null) return NotFound();
return SwapView(article); // Works for any action
}
[HttpPost]
public async Task<IActionResult> Create(Article article)
{
if (!ModelState.IsValid)
return SwapView("Create", article);
_context.Articles.Add(article);
await _context.SaveChangesAsync();
Response.HxTrigger("articleCreated"); // Trigger client-side event
return SwapView("Details", article);
}
}
3. Update Views
Index.cshtml (Shell with nested loading):
@model IEnumerable<Article>
<div id="articles-container">
<h1>Articles</h1>
<div hx-get="/Articles/List"
hx-trigger="load"
hx-target="#articles-list"
hx-indicator="#loading">
<div id="loading">Loading articles...</div>
</div>
<div id="articles-list"></div>
</div>
List.cshtml (Partial content):
@model IEnumerable<Article>
@foreach (var article in Model)
{
<div class="article-card">
<h2>
<a href="/Articles/Details/@article.Id"
hx-get="/Articles/Details/@article.Id"
hx-target="#main-content"
hx-push-url="true">
@article.Title
</a>
</h2>
<p>@article.Summary</p>
</div>
}
_Layout.cshtml (No hx-boost, explicit targets):
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>@ViewData["Title"] - My App</title>
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
</head>
<body>
<nav>
<a href="/"
hx-get="/"
hx-target="#main-content"
hx-push-url="true">Home</a>
<a href="/Articles"
hx-get="/Articles"
hx-target="#main-content"
hx-push-url="true">Articles</a>
</nav>
<main id="main-content">
@RenderBody()
</main>
</body>
</html>
How It Works
Automatic Page/Partial Detection
SwapView() checks for the HX-Request header:
- HTMX Request (header present): Returns
PartialView()- no layout - Normal Request (initial load, refresh): Returns
View()- with layout
This means:
- First page load → Full page with layout
- Navigation via HTMX → Partial view swapped into target
- Browser refresh → Full page with layout again
- No manual detection needed in every action
Middleware Enforcement
SwapHtmxShellMiddleware intercepts responses and checks:
- If request has
HX-Requestheader (excluding boosted requests) - If response is full HTML page (contains
<!DOCTYPE>,<html>,<head>) - If so, returns helpful error message instead
This catches common mistakes:
- Using
View()instead ofSwapView() - Error pages returning full layout for HTMX requests
- Accidental layout rendering
Navigation Pattern
Explicit HTMX Attributes (not hx-boost):
<a href="/Articles/Details/1"
hx-get="/Articles/Details/1"
hx-target="#main-content"
hx-push-url="true">
View Article
</a>
Why explicit over hx-boost:
- ✅ Clear intent - you see exactly what each link does
- ✅ No conflicts between
hx-boostand explicithx-target - ✅ Per-link control over targets and behavior
- ✅ Easier to debug and reason about
Nested Loading Pattern
For progressive enhancement:
<div id="articles-container">
<h1>Articles</h1>
<div hx-get="/Articles/List"
hx-trigger="load">
Loading...
</div>
</div>
Benefits:
- Fast initial page load
- Progressive content loading
- Better perceived performance
- Graceful degradation (content still loads without JS)
Extension Methods
Request Detection
// Check if request is from HTMX
if (Request.IsHtmxRequest())
{
// Handle HTMX-specific logic
}
// Check if request is boosted
if (Request.IsHtmxBoosted())
{
// Handle boosted request
}
// Get HTMX headers
var currentUrl = Request.GetHtmxCurrentUrl();
var target = Request.GetHtmxTarget();
var trigger = Request.GetHtmxTrigger();
Response Headers
// Trigger client-side event
Response.HxTrigger("itemCreated");
// Trigger with JSON details
Response.HxTriggerWithDetails("{\"showMessage\": {\"level\": \"info\"}}");
// Push URL to browser history
Response.HxPushUrl($"/articles/{article.Id}");
// Replace URL in history
Response.HxReplaceUrl($"/articles/{article.Id}");
// Client-side redirect
Response.HxRedirect("/login");
// Force full page refresh
Response.HxRefresh();
// Change target element
Response.HxRetarget("#notification-area");
// Change swap strategy
Response.HxReswap("beforebegin");
Architecture Benefits
Rigid Framework (Good Thing!)
Unlike copying code, using this package as a framework provides:
- Consistency: All controllers work the same way
- Upgrades: Bug fixes and improvements via package updates
- Best Practices: Enforces correct HTMX patterns
- Team Alignment: Everyone uses same approach
- Less Boilerplate: No repeated HX-Request checks
When to Use Embedded Code
Use --embed-htmx flag in Swap CLI if you need:
- Custom behavior beyond framework capabilities
- Full control over every detail
- No package dependencies
- Maximum flexibility
But for most apps, the package is better:
- Less code to maintain
- Automatic improvements
- Proven patterns
- Easier onboarding
Common Patterns
Form Submission
[HttpPost]
public async Task<IActionResult> Create(Article article)
{
if (!ModelState.IsValid)
{
return SwapView("Create", article); // Re-render form with errors
}
_context.Articles.Add(article);
await _context.SaveChangesAsync();
Response.HxTrigger("articleCreated"); // Notify client
Response.HxPushUrl($"/articles/{article.Id}");
return SwapView("Details", article);
}
Delete with Confirmation
[HttpDelete]
public async Task<IActionResult> Delete(int id)
{
var article = await _context.Articles.FindAsync(id);
if (article == null) return NotFound();
_context.Articles.Remove(article);
await _context.SaveChangesAsync();
Response.HxTrigger("articleDeleted");
Response.HxRedirect("/articles"); // Redirect after delete
return Ok();
}
Inline Editing
public async Task<IActionResult> EditInline(int id)
{
var article = await _context.Articles.FindAsync(id);
return SwapView("_EditForm", article); // Return form partial
}
[HttpPut]
public async Task<IActionResult> UpdateInline(int id, Article article)
{
if (!ModelState.IsValid)
return SwapView("_EditForm", article);
_context.Update(article);
await _context.SaveChangesAsync();
Response.HxTrigger("articleUpdated");
return SwapView("_ArticleCard", article); // Return updated card
}
Debugging
Middleware Error
If you see "HTMX Shell Middleware Error", check:
- Controller inherits from
SwapController - Using
SwapView()instead ofView() - Partial views don't specify layout
- Error handling returns partials for HTMX requests
Full Page Returned
If HTMX requests get full pages:
- Check
HX-Requestheader in browser DevTools - Verify middleware is registered:
app.UseSwapHtmxShell() - Ensure middleware comes after
UseRouting() - Check controller inherits
SwapController
Content Not Swapping
If links don't work:
- Verify HTMX script is loaded
- Check
hx-targetselector is correct - Ensure target element exists in DOM
- Check browser console for HTMX errors
Migration from Manual Implementation
If you have existing code checking HX-Request:
Before:
public async Task<IActionResult> Index()
{
var articles = await _context.Articles.ToListAsync();
if (Request.Headers.ContainsKey("HX-Request"))
return PartialView(articles);
else
return View(articles);
}
After:
public async Task<IActionResult> Index()
{
var articles = await _context.Articles.ToListAsync();
return SwapView(articles); // That's it!
}
Change:
- Controller:
Controller→SwapController - Return:
View()/PartialView()→SwapView() - Remove: Manual
HX-Requestchecks
Contributing
Contributions are welcome! Please see the main Swap repository for contribution guidelines.
License
This project is licensed under the MIT License - see the LICENSE file for details.
Support
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net9.0 is compatible. net9.0-android was computed. net9.0-browser was computed. net9.0-ios was computed. net9.0-maccatalyst was computed. net9.0-macos was computed. net9.0-tvos was computed. net9.0-windows was computed. net10.0 was computed. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
-
net9.0
- No dependencies.
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.
| Version | Downloads | Last Updated |
|---|---|---|
| 1.0.6 | 112 | 12/11/2025 |
| 1.0.5 | 118 | 12/11/2025 |
| 1.0.4 | 144 | 12/10/2025 |
| 1.0.3 | 138 | 12/9/2025 |
| 1.0.2 | 193 | 12/5/2025 |
| 1.0.1 | 680 | 12/2/2025 |
| 1.0.0 | 521 | 11/27/2025 |
| 0.14.0 | 164 | 11/26/2025 |
| 0.13.0 | 163 | 11/26/2025 |
| 0.12.0 | 166 | 11/26/2025 |
| 0.11.4 | 170 | 11/25/2025 |
| 0.11.3 | 175 | 11/24/2025 |
| 0.11.2 | 344 | 11/21/2025 |
| 0.11.1 | 275 | 11/21/2025 |
| 0.11.0 | 300 | 11/21/2025 |
| 0.10.0 | 341 | 11/21/2025 |
| 0.9.1 | 385 | 11/20/2025 |
| 0.9.0 | 379 | 11/20/2025 |
| 0.8.2 | 386 | 11/20/2025 |
| 0.8.1 | 388 | 11/20/2025 |
| 0.8.0 | 379 | 11/20/2025 |
| 0.7.1 | 381 | 11/20/2025 |
| 0.7.0 | 385 | 11/20/2025 |
| 0.6.0 | 386 | 11/20/2025 |
| 0.5.1 | 384 | 11/19/2025 |
| 0.5.0 | 317 | 11/17/2025 |
| 0.4.1 | 321 | 11/17/2025 |
| 0.4.0 | 266 | 11/16/2025 |
| 0.3.5 | 242 | 11/14/2025 |
| 0.3.4 | 281 | 11/12/2025 |
| 0.3.3 | 264 | 11/12/2025 |
| 0.3.2 | 275 | 11/11/2025 |
| 0.3.1 | 189 | 11/6/2025 |
| 0.3.0 | 190 | 11/3/2025 |
| 0.2.0-dev | 120 | 11/1/2025 |
| 0.1.0 | 177 | 10/30/2025 |