Swap.Htmx 0.6.0

There is a newer version of this package available.
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
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="Swap.Htmx" Version="0.6.0" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="Swap.Htmx" Version="0.6.0" />
                    
Directory.Packages.props
<PackageReference Include="Swap.Htmx" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add Swap.Htmx --version 0.6.0
                    
#r "nuget: Swap.Htmx, 0.6.0"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package Swap.Htmx@0.6.0
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=Swap.Htmx&version=0.6.0
                    
Install as a Cake Addin
#tool nuget:?package=Swap.Htmx&version=0.6.0
                    
Install as a Cake Tool

Swap.Htmx

NuGet

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-Trigger headers 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 render
  • AlsoUpdate(targetId, viewName, model, swapMode) - Add out-of-band swap
  • WithSuccessToast(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 element
  • SwapMode.InnerHTML - Replace inner content only
  • SwapMode.BeforeBegin / AfterBegin / BeforeEnd / AfterEnd - Insert content
  • SwapMode.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

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 partial
  • SuccessToast(message) / ErrorToast(message) / WarningToast(message) / InfoToast(message) - Show toast
  • AlsoTrigger(eventKey) - Trigger additional client-side events
  • Build() - 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

License

MIT

Product 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. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.
  • 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