Provides HTMX integration for ASP.NET Core applications.
- HtmxToolkit
Add the Ramstack.HtmxToolkit NuGet package
to your project with the following command:
dotnet add package Ramstack.HtmxToolkitRegister the toolkit. HTMX 2.x is used by default:
builder.Services.AddHtmxToolkit();To select another major version or override HTMX defaults, see
HtmxConfigTagHelper.
The library provides the HttpRequestExtensions class for working with HttpRequest.
/// <summary>
/// Provides extension methods for the <see cref="HttpRequest"/> class.
/// </summary>
public static class HttpRequestExtensions
{
/// <summary>
/// Determines whether the specified HTTP request is an HTMX request.
/// </summary>
/// <param name="request">The HTTP request.</param>
/// <returns>
/// <see langword="true" /> if the specified HTTP request is an HTMX request;
/// otherwise, <see langword="false" />.
/// </returns>
public static bool IsHtmxRequest(this HttpRequest request);
/// <summary>
/// Determines whether the specified HTTP request is an HTMX request.
/// </summary>
/// <param name="request">The HTTP request.</param>
/// <param name="headers">When this method returns, contains the <see cref="HtmxRequestHeaders"/>
/// that provides access to well-known HTMX headers.</param>
/// <returns>
/// <see langword="true" /> if the specified HTTP request is an HTMX request; otherwise, <see langword="false" />.
/// </returns>
public static bool IsHtmxRequest(this HttpRequest request, out HtmxRequestHeaders headers);
/// <summary>
/// Determines whether the specified HTTP request was made using AJAX
/// instead of a normal navigation.
/// </summary>
/// <param name="request">The HTTP request.</param>
/// <returns>
/// <see langword="true" /> if the specified HTTP request is boosted; otherwise, <see langword="false" />.
/// </returns>
public static bool IsHtmxBoosted(this HttpRequest request);
/// <summary>
/// Determines whether the specified HTTP request was made using AJAX
/// instead of a normal navigation.
/// </summary>
/// <param name="request">The HTTP request.</param>
/// <param name="headers">When this method returns, contains the <see cref="HtmxRequestHeaders"/>
/// that provides access to well-known HTMX headers.</param>
/// <returns>
/// <see langword="true" /> if the specified HTTP request is boosted; otherwise, <see langword="false" />.
/// </returns>
public static bool IsHtmxBoosted(this HttpRequest request, out HtmxRequestHeaders headers);
/// <summary>
/// Returns a strongly typed view of the HTMX request headers.
/// </summary>
/// <param name="request">The HTTP request.</param>
/// <returns>
/// The <see cref="HtmxRequestHeaders" />.
/// </returns>
public static HtmxRequestHeaders GetHtmxHeaders(this HttpRequest request);
}Use IsHtmxRequest to determine whether the current request was issued by HTMX.
HttpContext.Request.IsHtmxRequest()You can then handle HTMX and regular requests differently, for example:
if (Request.IsHtmxRequest())
return PartialView();
return View();The overloads with an out parameter also provide access to strongly typed headers set by HTMX:
if (Request.IsHtmxRequest(out var headers))
{
if (headers.HistoryRestoreRequest)
{
...
}
}You can also access strongly typed headers by calling GetHtmxHeaders:
var headers = Request.GetHtmxHeaders();The complete set of request header properties is shown below:
/// <summary>
/// Represents strongly typed HTMX request headers.
/// </summary>
public readonly struct HtmxRequestHeaders
{
/// <summary>
/// Gets a value indicating whether the request was made using AJAX instead of a normal navigation.
/// </summary>
public bool Boosted { get; }
/// <summary>
/// Gets the current URL of the browser.
/// </summary>
public string? CurrentUrl { get; }
/// <summary>
/// Gets a value indicating whether the request restores history after a miss in the local history cache.
/// </summary>
public bool HistoryRestoreRequest { get; }
/// <summary>
/// Gets the user's response to an <c>hx-prompt</c> on the client.
/// </summary>
public string? Prompt { get; }
/// <summary>
/// Gets a value indicating whether the current request is an HTMX request.
/// </summary>
public bool Request { get; }
/// <summary>
/// Gets the ID of the target element, if present.
/// </summary>
public string? Target { get; }
/// <summary>
/// Gets the name of the triggered element, if present.
/// </summary>
public string? TriggerName { get; }
/// <summary>
/// Gets the ID of the triggered element, if present.
/// </summary>
public string? Trigger { get; }
}For example:
if (Request.GetHtmxHeaders().HistoryRestoreRequest)
{
...
}The HtmxRequestHeaderNames class also provides constants for well-known request header names,
so you do not have to remember their exact spelling.
/// <summary>
/// Defines constants for the well-known names of HTMX request headers.
/// </summary>
/// <remarks>
/// For more information, see <see href="https://htmx.org/reference/#request_headers">HTMX Request Headers Reference</see>.
/// </remarks>
public static class HtmxRequestHeaderNames
{
/// <summary>
/// The <c>HX-Boosted</c> header indicates whether the request was made using AJAX
/// instead of a normal navigation.
/// </summary>
public const string Boosted = "HX-Boosted";
/// <summary>
/// The <c>HX-Current-URL</c> header contains the current URL of the browser.
/// </summary>
public const string CurrentUrl = "HX-Current-URL";
...
// The list of other constants is omitted for brevity
}To route HTMX requests to a specific controller action, apply the HtmxRequestAttribute
action constraint to that action:
public class UserController : ControllerBase
{
[HtmxRequest]
public IActionResult UpdateProfile(UserProfile profile)
{
...
}
}To match only boosted requests, set the Boosted property to true:
public class UserController : ControllerBase
{
...
[HtmxRequest(Boosted = true)]
public IActionResult UpdateProfile(UserProfile profile)
{
...
}
}For working with response headers, the library provides the HttpResponseExtensions class:
/// <summary>
/// Provides extension methods for the <see cref="HttpResponse"/> class.
/// </summary>
public static class HttpResponseExtensions
{
/// <summary>
/// Returns a strongly typed view of the HTMX response headers.
/// </summary>
/// <param name="response">The HTTP response.</param>
/// <returns>
/// The <see cref="HtmxResponseHeaders" />.
/// </returns>
public static HtmxResponseHeaders GetHtmxHeaders(this HttpResponse response);
/// <summary>
/// Configures the HTMX response headers.
/// </summary>
/// <param name="response">The HTTP response to configure.</param>
/// <param name="configure">The delegate that configures the HTMX response headers.</param>
public static void Htmx(this HttpResponse response, Action<HtmxResponse> configure);
/// <summary>
/// Configures the HTMX response headers.
/// </summary>
/// <param name="response">The HTTP response to configure.</param>
/// <param name="configure">The delegate that configures the HTMX response headers
/// using <paramref name="state" />.</param>
/// <param name="state">The state passed to <paramref name="configure" />.</param>
public static void Htmx<TState>(this HttpResponse response, Action<HtmxResponse, TState> configure, TState state);
}The GetHtmxHeaders method provides access to strongly typed response headers
that control HTMX behavior.
/// <summary>
/// Represents strongly typed HTMX response headers.
/// </summary>
public readonly struct HtmxResponseHeaders
{
/// <summary>
/// Gets or sets the value of the <c>HX-Location</c> header, which performs
/// a client-side redirect without a full-page reload.
/// </summary>
[MaybeNull]
public string Location { get; set; }
/// <summary>
/// Gets or sets the value of the <c>HX-Push-Url</c> header, which pushes a new URL
/// onto the browser's history stack.
/// </summary>
[MaybeNull]
public string PushUrl { get; set; }
...
// The remaining properties are omitted for brevity
}Just as HtmxRequestHeaderNames defines constants for HTMX request headers,
HtmxResponseHeaderNames defines constants for HTMX response headers.
/// <summary>
/// Defines constants for the well-known names of HTMX response headers.
/// </summary>
/// <remarks>
/// For more information, see <see href="https://htmx.org/reference/#response_headers">HTMX Response Headers Reference</see>.
/// </remarks>
public static class HtmxResponseHeaderNames
{
/// <summary>
/// The <c>HX-Location</c> header performs a client-side redirect without a full-page reload.
/// </summary>
public const string Location = "HX-Location";
/// <summary>
/// The <c>HX-Push-Url</c> header pushes a new URL onto the browser's history stack.
/// </summary>
public const string PushUrl = "HX-Push-Url";
/// <summary>
/// The <c>HX-Redirect</c> header performs a client-side redirect to a new location.
/// </summary>
public const string Redirect = "HX-Redirect";
...
// The list of other constants is omitted for brevity
}The most convenient approach is to use one of the HttpResponse.Htmx extension methods.
Its callback receives an HtmxResponse, allowing you to configure response headers in a fluent style:
Response.Htmx(h => h
.TriggerEvent(
eventName: "process",
detail: new { Value = ... }));TriggerEvent and TriggerEvents accept an optional HtmxTriggerTiming value.
In HTMX 1.x and 2.x, the value selects HX-Trigger, HX-Trigger-After-Swap, or
HX-Trigger-After-Settle. HTMX 4.x supports only HX-Trigger, so the toolkit
emits events requested for any timing through that header rather than dropping
them. These events run after the swap; in particular, the 1.x/2.x Receive and
AfterSettle timings cannot be preserved. See
htmx pull request #3900 for
the upstream timing change.
💡 The generic overload accepts an additional state parameter to avoid closure allocations:
Response.Htmx(
static (h, value) => h
.TriggerEvent(
eventName: "process",
detail: new { Value = value }),
ProcessValue);💡 The same API works in Minimal API handlers by binding HttpResponse:
app.MapGet("/profile", (HttpResponse response) =>
{
response.Htmx(h => h.Retarget("#profile"));
return TypedResults.Content("<div>Profile</div>", "text/html");
});In all these examples, headers are set only for an HTMX request. For a regular request,
the callback passed to Htmx is not executed, avoiding unnecessary work.
Some response headers can be set declaratively by applying HtmxResponseAttribute
to a controller or action. For example, an action that renders one new comment can append
it to the element targeted by the request:
public class CommentController : Controller
{
[HtmxRequest]
[HtmxResponse(Reswap = HtmxSwap.BeforeEnd)]
public IActionResult Add(CommentInput input)
{
var comment = ...;
return PartialView("_Comment", comment);
}
}💡 For a more complex swap expression, such as innerHTML show:#result:top,
use the Reswap overload that accepts a string.
/// <summary>
/// Sets the <c>HX-Reswap</c> header to specify how the response will be swapped.
/// </summary>
/// <param name="value">The swap style to assign to the header.</param>
/// <returns>
/// The current <see cref="HtmxResponse"/> instance.
/// </returns>
public HtmxResponse Reswap(HtmxSwap value);
/// <summary>
/// Sets the <c>HX-Reswap</c> header to specify how the response will be swapped.
/// </summary>
/// <param name="value">The header value to set.</param>
/// <returns>
/// The current <see cref="HtmxResponse"/> instance.
/// </returns>
public HtmxResponse Reswap(string value);For declarative configuration, HtmxResponseAttribute provides the ReswapExpression property:
/// <summary>
/// Gets or sets the complete <c>HX-Reswap</c> header value, including any swap modifiers.
/// </summary>
[MaybeNull]
public string ReswapExpression { get; set; }
/// <summary>
/// Gets or sets the swap style to specify in the <c>HX-Reswap</c> header.
/// </summary>
public HtmxSwap Reswap { get; set; }Use ReswapExpression when the strongly typed Reswap property is not flexible enough.
For server-controlled polling that works in every supported HTMX version, return
the polling element itself and replace it with outerHTML:
<div id="poll-status"
hx-get="/poll"
hx-trigger="load delay:1s"
hx-swap="outerHTML">
Polling...
</div>While polling should continue, return the same element with its request
attributes. To stop, return the element without hx-get and hx-trigger:
<div id="poll-status">
Polling stopped!
</div>This load-polling pattern gives the server control over every next request and
works with HTMX 1.9.x, 2.x, and 4.x. For an indefinitely updated status, use
hx-trigger="every 1s" instead.
HTMX 1.9.x and 2.x also recognize HTTP status code 286 as a fixed-rate polling
stop signal. Applications that target only those versions can opt into that legacy
behavior directly:
Response.StatusCode = 286;HTMX 4.x treats 286 as a regular successful response, so it is not exposed as a
toolkit API.
The library provides five tag helpers:
HtmxUrlTagHelperHtmxHeaderTagHelperHtmxValsTagHelperHtmxRequestTagHelperHtmxConfigTagHelper
To make them available in your project, add the @addTagHelper directive to a Razor view:
@addTagHelper *, Ramstack.HtmxToolkitTo make the tag helpers available throughout the application, add this line to
_ViewImports.cshtml, which is inherited by Razor views by default.
Import the toolkit namespace there as well if a view refers to toolkit types:
@using Ramstack.HtmxToolkitThe HtmxUrlTagHelper generates URLs for HTMX requests in much the same way that
the built-in ASP.NET Core tag helpers generate links. In most cases, replace the asp- prefix
with hx-:
<div hx-target="this">
<button hx-area="Sessions"
hx-controller="Speaker"
hx-action="Detail"
hx-route-id="@Model.SpeakerId">Show Info</button>
</div>The following code will be generated:
<div hx-target="this">
<button hx-get="/Sessions/Speaker/Detail/1">Show Info</button>
</div>If no HTMX method is specified, the tag helper uses hx-get. You can select a method with
hx-get, hx-post, hx-put, hx-delete, or hx-patch.
For instance, in the following example, we use hx-post:
<div hx-target="this">
<button hx-post
hx-area="Sessions"
hx-controller="Speaker"
hx-action="Detail"
hx-route-id="@Model.SpeakerId">Show Info</button>
</div>In this case, the following code will be generated:
<div hx-target="this">
<button hx-post="/Sessions/Speaker/Detail/1">Show Info</button>
</div>Use hx-page and hx-page-handler to generate a URL for a Razor Page handler:
<div hx-target="this">
<button hx-page="/Attendee"
hx-page-handler="Profile"
hx-route-attendeeid="1">Attendee Profile</button>
</div>The following code will be generated:
<div hx-target="this">
<button hx-get="/Attendee?handler=Profile&attendeeid=1">Attendee Profile</button>
</div>The hx-all-route-data attribute accepts an IDictionary<string, string> containing
additional route values:
@{
var parameters = new Dictionary<string, string>
{
["category"] = "science",
["pdf"] = "true"
};
}
<button hx-target="#result"
hx-action="List"
hx-all-route-data="parameters">Books</button>The following code will be generated:
<button hx-target="#result" hx-get="/Books/List?category=science&pdf=true">Books</button>The following URL-generation attributes are also available:
hx-hosthx-protocolhx-fragment
HTMX lets you add custom request headers through a JSON-valued attribute. Because writing and
escaping that JSON manually can be inconvenient, HtmxHeaderTagHelper provides a clearer format:
<div hx-action="Example"
hx-header-Key-1="Value-1"
hx-header-Key-2="Value-2">
Get some HTML and include custom headers in the request
</div>The following code will be generated:
<div hx-get="/Home/Example"
hx-headers='{"Key-1":"Value-1","Key-2":"Value-2"}'>
Get some HTML and include custom headers in the request
</div>You can also assign an IDictionary<string, string> to hx-all-headers:
@{
var headers = new Dictionary<string, string>
{
["Key-1"] = "Value-1",
["Key-2"] = "Value-2"
};
}
<div hx-action="Example"
hx-all-headers="headers">
Get some HTML and include custom headers in the request
</div>HtmxHeaderTagHelper handles JSON serialization and escaping.
The HtmxValsTagHelper adds values that HTMX includes with a request. Use hx-val-*
attributes instead of writing JSON manually:
<button hx-get="/books"
hx-val-category="science"
hx-val-format="summary">
Browse books
</button>The following HTML will be generated:
<button hx-get="/books"
hx-vals='{"category":"science","format":"summary"}'>
Browse books
</button>You can also assign an IDictionary<string, string> to hx-all-vals.
HtmxRequestTagHelper configures request options for the selected HTMX version.
For HTMX 1.9.x and 2.x, use typed hx-request-* attributes instead of writing hx-request
JSON manually:
<button hx-get="/reports"
hx-request-timeout="5000"
hx-request-credentials="@HtmxRequestCredentials.Include"
hx-request-no-headers="false">
Load report
</button>The following HTML will be generated:
<button hx-get="/reports"
hx-request='{"timeout":5000,"credentials":true,"noHeaders":false}'>
Load report
</button>For HTMX 4.x, the tag helper generates hx-config. In addition to timeout and credentials,
HTMX 4.x supports cache, redirect, referrer, integrity, and validate. The noHeaders
option is available only in HTMX 1.9.x and 2.x.
<button hx-get="/reports"
hx-request-timeout="5000"
hx-request-credentials="@HtmxRequestCredentials.Omit"
hx-request-cache="no-cache"
hx-request-validate="true">
Load report
</button>With HTMX 4.x selected, the following HTML will be generated:
<button hx-get="/reports"
hx-config='{"timeout":5000,"credentials":"omit","cache":"no-cache","validate":true}'>
Load report
</button>HTMX configuration is defined at application startup through AddHtmxToolkit.
The values apply application-wide and override HTMX defaults, so configure only behavior
the application relies on. For example, a form-oriented application can report native
validation failures before sending a request and scroll restored focus into view after a swap:
builder.Services.AddHtmxToolkit(options =>
{
options.UseHtmxV2(config =>
{
config.ReportValidityOfForms = true;
config.DefaultFocusScroll = true;
});
});Use the tag helper as a marker where the configuration meta element should be rendered:
<head>
<htmx-config />
</head>The following markup will be generated:
<head>
<meta name="htmx-config"
content='{"defaultFocusScroll":true,"reportValidityOfForms":true}'
data-antiforgery-request-token="..."
data-antiforgery-header-name="RequestVerificationToken"
data-antiforgery-form-field-name="__RequestVerificationToken" />
</head>The marker can also be written as a meta element:
<meta htmx-config />HTMX 2.x is selected by default. Use UseHtmxV1, UseHtmxV2, or UseHtmxV4 to select a
version explicitly. Each configuration type follows the names used by that HTMX version, so HTMX 1.9.x
and 2.x expose DefaultSwapStyle and Timeout, while HTMX 4.x exposes DefaultSwap and
DefaultTimeout. Selecting different versions in the same configuration throws an exception.
To target HTMX 4.x, select it explicitly:
builder.Services.AddHtmxToolkit(options => options.UseHtmxV4());The configured values remain available through dependency injection:
public sealed class ConfigurationInspector(IOptions<HtmxToolkitOptions> options)
{
public HtmxV2Config HtmxConfig =>
options.Value.GetHtmxConfig<HtmxV2Config>();
}HTMX 2.x introduces the responseHandling configuration option,
allowing you to define how HTMX should handle responses based on HTTP status codes. Rules are
configured in order through HtmxV2Config:
builder.Services.AddHtmxToolkit(options =>
{
options.UseHtmxV2(config =>
{
config.ResponseHandling =
[
new() { Code = "204", Swap = false },
new() { Code = "[23]..", Swap = true },
new() { Code = "422", Swap = true },
new() { Code = "[45]..", Swap = false, Error = true },
new() { Code = "...", Swap = true }
];
});
});HTMX 4.x removes responseHandling. To retain HTMX 2.x behavior that does not swap error
responses, configure NoSwap explicitly:
builder.Services.AddHtmxToolkit(options =>
{
options.UseHtmxV4(config =>
{
config.NoSwap = ["204", "304", "4xx", "5xx"];
});
});The toolkit script provides antiforgery support and HTMX compatibility behavior. Antiforgery metadata generation is enabled by default. Include the script to ensure the token is added to non-GET request form parameters or headers and refreshed in a timely manner.
Sending the token does not enable server-side validation by itself. Configure antiforgery validation for the corresponding ASP.NET Core endpoints as appropriate.
To disable antiforgery metadata generation—for example, when the application handles
antiforgery separately or does not issue unsafe HTMX requests—set the option to false:
builder.Services.AddHtmxToolkit(options =>
{
options.IncludeAntiforgeryToken = false;
});You can embed the minified script directly in a Razor view:
<script>
@Html.HtmxToolkitScript()
</script>Pass true to embed the debug version instead:
<script>
@Html.HtmxToolkitScript(debug: true)
</script>The minified version is used by default and is less than 1 KB.
The method returns a cached HtmlString, avoiding repeated conversions and allocations.
Alternatively, register an endpoint that serves the script:
app.UseAuthorization();
...
app.MapHtmxToolkitScript();
app.MapControllers();By default, the registered path is mapped to /htmxtoolkit/[sha1-hash],
where [sha1-hash] represents a precomputed hash of the script content.
The hash changes whenever the script changes, providing automatic cache invalidation.
To use a custom path, pass it to MapHtmxToolkitScript:
app.MapHtmxToolkitScript("/my-path");Then include the mapped script in a Razor view:
<script src="@Html.HtmxToolkitScriptPath()"></script>Pass true to generate a path with the ?debug query string and load the debug version:
<script src="@Html.HtmxToolkitScriptPath(debug: true)"></script>Without the debug argument, the endpoint serves the minified version.
The following .NET and HTMX versions are supported:
| Version | |
|---|---|
| .NET | 6, 7, 8, 9, 10, 11 |
| HTMX | 1.9.x, 2.x (default), 4.x (beta) |
Bug reports and contributions are welcome.
This package is released as open source under the MIT License. See the LICENSE file for more details.