htmx gives HTML the ability to issue HTTP requests and swap content into the page, without writing JavaScript. Add attributes to your markup, and htmx handles the rest: it sends an AJAX request, receives an HTML fragment from the server, and replaces a targeted element in the DOM. The server stays in control of rendering. There is no client-side state, no JSON serialisation layer, and no build step.
htmx is 14 KB minified and gzipped, has zero runtime dependencies, and works with any server that returns HTML. In this stack, Axum handlers return Maud Markup and htmx swaps it into place.
Including htmx
Vendor htmx into your project rather than loading it from a CDN. Download the minified file and place it in your assets directory:
assets/
htmx.min.js
The Web Server with Axum section covers serving static assets with rust-embed. Include htmx in your layout’s <head>:
head {
// ...
script src="/assets/htmx.min.js" defer {}
}
The defer attribute ensures htmx loads after HTML parsing completes, avoiding render-blocking.
Vendoring eliminates CDN availability concerns and keeps the dependency auditable. htmx has zero transitive runtime dependencies, so you are vendoring exactly one file.
How htmx works
htmx extends HTML with attributes that describe HTTP interactions declaratively. The core mechanism is:
- An event occurs on an element (click, submit, keyup, or any DOM event).
- htmx sends an HTTP request (GET, POST, PUT, PATCH, DELETE) to a URL.
- The server returns an HTML fragment.
- htmx swaps that fragment into a target element in the DOM.
Every step is controlled by HTML attributes. No JavaScript is written by the application developer.
button hx-get="/clicked" hx-target="#result" hx-swap="innerHTML" {
"Click me"
}
div #result {}
When the button is clicked, htmx issues GET /clicked, takes the response body, and replaces the inner HTML of #result with it. The handler for /clicked returns a Maud fragment:
async fn clicked() -> Markup {
html! { p { "You clicked the button." } }
}
That is the entire pattern. Everything else in htmx is refinement of these four concepts: what triggers the request, what request to send, where to put the response, and how to swap it in.
Core attributes
Request attributes
Five attributes correspond to the five HTTP methods:
| Attribute | Method | Typical use |
|---|---|---|
hx-get | GET | Fetch and display data |
hx-post | POST | Submit data, create resources |
hx-put | PUT | Full resource replacement |
hx-patch | PATCH | Partial update |
hx-delete | DELETE | Remove a resource |
Each takes a URL as its value:
// Fetch a list
button hx-get="/users" hx-target="#user-list" { "Load users" }
// Create a resource
form hx-post="/users" hx-target="#user-list" hx-swap="beforeend" {
input type="text" name="name" required;
button type="submit" { "Add user" }
}
// Delete a resource
button hx-delete={ "/users/" (user.id) }
hx-confirm="Delete this user?"
hx-target="closest tr"
hx-swap="outerHTML" {
"Delete"
}
htmx sends form data automatically for elements within a form. For elements outside a form, use hx-include to specify which inputs to include in the request.
hx-target
hx-target specifies which element receives the swapped content. It takes a CSS selector, or one of these special values:
this: the element that triggered the requestclosest <selector>: the nearest ancestor matching the selectorfind <selector>: the first descendant matching the selectornext <selector>: the next sibling matching the selectorprevious <selector>: the previous sibling matching the selector
If hx-target is omitted, htmx swaps content into the element that issued the request.
// Swap into a specific element
button hx-get="/stats" hx-target="#dashboard-stats" { "Refresh" }
// Swap into the closest ancestor
button hx-delete={ "/items/" (item.id) }
hx-target="closest li"
hx-swap="outerHTML" {
"Remove"
}hx-swap
hx-swap controls how the response is inserted relative to the target. The default is innerHTML.
| Value | Behaviour |
|---|---|
innerHTML | Replace the target’s children (default) |
outerHTML | Replace the entire target element |
beforebegin | Insert before the target |
afterbegin | Insert as the target’s first child |
beforeend | Insert as the target’s last child |
afterend | Insert after the target |
delete | Delete the target element, ignore the response |
none | Don’t swap anything (out-of-band swaps still process) |
beforeend is particularly useful for appending to lists:
// Handler returns a single <li>, appended to the list
form hx-post="/todos" hx-target="#todo-list" hx-swap="beforeend" {
input type="text" name="title" placeholder="New todo" required;
button type="submit" { "Add" }
}
ul #todo-list {
@for todo in &todos {
li { (todo.title) }
}
}
outerHTML replaces the target itself, which is the right choice for inline editing where the display row swaps with an edit form and back:
// Display row, click to edit
tr hx-get={ "/contacts/" (contact.id) "/edit" }
hx-trigger="click"
hx-target="this"
hx-swap="outerHTML" {
td { (contact.name) }
td { (contact.email) }
}Swap modifiers
Append modifiers to the swap value, separated by spaces:
swap:<timing>: delay before performing the swap (e.g.,swap:100ms)settle:<timing>: delay between swap and settle phase, useful for CSS transitions (e.g.,settle:200ms)scroll:<target>:<direction>: scroll the target or window after swap (scroll:top,scroll:bottom)show:<target>:<direction>: scroll to show the swapped element (show:top,show:bottom)transition:true: use the View Transitions API for the swap animation
// Fade-out delete: swap after 500ms to allow a CSS transition
button hx-delete={ "/users/" (user.id) }
hx-target="closest tr"
hx-swap="outerHTML swap:500ms" {
"Delete"
}
// Scroll to top after loading new content
div hx-get="/page/2" hx-swap="innerHTML show:top" {
"Load more"
}hx-confirm
hx-confirm shows a browser confirmation dialog before the request is sent. The request only proceeds if the user confirms:
button hx-delete={ "/projects/" (project.id) }
hx-confirm="Are you sure? This cannot be undone."
hx-target="closest .project-card"
hx-swap="outerHTML" {
"Delete project"
}hx-select
hx-select extracts a subset of the response using a CSS selector before swapping. This is useful when a handler returns a full page but you only need a fragment:
// Extract just the #results element from the response
a hx-get="/search?q=rust" hx-target="#results" hx-select="#results" {
"Search for Rust"
}hx-include
hx-include tells htmx to include values from additional elements in the request. Accepts a CSS selector:
// Include the search input's value in the request
input #search type="text" name="q";
button hx-get="/search" hx-include="#search" hx-target="#results" {
"Search"
}hx-vals and hx-headers
hx-vals adds extra parameters to the request body as JSON:
button hx-post="/track"
hx-vals=r#"{"event": "button_click", "source": "header"}"# {
"Track"
}
hx-headers adds custom HTTP headers:
button hx-get="/api/data"
hx-headers=r#"{"X-Custom-Header": "value"}"# {
"Fetch"
}hx-push-url
hx-push-url pushes the request URL into the browser’s history stack, so the back button works. Set it to true to push the request URL, or provide a specific URL:
// Push the request URL to history
a hx-get="/users" hx-target="#content" hx-push-url="true" {
"Users"
}
// Push a different URL
button hx-get="/users?page=2" hx-target="#user-list"
hx-push-url="/users/page/2" {
"Next page"
}Triggering requests
hx-trigger
hx-trigger specifies which event initiates the request. Without it, htmx uses the natural event for each element type:
| Element | Default trigger |
|---|---|
input, textarea, select | change |
form | submit |
| Everything else | click |
Override the default by specifying any DOM event:
// Trigger on keyup instead of change
input type="search" name="q"
hx-get="/search"
hx-target="#results"
hx-trigger="keyup" {
}Trigger modifiers
Modifiers refine when and how triggers fire. Append them after the event name, separated by spaces:
changed: only fire if the element’s value has actually changed since the last request:
input type="search" name="q"
hx-get="/search"
hx-target="#results"
hx-trigger="keyup changed";
delay:<time>: wait before firing. Each new event resets the timer. This is debouncing: the request fires only after the user stops typing:
input type="search" name="q"
hx-get="/search"
hx-target="#results"
hx-trigger="keyup changed delay:300ms";
throttle:<time>: fire at most once per interval. Unlike delay, the first event fires immediately. Subsequent events within the window are dropped:
// Update position at most every 200ms
div hx-post="/position"
hx-trigger="mousemove throttle:200ms" {
"Track mouse"
}
from:<selector>: listen for the event on a different element. Useful for keyboard shortcuts:
// Trigger search when Enter is pressed anywhere in the document
div hx-get="/search"
hx-target="#results"
hx-trigger="keyup[key=='Enter'] from:body" {
// ...
}
consume: prevent the event from propagating to parent elements.
queue:<strategy>: control what happens when a new event fires while a request is in flight:
queue:first: queue the first event, drop the restqueue:last: queue only the most recent event (default)queue:all: queue every event, process them one at a timequeue:none: drop all events while a request is active
Multiple triggers
Separate multiple triggers with commas:
// Load on page load AND on click
div hx-get="/notifications"
hx-trigger="load, click"
hx-target="this" {
"Loading..."
}Polling
Use the every syntax to poll an endpoint at a fixed interval:
div hx-get="/status"
hx-trigger="every 5s"
hx-target="this" {
"Checking status..."
}Event filters
Square brackets filter events by a JavaScript expression:
// Only trigger on Enter key
input type="text" name="q"
hx-get="/search"
hx-target="#results"
hx-trigger="keyup[key=='Enter']";Boosted links and navigation
hx-boost converts standard links and forms into AJAX requests. Apply it to a parent element and all descendant <a> and <form> elements are automatically boosted:
body hx-boost="true" {
nav {
a href="/users" { "Users" }
a href="/settings" { "Settings" }
}
main #content {
(content)
}
}
When a user clicks a boosted link, htmx:
- Issues a GET request to the link’s
href - Swaps the response into
<body>usinginnerHTML - Pushes the URL into browser history
The page does not fully reload. The browser keeps the existing <head> (scripts, stylesheets) and only swaps the body content, making navigation feel instant.
Boosted forms work the same way: the form is submitted via AJAX and the response replaces the body.
Progressive enhancement is built in. If JavaScript is disabled or fails to load, boosted links and forms still work as standard HTML. The server returns the same full HTML page either way. htmx intercepts the navigation when it can; the browser handles it normally when it cannot.
Detecting boosted requests on the server
Boosted requests include the HX-Boosted: true header. Use this to return just the body content instead of a full HTML document, avoiding redundant <head> parsing:
use axum_htmx::HxBoosted;
async fn users_page(
HxBoosted(boosted): HxBoosted,
layout: PageLayout,
State(state): State<AppState>,
) -> Markup {
let users = fetch_users(&state.db).await;
let content = html! {
h1 { "Users" }
ul {
@for user in &users {
li { (user.name) }
}
}
};
if boosted {
content
} else {
layout.render("Users", content)
}
}
Every URL works as both a full page (direct navigation, bookmarks) and a fragment (boosted navigation). One handler, one template, no separate endpoint.
Loading indicators
htmx adds the htmx-request CSS class to elements while a request is in flight. Use this to show loading spinners, disable buttons, or fade content.
Default behaviour
By default, htmx adds htmx-request to the element that issued the request:
button hx-get="/slow-endpoint" hx-target="#result" {
"Load data"
}
Style the loading state with CSS:
button.htmx-request {
opacity: 0.5;
pointer-events: none;
}hx-indicator
hx-indicator specifies a different element to receive the htmx-request class. This is useful for showing a spinner that is separate from the trigger element:
button hx-get="/data" hx-target="#results" hx-indicator="#spinner" {
"Load"
}
img #spinner.htmx-indicator src="/assets/spinner.svg" alt="Loading";
htmx includes default CSS for the htmx-indicator class that hides the element until the request is active:
.htmx-indicator {
opacity: 0;
}
.htmx-request .htmx-indicator,
.htmx-request.htmx-indicator {
opacity: 1;
transition: opacity 200ms ease-in;
}
Override these styles to match your application’s design. The visibility approach avoids layout shifts:
.htmx-indicator {
display: none;
}
.htmx-request .htmx-indicator,
.htmx-request.htmx-indicator {
display: inline-block;
}Inline loading text
A common pattern replaces the button text while loading:
button hx-post="/save" hx-target="#form-container" hx-swap="outerHTML" {
span.ready { "Save" }
span.htmx-indicator { "Saving..." }
}button .htmx-indicator { display: none; }
button.htmx-request .ready { display: none; }
button.htmx-request .htmx-indicator { display: inline; }Working with Axum
The axum-htmx crate provides typed extractors for htmx request headers and typed responders for htmx response headers:
[dependencies]
axum-htmx = "0.6"Request extractors
All htmx request headers have a corresponding extractor. Extractors are infallible, so they always succeed and never reject a request:
| Header | Extractor | Value |
|---|---|---|
HX-Request | HxRequest | bool |
HX-Boosted | HxBoosted | bool |
HX-Target | HxTarget | Option<String> |
HX-Trigger | HxTrigger | Option<String> |
HX-Trigger-Name | HxTriggerName | Option<String> |
HX-Current-URL | HxCurrentUrl | Option<Uri> |
HX-Prompt | HxPrompt | Option<String> |
HxRequest detects any htmx-initiated request. HxBoosted specifically detects boosted navigation. Use whichever matches the handler’s needs:
use axum_htmx::HxRequest;
async fn search(
HxRequest(is_htmx): HxRequest,
Query(params): Query<SearchParams>,
layout: PageLayout,
State(state): State<AppState>,
) -> Markup {
let users = search_users(&state.db, ¶ms.q).await;
let results = html! {
ul #search-results {
@for user in &users {
li { (user.name) " – " (user.email) }
}
}
};
if is_htmx {
results
} else {
layout.render("Search", html! {
h1 { "Search users" }
input type="search" name="q" value=(params.q)
hx-get="/users/search"
hx-target="#search-results"
hx-trigger="input changed delay:300ms";
(results)
})
}
}
This pattern ensures every URL works as both a full page (direct navigation, bookmarks, search engine indexing) and as a fragment (htmx requests). The handler renders the same data either way; the only difference is whether it wraps the content in the layout.
Returning fragments from handlers
Handlers that serve only htmx requests return a bare Maud Markup:
async fn delete_user(
Path(id): Path<i64>,
State(state): State<AppState>,
) -> Markup {
sqlx::query!("DELETE FROM users WHERE id = $1", id)
.execute(&state.db)
.await
.unwrap();
// Return empty markup. htmx will swap with hx-swap="outerHTML"
// to remove the deleted row
html! {}
}
For delete operations, the handler returns an empty fragment. Combined with hx-swap="outerHTML" on the trigger element, this removes the target element from the DOM.
Server response headers
htmx checks specific response headers to control client-side behaviour. The axum-htmx crate provides typed responders for each header. Return them as part of a tuple with your response body.
HX-Redirect
Forces a full-page redirect (not an htmx swap). Use this for operations that should leave the current page entirely, like a successful login:
use axum_htmx::HxRedirect;
async fn login(Form(data): Form<LoginForm>) -> impl IntoResponse {
// ... authenticate ...
(HxRedirect("/dashboard".to_string()), html! {})
}
htmx intercepts the response and performs window.location = url. The browser does a full navigation. This is different from a standard HTTP 302 redirect, which the browser handles transparently before htmx sees the response.
HX-Push-Url and HX-Replace-Url
HX-Push-Url pushes a URL into the browser’s history stack (creates a new history entry). HX-Replace-Url replaces the current history entry. Both let the server control the displayed URL after a swap:
use axum_htmx::HxPushUrl;
async fn filter_users(
Query(params): Query<FilterParams>,
State(state): State<AppState>,
) -> impl IntoResponse {
let users = filter_users(&state.db, ¶ms).await;
let url = format!("/users?role={}", params.role);
(
HxPushUrl(url),
html! {
@for user in &users {
tr {
td { (user.name) }
td { (user.role) }
}
}
},
)
}HX-Retarget and HX-Reswap
HX-Retarget overrides the element’s hx-target from the server side. HX-Reswap overrides hx-swap. Together, they let the server change where and how content is placed based on the response:
use axum_htmx::{HxRetarget, HxReswap, SwapOption};
use axum::http::StatusCode;
async fn create_user(Form(data): Form<NewUser>) -> impl IntoResponse {
match validate_and_save(&data).await {
Ok(user) => {
// Success: append to the user list as intended by the form's hx-target
(StatusCode::OK, html! {
tr {
td { (user.name) }
td { (user.email) }
}
}).into_response()
}
Err(errors) => {
// Validation failed: retarget to the error container, swap innerHTML
(
StatusCode::UNPROCESSABLE_ENTITY,
HxRetarget("#form-errors".to_string()),
HxReswap(SwapOption::InnerHtml),
html! {
ul.errors {
@for error in &errors {
li { (error) }
}
}
},
).into_response()
}
}
}
This is a powerful pattern: the form’s hx-target and hx-swap describe the success case. When validation fails, the server redirects the swap to a different element without any client-side logic.
HX-Trigger (response)
HX-Trigger fires custom events on the client after the response is processed. Other elements on the page can listen for these events using hx-trigger="from:body":
use axum_htmx::HxResponseTrigger;
async fn create_todo(
Form(data): Form<NewTodo>,
State(state): State<AppState>,
) -> impl IntoResponse {
let todo = save_todo(&state.db, &data).await.unwrap();
(
HxResponseTrigger::normal(vec!["todo-added".to_string()]),
html! {
li { (todo.title) }
},
)
}
An element elsewhere on the page can react to this event:
// This element refreshes when a todo is added
span hx-get="/todos/count"
hx-trigger="todo-added from:body"
hx-target="this" {
(count)
}
HxResponseTrigger supports three timing modes:
HxResponseTrigger::normal(): fires immediately (setsHX-Trigger)HxResponseTrigger::after_swap(): fires after the swap completes (setsHX-Trigger-After-Swap)HxResponseTrigger::after_settle(): fires after the settle phase (setsHX-Trigger-After-Settle)
HX-Refresh
Forces a full page refresh:
use axum_htmx::HxRefresh;
async fn clear_cache() -> impl IntoResponse {
// ... clear cache ...
(HxRefresh(true), html! {})
}Complete responder reference
| Header | Responder | Value |
|---|---|---|
HX-Location | HxLocation | String |
HX-Push-Url | HxPushUrl | String |
HX-Redirect | HxRedirect | String |
HX-Refresh | HxRefresh | bool |
HX-Replace-Url | HxReplaceUrl | String |
HX-Reswap | HxReswap | SwapOption |
HX-Retarget | HxRetarget | String |
HX-Reselect | HxReselect | String |
HX-Trigger | HxResponseTrigger | Vec<String> or Vec<HxEvent> |
All responders implement IntoResponseParts, so they compose naturally with Maud’s Markup in tuples.
Form submission and validation
A basic pattern: submit a form with hx-post, display validation errors inline if the submission fails. The Form Handling and Validation section covers this topic in full.
The form:
fn new_contact_form(errors: &[String]) -> Markup {
html! {
form #contact-form hx-post="/contacts" hx-target="this" hx-swap="outerHTML" {
@if !errors.is_empty() {
ul.errors {
@for error in errors {
li { (error) }
}
}
}
label {
"Name"
input type="text" name="name" required;
}
label {
"Email"
input type="email" name="email" required;
}
button type="submit" {
span.ready { "Save" }
span.htmx-indicator { "Saving..." }
}
}
}
}
The handler:
async fn create_contact(
State(state): State<AppState>,
Form(data): Form<NewContact>,
) -> Markup {
let mut errors = Vec::new();
if data.name.trim().is_empty() {
errors.push("Name is required.".to_string());
}
if !data.email.contains('@') {
errors.push("A valid email is required.".to_string());
}
if !errors.is_empty() {
return new_contact_form(&errors);
}
let contact = save_contact(&state.db, &data).await.unwrap();
html! {
tr {
td { (contact.name) }
td { (contact.email) }
}
}
}
On validation failure, the handler returns the form again with error messages. The form’s hx-swap="outerHTML" replaces itself with the re-rendered version, preserving the user’s input. On success, the handler returns a table row, which replaces the form.
SSE extension
htmx includes an SSE (Server-Sent Events) extension for receiving real-time server-pushed updates. The Server-Sent Events and Real-Time Updates section covers SSE in depth. Here is the basic setup.
Include the SSE extension after htmx. Vendor the file alongside htmx.min.js:
assets/
htmx.min.js
ext/
sse.js
Include it in the layout:
head {
// ...
script src="/assets/htmx.min.js" defer {}
script src="/assets/ext/sse.js" defer {}
}
Connect to an SSE endpoint and swap content when events arrive:
div hx-ext="sse" sse-connect="/events" {
// This element updates when the server sends a "notification" event
div sse-swap="notification" {
"Waiting for notifications..."
}
// This element updates on "status" events
div sse-swap="status" {
"Status: unknown"
}
}
The Axum handler returns an SSE stream. When the server sends an event named notification, htmx takes the event’s data (an HTML fragment) and swaps it into the element with sse-swap="notification". The browser handles reconnection automatically if the connection drops.
Gotchas
htmx processes 2xx responses only. By default, htmx swaps content only for 200-level status codes. Non-2xx responses are ignored (no swap happens). To swap error content into the page, either return a 200 with error markup, or configure htmx’s responseHandling to process specific error codes. The HX-Retarget and HX-Reswap headers offer a clean alternative: return the error markup with a 422 status and redirect the swap to an error container.
3xx redirects bypass htmx headers. When a server returns a 302 or 301, the browser follows the redirect transparently. htmx never sees the response headers. Use HX-Redirect (with a 200 status) instead of HTTP 302 when you need htmx to process the redirect.
hx-boost changes form encoding. Boosted forms send requests via AJAX. If a form uses enctype="multipart/form-data" for file uploads, htmx handles this correctly. But be aware that boosted GET forms append parameters to the URL rather than the body, matching standard HTML form behaviour.
History and the back button. When using hx-push-url or hx-boost, htmx caches page snapshots for the back button. If your pages include dynamic state (e.g., a logged-in user’s name in the nav), the cached snapshot may show stale data. htmx fires an htmx:historyRestore event when restoring from cache, which you can use to refresh stale sections.
Attribute inheritance. htmx attributes inherit down the DOM tree. An hx-target on a <div> applies to all htmx-enabled elements inside it. This is useful for setting defaults (e.g., hx-target="#content" on a container), but can cause surprises if a nested element inherits a target you did not intend. Use hx-target="unset" to break inheritance.