Swap.Htmx
0.6.0
See the version list below for details.
dotnet add package Swap.Htmx --version 0.6.0
NuGet\Install-Package Swap.Htmx -Version 0.6.0
<PackageReference Include="Swap.Htmx" Version="0.6.0" />
<PackageVersion Include="Swap.Htmx" Version="0.6.0" />
<PackageReference Include="Swap.Htmx" />
paket add Swap.Htmx --version 0.6.0
#r "nuget: Swap.Htmx, 0.6.0"
#:package Swap.Htmx@0.6.0
#addin nuget:?package=Swap.Htmx&version=0.6.0
#tool nuget:?package=Swap.Htmx&version=0.6.0
Swap.Htmx
Swap.Htmx is a lightweight library that makes it easy to build HTMX-powered ASP.NET Core applications with a clean, fluent API.
Key Features
- Fluent response builder - Coordinate view rendering, out-of-band swaps, toasts, and triggers in one clean chain
- Type-safe API - No magic strings for swap modes or event names
- SwapController base class - Automatically handles HTMX requests vs full page loads
- Real-time updates with SSE - Built-in Server-Sent Events support with automatic connection management, room-based broadcasting, and seamless HTMX integration
- Event system - Build
HX-Triggerheaders declaratively and chain complex UI updates
Install
dotnet add package Swap.Htmx
Setup
Add the following to your _Layout.cshtml to enable the Toast system and client-side event handling:
<head>
<link rel="stylesheet" href="~/_content/Swap.Htmx/css/swap.css" />
<script src="~/_content/Swap.Htmx/js/swap.js"></script>
</head>
Three Ways to Build Responses
1. Simple View (80% of use cases)
For single updates with automatic partial/full view detection:
public class ProductsController : SwapController
{
public IActionResult Details(int id)
{
var product = _db.Products.Find(id);
return SwapView("Details", product);
// Returns full view for normal requests
// Returns partial for HTMX requests
}
}
2. Coordinated Updates (Manual Control)
When you need to update multiple parts of the page:
public class CartController : SwapController
{
public ActionResult AddToCart(int productId)
{
_cart.Add(productId);
return SwapResponse()
.WithView(CartViews.ProductAdded)
.AlsoUpdate(CartElements.Count, CartViews.Count, _cart.Count, SwapMode.InnerHTML)
.AlsoUpdate(CartElements.Total, CartViews.Total, _cart.Total)
.WithSuccessToast("Added to cart!")
.WithTrigger(CartEvents.Updated, new { itemCount = _cart.Count });
}
}
Available methods:
WithView(viewName, model)- Set the main view to renderAlsoUpdate(targetId, viewName, model, swapMode)- Add out-of-band swapWithSuccessToast(message)/WithErrorToast(message)/WithWarningToast(message)/WithInfoToast(message)WithTrigger(eventName, payload)- Add custom HX-Trigger event
Note: All triggers and toasts are automatically merged into a single HX-Trigger header. Multiple calls to WithTrigger and toast methods will create a JSON object containing all events.
SwapMode options:
SwapMode.OuterHTML(default) - Replace entire elementSwapMode.InnerHTML- Replace inner content onlySwapMode.BeforeBegin/AfterBegin/BeforeEnd/AfterEnd- Insert contentSwapMode.Delete- Remove the element
3. Event-Driven (Configuration-Based Updates)
For complex apps where multiple controllers need coordinated updates, configure event chains once and trigger them anywhere:
// Define constants for element IDs and view names (avoids magic strings)
public static class ProductViews
{
public const string List = "_ProductList";
public const string Count = "_ProductCount";
}
public static class ProductElements
{
public const string List = "product-list";
public const string Count = "product-count";
}
// Configuration in Program.cs
builder.Services.AddSwapHtmx(events =>
{
events.When(SwapEvents.Entity.Created("Product"))
.RefreshPartial(ProductElements.List, ProductViews.List, ctx => GetProducts(ctx))
.RefreshPartial(ProductElements.Count, ProductViews.Count, ctx => GetProductCount(ctx))
.SuccessToast("Product created!");
});
4. Composition Over Inheritance (New in v1.2)
You don't have to inherit from SwapController. You can use standard ASP.NET Core controllers and access all Swap features via extension methods:
using Swap.Htmx; // Import extension methods
public class ReviewsController : Controller
{
[HttpPost]
public IActionResult Add(Review review)
{
if (!ModelState.IsValid)
{
// New in v1.3: First-class validation support
return this.SwapValidationErrors(ModelState)
.AlsoUpdate("review-form", "_ReviewForm", review)
.Build();
}
_service.Add(review);
// Use extension methods on 'this' (ControllerBase)
return this.SwapResponse()
.WithSuccessToast("Review added!")
.WithTrigger(ReviewEvents.Added, review)
.Build();
}
[HttpGet]
public IActionResult List(int productId)
{
var reviews = _service.GetByProductId(productId);
// Use SwapView extension to handle partial/full view automatically
return this.SwapView("_ReviewList", reviews);
}
}
Documentation
- Getting Started
- Events & Triggers
- Event Chains
- Out-of-Band Swaps
- Server-Sent Events
- Debugging & Logging
License
MIT
events.When(SwapEvents.Entity.Updated("Product"))
.RefreshPartial(ProductElements.List, ProductViews.List, ctx => GetProducts(ctx))
.InfoToast("Product updated!");
events.When(SwapEvents.Entity.Deleted("Product"))
.RefreshPartial(ProductElements.List, ProductViews.List, ctx => GetProducts(ctx))
.RefreshPartial(ProductElements.Count, ProductViews.Count, ctx => GetProductCount(ctx))
.WarningToast("Product deleted!");
});
// In your controller - just emit the event public class ProductsController : SwapController { public IActionResult Create(Product product) { _db.Products.Add(product); _db.SaveChangesAsync();
// Event chain handles ALL UI updates based on configuration
return SwapEvent(SwapEvents.Entity.Created("Product"));
}
}
### Async Event Chains (v1.1)
Avoid thread starvation by using async model factories for database operations:
```csharp
// Configuration
events.When(ProductEvents.StockChecked)
.RefreshPartialAsync(ProductElements.Stock, ProductViews.Stock, async ctx =>
{
var service = ctx.RequestServices.GetRequiredService<IProductService>();
return await service.GetStockAsync(); // Safe async execution
});
// Controller
public async Task<IActionResult> CheckStock(int id)
{
// ... logic ...
return await SwapEventAsync(ProductEvents.StockChecked);
}
Benefits:
- Centralized UI update configuration
- No repetition across controllers
- Easy to understand what updates when an event fires
- Change UI behavior without touching controller code
Event chain builder methods:
RefreshPartial(targetId, viewName, modelFactory, swapMode)- Render and swap a partialSuccessToast(message)/ErrorToast(message)/WarningToast(message)/InfoToast(message)- Show toastAlsoTrigger(eventKey)- Trigger additional client-side eventsBuild()- Complete the chain configuration
See Events Documentation for more details on type-safe events.
Setup
Basic Configuration
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllersWithViews();
builder.Services.AddSwapHtmx(); // Registers core services
var app = builder.Build();
app.UseSwapHtmx(); // Adds middleware for event handling
app.MapControllers();
app.Run();
With Server-Sent Events
builder.Services
.AddSwapHtmx()
.AddSseEventBridge(); // Enables SSE with connection management
app.UseSwapHtmx(); // Handles both HTTP responses and SSE broadcasts
Server-Sent Events (SSE)
Stream real-time HTML updates to connected clients:
Basic SSE
public const string NotificationView = "_Notification";
[HttpGet("/sse/notifications")]
public IActionResult StreamNotifications()
{
return ServerSentEvents(async (connection, ct) =>
{
while (!ct.IsCancellationRequested)
{
var html = await RenderPartialToStringAsync(NotificationView, GetLatestNotification());
await connection.SendEventAsync("notification", html);
await Task.Delay(5000, ct);
}
});
}
Enhanced SSE with Connection Management
public static class SseRooms
{
public const string Dashboard = "dashboard";
}
public static class SseEventNames
{
public const string MetricsUpdated = "metrics-updated";
}
[HttpGet("/sse/dashboard")]
public IActionResult Dashboard()
{
return EnhancedServerSentEvents(async (builder, ct) =>
{
var connection = builder.Connection;
// Join rooms for targeted broadcasting
connection.JoinRoom(SseRooms.Dashboard);
connection.SubscribeToEvent(SseEventNames.MetricsUpdated);
// Keep connection alive
while (!ct.IsCancellationRequested)
{
await Task.Delay(30000, ct);
}
});
}
Manual HTMX Helpers
For low-level control, extension methods are available:
// Check if request is from HTMX
if (Request.IsHtmxRequest())
{
// Set response headers
Response.HxTrigger("todoCreated");
Response.HxTrigger("todoCreated", new { id = 123 });
Response.HxRedirect("/todos");
Response.HxRefresh();
}
Dev Tooling
Visualize event chains and inspect SSE connections in development:
app.MapSwapHtmxDevEndpoints(); // Available at /_swap/dev/*
Migration from Manual Patterns
Before (Manual Coordination)
public static class TodoViews { public const string Todo = "_Todo"; }
public static class TodoElements { public const string Counter = "counter"; public const string Status = "status"; }
public static class TodoEvents { public static readonly EventKey Created = new("todo.created"); }
public IActionResult Create(TodoInput input)
{
var todo = _service.Create(input);
Response.ShowSuccessToast("Todo created!");
Response.HxTrigger(TodoEvents.Created.Name);
ViewData["OobCounter"] = RenderOobPanel(TodoElements.Counter, GetCount());
ViewData["OobStatus"] = RenderOobPanel(TodoElements.Status, "Active");
return SwapView(TodoViews.Todo, todo);
}
After (Fluent Builder)
public static class TodoViews
{
public const string Todo = "_Todo";
public const string Counter = "_Counter";
public const string Status = "_Status";
}
public ActionResult Create(TodoInput input)
{
var todo = _service.Create(input);
return SwapResponse()
.WithView(TodoViews.Todo, todo)
.AlsoUpdate(TodoElements.Counter, TodoViews.Counter, GetCount(), SwapMode.InnerHTML)
.AlsoUpdate(TodoElements.Status, TodoViews.Status, "Active")
.WithSuccessToast("Todo created!")
.WithTrigger(TodoEvents.Created);
}
Examples
Complete Product Management Example
This example shows all three approaches in a single controller:
// Constants for type safety (no magic strings!)
public static class ProductViews
{
public const string List = "_ProductList";
public const string Count = "_ProductCount";
public const string Added = "_ProductAdded";
}
public static class ProductElements
{
public const string List = "product-list";
public const string Count = "product-count";
}
public static class CartElements
{
public const string Badge = "cart-badge";
public const string Total = "cart-total";
}
public static class CartViews
{
public const string Badge = "_CartBadge";
public const string Total = "_CartTotal";
}
// 1. Configure event chains (Program.cs)
builder.Services.AddSwapHtmx(events =>
{
events.When(SwapEvents.Entity.Created("Product"))
.RefreshPartial(ProductElements.List, ProductViews.List, ctx =>
ctx.RequestServices.GetRequiredService<ProductService>().GetAll())
.RefreshPartial(ProductElements.Count, ProductViews.Count, ctx =>
ctx.RequestServices.GetRequiredService<ProductService>().GetCount())
.SuccessToast("Product created successfully!");
});
// 2. Controller using all three approaches
public class ProductsController : SwapController
{
private readonly ProductService _service;
public ProductsController(ProductService service) => _service = service;
// Approach 1: Simple view (list page)
public IActionResult Index()
{
var products = _service.GetAll();
return SwapView(products);
// Auto-detects: full page on first load, partial on HTMX requests
}
// Approach 2: Coordinated updates (manual control for cart)
public IActionResult AddToCart(int id)
{
var product = _service.Get(id);
_cart.Add(product);
return SwapResponse()
.WithView(ProductViews.Added, product)
.AlsoUpdate(CartElements.Badge, CartViews.Badge, _cart.ItemCount, SwapMode.InnerHTML)
.AlsoUpdate(CartElements.Total, CartViews.Total, _cart.Total)
.WithSuccessToast($"{product.Name} added to cart!")
.WithTrigger(SwapEvents.UI.UpdateCounter, new { count = _cart.ItemCount });
}
// Approach 3: Event-driven (DRY for repeated patterns)
public IActionResult Create(Product product)
{
_service.Create(product);
// All UI updates happen automatically via configured event chain
return SwapEvent(SwapEvents.Entity.Created("Product"));
}
}
Migration from Manual Patterns
Before (manual ViewData + Response headers):
public IActionResult Create(Todo todo)
{
_db.Todos.Add(todo);
_db.SaveChangesAsync();
// Scattered UI logic with magic strings everywhere
ViewData["OobTodoList"] = await RenderPartialAsync("_TodoList", _db.Todos.ToList());
ViewData["OobTodoCount"] = await RenderPartialAsync("_TodoCount", _db.Todos.Count());
Response.AddSuccessToast("Todo created!");
Response.AddTrigger("todoCreated");
return PartialView("_TodoCreated", todo);
}
After (fluent API with type-safe constants):
// Define once, use everywhere
public static class TodoViews
{
public const string Created = "_TodoCreated";
public const string List = "_TodoList";
public const string Count = "_TodoCount";
}
public static class TodoElements
{
public const string List = "todo-list";
public const string Count = "todo-count";
}
public IActionResult Create(Todo todo)
{
_db.Todos.Add(todo);
_db.SaveChangesAsync();
// Clean, discoverable, type-safe - no magic strings!
return SwapResponse()
.WithView(TodoViews.Created, todo)
.AlsoUpdate(TodoElements.List, TodoViews.List, _db.Todos.ToList())
.AlsoUpdate(TodoElements.Count, TodoViews.Count, _db.Todos.Count())
.WithSuccessToast("Todo created!")
.WithTrigger(SwapEvents.UI.RefreshList);
}
For complete working examples, see the Swap.Htmx.TestApp project in this repository.
Documentation
- Getting Started Guide - Step-by-step tutorial for building your first HTMX app
- Out-of-Band Swaps Guide - Complete guide to multi-part page updates
- Server-Sent Events Guide - Complete guide to real-time updates with SSE
- Type-Safe Events Guide - Learn how to define and use strongly-typed event keys to eliminate magic strings
- Event Chains Guide - Configure automatic UI updates when events are triggered
- Debugging & Logging - Enable debug logging to see event chains, toasts, and HTTP headers during development
License
MIT
| 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 | 20 | 12/11/2025 |
| 1.0.5 | 31 | 12/11/2025 |
| 1.0.4 | 59 | 12/10/2025 |
| 1.0.3 | 53 | 12/9/2025 |
| 1.0.2 | 173 | 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 | 383 | 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 |