HTMX helper library for ASP.NET Core MVC applications. Provides SwapController base class, middleware, event helpers, and extension methods for seamless HTMX integration. (Realtime features moved to Swap.Htmx.Realtime.)
$ dotnet add package Swap.HtmxHTMX + ASP.NET Core, made simple.
Build interactive web apps with server-rendered HTML. No JavaScript frameworks, no complex state management, no build tools.
dotnet add package Swap.Htmx
Optional (realtime + Redis):
dotnet add package Swap.Htmx.Realtime
dotnet add package Swap.Htmx.Realtime.Redis
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllersWithViews();
builder.Services.AddSwapHtmx(); // ← Add this
var app = builder.Build();
app.UseStaticFiles();
app.UseRouting();
app.UseSwapHtmx(); // ← Add this
app.MapControllers();
app.Run();
<head>
<link rel="stylesheet" href="~/_content/Swap.Htmx/css/swap.css" />
<script src="https://unpkg.com/htmx.org@2.0.8"></script>
<script src="~/_content/Swap.Htmx/js/swap.client.js"></script>
</head>
<body>
@RenderBody()
</body>
public class ProductsController : SwapController
{
public IActionResult Index()
{
var products = GetProducts();
return SwapView(products); // Auto-detects HTMX vs full page
}
[HttpPost]
public IActionResult Add(Product product)
{
SaveProduct(product);
return SwapResponse()
.WithView("_ProductRow", product) // Main response
.AlsoUpdate("product-count", "_Count", GetCount()) // OOB update
.WithSuccessToast("Product added!") // Toast notification
.Build();
}
}
That's it! You're ready to build interactive UIs.
SwapController auto-detects HTMX requests:
public class HomeController : SwapController
{
public IActionResult Index()
{
// Normal request → View with layout
// HTMX request → PartialView (no layout)
return SwapView(model);
}
}Don't want to inherit? Use extensions:
public class HomeController : Controller
{
public IActionResult Index()
{
return this.SwapView(model); // Extension method
}
}For complex responses with multiple updates:
return SwapResponse()
.WithView("_MainContent", model) // Primary response
.AlsoUpdate("sidebar", "_Sidebar", sidebar) // OOB swap
.AlsoUpdate("header", "_Header", header) // Another OOB
.WithSuccessToast("Done!") // Toast
.WithTrigger("dataUpdated") // Client event
.Build();SPA-style navigation without JavaScript:
<!-- Instead of this verbose HTMX: -->
<a href="/products" hx-get="/products" hx-target="#main" hx-push-url="true">Products</a>
<!-- Use this: -->
<swap-nav to="/products">Products</swap-nav>Configure the default target:
builder.Services.AddSwapHtmx(options =>
{
options.DefaultNavigationTarget = "#main-content";
});Manage UI state with hidden fields—no JavaScript state management:
Define state:
public class FilterState : SwapState
{
public string Category { get; set; } = "all";
public int Page { get; set; } = 1;
public string? Search { get; set; }
}Render in view:
<swap-state state="Model.State" />
<input type="text"
name="Search"
value="@Model.State.Search"
hx-get="/Products/Filter?Page=1"
hx-target="#results"
hx-include="#filter-state"
hx-trigger="keyup changed delay:300ms" />Bind in controller:
[HttpGet]
public IActionResult Filter([FromSwapState] FilterState state)
{
var products = GetProducts(state.Category, state.Search, state.Page);
return PartialView("_ProductList", new ViewModel { State = state, Products = products });
}Key Pattern: URL parameters override hidden fields (first value wins).
Three approaches, from simple to powerful:
// You know exactly what to update
return SwapResponse()
.WithView("_Item", item)
.AlsoUpdate("count", "_Count", count)
.Build();// Configure once in Program.cs
builder.Services.AddSwapHtmx(events =>
{
events.When(CartEvents.ItemAdded)
.RefreshPartial("cart-count", "_CartCount")
.RefreshPartial("cart-total", "_CartTotal");
});
// Controller just fires event
return SwapEvent(CartEvents.ItemAdded, item).WithView("_Added", item).Build();// Define events
public static class TaskEvents
{
public static readonly EventKey Completed = new("task.completed");
}
// Handler updates stats (DI supported!)
[SwapHandler(typeof(TaskEvents), nameof(TaskEvents.Completed))]
public class StatsHandler : ISwapEventHandler<TaskPayload>
{
private readonly IStatsService _stats;
public StatsHandler(IStatsService stats) => _stats = stats;
public void Handle(SwapEventContext<TaskPayload> context)
{
var stats = _stats.Calculate();
context.Response.AlsoUpdate("stats-panel", "_Stats", stats);
}
}
// Handler updates activity feed
[SwapHandler(typeof(TaskEvents), nameof(TaskEvents.Completed))]
public class ActivityHandler : ISwapEventHandler<TaskPayload>
{
public void Handle(SwapEventContext<TaskPayload> context)
{
context.Response.AlsoUpdate("activity", "_Activity", GetRecent());
}
}
// Controller stays thin
public IActionResult Complete(int id)
{
var task = _service.Complete(id);
return SwapEvent(TaskEvents.Completed, new TaskPayload(task))
.WithView("_TaskCompleted", task)
.Build();
}
// One event → multiple handlers → one response with all updatesIf you’re using SSE/WebSockets, see: 📖 Event Naming & Realtime Routing
✅ Use OOB for related updates:
// Add to cart → update count AND total
return SwapResponse()
.WithView("_ProductAdded", product)
.AlsoUpdate("cart-count", "_Count", count)
.AlsoUpdate("cart-total", "_Total", total)
.Build();❌ Don't stuff unrelated updates:
// BAD: Kitchen sink response
return SwapResponse()
.WithView("_Item", item)
.AlsoUpdate("header", "_Header", header)
.AlsoUpdate("sidebar", "_Sidebar", sidebar)
.AlsoUpdate("footer", "_Footer", footer)
.AlsoUpdate("notifications", "_Notifications", notifications)
// ... 10 more unrelated things
.Build();Instead: Use event handlers or let components refresh themselves:
<div hx-get="/notifications" hx-trigger="load, every 30s"></div>OOB swaps render in parallel. When a response contains multiple AlsoUpdate() calls, all partial views are rendered concurrently via Task.WhenAll(). A dashboard with 12+ OOB swaps benefits from this — ordering is preserved.
Target IDs are validated. IDs must start with a letter and contain only letters, digits, hyphens, and underscores. Invalid IDs (XSS payloads, empty strings, special characters) throw ArgumentException at build time, not at render time.
Redirect URLs are validated. WithRedirect() and WithNavigation() reject javascript:, data:, and vbscript: URL schemes.
| Feature | Usage |
|---|---|
| Auto HTMX detection | SwapView() / this.SwapView() |
| Multiple updates | SwapResponse().AlsoUpdate() |
| SPA navigation | <swap-nav to="/path"> |
| State management | <swap-state> + [FromSwapState] |
| Toast notifications | .WithSuccessToast(), .WithErrorToast() |
| Client events | .WithTrigger("eventName") |
| Event handlers | ISwapEventHandler<T> |
| Form validation | <swap-validation> + SwapValidationErrors() |
| Real-time (SSE) | ServerSentEvents() |
| Real-time (WebSocket) | WebSocket registry |
| Source generators | [SwapEventSource], auto SwapViews/SwapElements |
Eliminate magic strings with compile-time code generation:
// Define your events
[SwapEventSource]
public static partial class CartEvents
{
public const string ItemAdded = "cart.itemAdded";
public const string CheckoutCompleted = "cart.checkoutCompleted";
}
// Generated at build time:
// CartEvents.Cart.ItemAdded → EventKey("cart.itemAdded")
// CartEvents.Cart.CheckoutCompleted → EventKey("cart.checkoutCompleted")
// Use in controller
return SwapEvent(CartEvents.Cart.ItemAdded, item).Build();With zero configuration, the generators scan your .cshtml files and group by controller folder:
// Auto-generated from your views
public static class SwapViews
{
public static class Products
{
public const string Index = "Index";
public const string Details = "Details";
public const string _Grid = "_Grid"; // Partials keep underscore
public const string _Pagination = "_Pagination";
}
}
public static class SwapElements
{
public const string ProductGrid = "product-grid";
public const string CartCount = "cart-count";
}
// Use instead of magic strings
builder.AlsoUpdate(SwapElements.CartCount, SwapViews.Cart._Count, count);No configuration required! The Swap.Htmx.targets auto-includes common folders:
Views/**/*.cshtmlModules/**/Views/**/*.cshtmlPages/**/*.cshtmlComponents/**/*.cshtmlAreas/**/Views/**/*.cshtmlJust reference Swap.Htmx and build — views are scanned automatically.
Optional: To inspect generated code:
<PropertyGroup>
<EmitCompilerGeneratedFiles>true</EmitCompilerGeneratedFiles>
<CompilerGeneratedFilesOutputPath>obj\Generated</CompilerGeneratedFilesOutputPath>
</PropertyGroup>
<ItemGroup>
<Compile Remove="obj\Generated\**\*.cs" />
</ItemGroup>The HandlerValidationAnalyzer warns you about:
SWAP001: Events without handlersSWAP002: Undefined event keysSWAP003: Circular event chains| Guide | Description |
|---|---|
| Getting Started | Full setup walkthrough |
| Public API & Compatibility | What is stable vs experimental |
| Security Checklist | CSRF, realtime auth, room scoping, headers |
| SwapState | Server-driven state management |
| Events | Event system deep dive |
| Navigation | SPA-style navigation |
| Patterns | Common patterns cheatsheet |
| Real-time | SSE & WebSocket |
| Validation | Form validation |
| Demo | Description |
|---|---|
| SwapStateDemo | State management patterns |
| SwapLab | Pattern showcase |
| SwapShop | E-commerce example |
| SwapDashboard | Dashboard with events |
| SwapSmallPartials | Complex UI orchestration |
MIT License - see LICENSE