Swap.Htmx 0.2.0-dev

This is a prerelease version of Swap.Htmx.
There is a newer version of this package available.
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
                    
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.2.0-dev" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="Swap.Htmx" Version="0.2.0-dev" />
                    
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.2.0-dev
                    
#r "nuget: Swap.Htmx, 0.2.0-dev"
                    
#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.2.0-dev
                    
#: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.2.0-dev&prerelease
                    
Install as a Cake Addin
#tool nuget:?package=Swap.Htmx&version=0.2.0-dev&prerelease
                    
Install as a Cake Tool

Swap.Htmx

NuGet License: MIT

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-Request header (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 of SwapView()
  • Error pages returning full layout for HTMX requests
  • Accidental layout rendering

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-boost and explicit hx-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:

  1. Consistency: All controllers work the same way
  2. Upgrades: Bug fixes and improvements via package updates
  3. Best Practices: Enforces correct HTMX patterns
  4. Team Alignment: Everyone uses same approach
  5. 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:

  1. Controller inherits from SwapController
  2. Using SwapView() instead of View()
  3. Partial views don't specify layout
  4. Error handling returns partials for HTMX requests

Full Page Returned

If HTMX requests get full pages:

  1. Check HX-Request header in browser DevTools
  2. Verify middleware is registered: app.UseSwapHtmxShell()
  3. Ensure middleware comes after UseRouting()
  4. Check controller inherits SwapController

Content Not Swapping

If links don't work:

  1. Verify HTMX script is loaded
  2. Check hx-target selector is correct
  3. Ensure target element exists in DOM
  4. 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:

  1. Controller: ControllerSwapController
  2. Return: View() / PartialView()SwapView()
  3. Remove: Manual HX-Request checks

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 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 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