Interactivity with HTMX

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:

  1. An event occurs on an element (click, submit, keyup, or any DOM event).
  2. htmx sends an HTTP request (GET, POST, PUT, PATCH, DELETE) to a URL.
  3. The server returns an HTML fragment.
  4. 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:

AttributeMethodTypical use
hx-getGETFetch and display data
hx-postPOSTSubmit data, create resources
hx-putPUTFull resource replacement
hx-patchPATCHPartial update
hx-deleteDELETERemove 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 request
  • closest <selector>: the nearest ancestor matching the selector
  • find <selector>: the first descendant matching the selector
  • next <selector>: the next sibling matching the selector
  • previous <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.

ValueBehaviour
innerHTMLReplace the target’s children (default)
outerHTMLReplace the entire target element
beforebeginInsert before the target
afterbeginInsert as the target’s first child
beforeendInsert as the target’s last child
afterendInsert after the target
deleteDelete the target element, ignore the response
noneDon’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:

ElementDefault trigger
input, textarea, selectchange
formsubmit
Everything elseclick

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 rest
  • queue:last: queue only the most recent event (default)
  • queue:all: queue every event, process them one at a time
  • queue: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']";

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:

  1. Issues a GET request to the link’s href
  2. Swaps the response into <body> using innerHTML
  3. 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:

HeaderExtractorValue
HX-RequestHxRequestbool
HX-BoostedHxBoostedbool
HX-TargetHxTargetOption<String>
HX-TriggerHxTriggerOption<String>
HX-Trigger-NameHxTriggerNameOption<String>
HX-Current-URLHxCurrentUrlOption<Uri>
HX-PromptHxPromptOption<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, &params.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, &params).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 (sets HX-Trigger)
  • HxResponseTrigger::after_swap(): fires after the swap completes (sets HX-Trigger-After-Swap)
  • HxResponseTrigger::after_settle(): fires after the settle phase (sets HX-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

HeaderResponderValue
HX-LocationHxLocationString
HX-Push-UrlHxPushUrlString
HX-RedirectHxRedirectString
HX-RefreshHxRefreshbool
HX-Replace-UrlHxReplaceUrlString
HX-ReswapHxReswapSwapOption
HX-RetargetHxRetargetString
HX-ReselectHxReselectString
HX-TriggerHxResponseTriggerVec<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.