htmx

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against htmx 2.0 with axum 0.8.9 and askama 0.16.1 on Rust 1.99 (edition 2024), checked on 2026-10-06. htmx lets HTML elements issue HTTP requests and swap the returned HTML into the page, so a server-rendered application gains interactivity — live search, inline form validation, partial updates — without a JavaScript front-end framework. The server’s job is to return HTML fragments, which axum and askama do naturally. Templates are introduced on Server-side Templates.

Full page or fragment?

htmx marks its requests with HX-Request: true. The same URL can therefore serve a full page to a normal navigation (bookmarks, reloads, search engines) and only the changed fragment to htmx. Bookshelf's catalogue search does this:

sequenceDiagram participant U as Browser participant A as axum U->>A: GET /books (normal navigation) A-->>U: 200 full page: layout + search box + results list Note over U: user types "dune" (hx-trigger="input changed delay:300ms") U->>A: GET /books?q=dune, HX-Request: true A-->>U: 200 only the results fragment, Vary: HX-Request U->>U: htmx swaps #results innerHTML
use askama::Template;
use axum::{
    Form, Router,
    extract::Query,
    http::{HeaderMap, HeaderValue, StatusCode, header},
    response::{Html, IntoResponse, Response},
    routing::{get, post},
};
use serde::Deserialize;

const BOOKS: [(&str, &str); 3] = [("9780441013593", "Dune"), ("9780593099322", "Dune Messiah"), ("9780441478125", "The Left Hand of Darkness")];

#[derive(Template)]
#[template(
    ext = "html",
    source = r#"<ul id="results">{% for (isbn, title) in books %}<li><a href="/books/{{ isbn }}">{{ title }}</a></li>{% else %}<li>No match.</li>{% endfor %}</ul>"#
)]
struct Results<'a> {
    books: Vec<(&'a str, &'a str)>,
}

#[derive(Template)]
#[template(
    ext = "html",
    source = r##"<!doctype html><html lang="en"><head><meta charset="utf-8"><title>Catalogue</title>
<script src="/static/htmx-2.0.4.min.js" defer></script>
<meta name="htmx-config" content='{"responseHandling":[{"code":"204","swap":false},{"code":"[23]..","swap":true},{"code":"422","swap":true},{"code":"[45]..","swap":false,"error":true}]}'>
</head><body>
<input type="search" name="q" value="{{ q }}" hx-get="/books" hx-trigger="input changed delay:300ms" hx-target="#results" hx-swap="outerHTML" placeholder="Search">
{{ results|safe }}
<span id="review-count">0 reviews</span>
<form id="review-form" hx-post="/reviews" hx-swap="outerHTML">{{ form|safe }}</form>
</body></html>"##
)]
struct Page {
    q: String,
    results: String, // rendered fragments, already escaped by their own templates
    form: String,
}

#[derive(Template, Default)]
#[template(
    ext = "html",
    source = r#"<label>Review <textarea name="text">{{ text }}</textarea></label>{% if let Some(e) = error %}<p class="error" role="alert">{{ e }}</p>{% endif %}<button>Send</button>"#
)]
struct ReviewFields {
    text: String,
    error: Option<String>,
}

#[derive(Deserialize)]
struct Search {
    #[serde(default)]
    q: String,
}

fn is_htmx(headers: &HeaderMap) -> bool {
    headers.get("hx-request").is_some_and(|v| v == "true")
}

fn render<T: Template>(t: &T) -> Result<String, StatusCode> {
    t.render().map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)
}

async fn books(headers: HeaderMap, Query(search): Query<Search>) -> Result<Response, StatusCode> {
    let needle = search.q.to_lowercase();
    let books = BOOKS.iter().copied().filter(|(_, t)| t.to_lowercase().contains(&needle)).collect();
    let results = render(&Results { books })?;
    let mut response = if is_htmx(&headers) {
        Html(results).into_response() // fragment only
    } else {
        let form = render(&ReviewFields::default())?;
        Html(render(&Page { q: search.q, results, form })?).into_response() // full page
    };
    // caches must keep the two variants apart
    response.headers_mut().insert(header::VARY, HeaderValue::from_static("HX-Request"));
    Ok(response)
}

#[derive(Deserialize)]
struct ReviewForm {
    text: String,
}

async fn add_review(Form(form): Form<ReviewForm>) -> Result<Response, StatusCode> {
    if form.text.trim().len() < 10 {
        // 422 + the form re-rendered with the user's input and the message (swapped thanks to htmx-config above)
        let fields = render(&ReviewFields { text: form.text, error: Some("Please write at least 10 characters.".into()) })?;
        return Ok((StatusCode::UNPROCESSABLE_ENTITY, Html(format!(r#"<form id="review-form" hx-post="/reviews" hx-swap="outerHTML">{fields}</form>"#))).into_response());
    }
    // success: a fresh form, plus an out-of-band update of the counter elsewhere on the page,
    // plus an event that page scripts (e.g. a toast) can listen to
    let fields = render(&ReviewFields::default())?;
    let body = format!(
        r#"<form id="review-form" hx-post="/reviews" hx-swap="outerHTML">{fields}</form><span id="review-count" hx-swap-oob="true">1 review</span>"#
    );
    Ok(([("hx-trigger", r#"{"reviewAdded":{"message":"Thanks for your review!"}}"#)], Html(body)).into_response())
}

#[tokio::main]
async fn main() {
    let app = Router::new().route("/books", get(books)).route("/reviews", post(add_review));
    let listener = tokio::net::TcpListener::bind("127.0.0.1:3000").await.unwrap();
    axum::serve(listener, app).await.unwrap();
}
$ curl -s 'localhost:3000/books?q=dune' -H 'hx-request: true'
<ul id="results"><li><a href="/books/9780441013593">Dune</a></li><li><a href="/books/9780593099322">Dune Messiah</a></li></ul>
$ curl -s 'localhost:3000/books?q=dune' | head -c 58
<!doctype html><html lang="en"><head><meta charset="utf-8">
$ curl -si -d 'text=short' localhost:3000/reviews | grep -E '^HTTP|error'
HTTP/1.1 422 Unprocessable Entity
<form id="review-form" hx-post="/reviews" hx-swap="outerHTML"><label>Review <textarea name="text">short</textarea></label><p class="error" role="alert">Please write at least 10 characters.</p><button>Send</button></form>

Patterns used above

  • Fragment templates: each fragment (Results, ReviewFields) is its own askama template, used both inside the full page and alone. Fragments must keep the same root element and id they replace (<ul id="results"> with hx-swap="outerHTML").

  • Vary: HX-Request: without it, a browser or CDN may cache the fragment and show it on a full navigation.

  • Inline validation: the server re-renders the form with the submitted values and errors. htmx 2 does not swap 4xx/5xx responses by default; the htmx-config meta tag’s responseHandling enables swapping 422 while keeping other errors unswapped. (Returning 200 for invalid input also works, but loses the semantic status.)

  • Out-of-band swaps: an element with hx-swap-oob="true" in a response replaces the element with the same id anywhere on the page — here the review counter — in addition to the main target.

  • Escaping: the fragments are escaped by their templates; |safe is only applied to HTML we rendered ourselves.

  • The htmx script is served from the application’s own /static directory, which keeps a strict script-src 'self' CSP working; when loading it from a CDN instead, add a Subresource Integrity integrity attribute.

Response headers

htmx reads instructions from response headers:

Header Effect

HX-Trigger

fire client-side events after the swap (a name, or JSON with details as above); HX-Trigger-After-Settle and HX-Trigger-After-Swap time them differently

HX-Redirect

full-page redirect to a URL (e.g. after login); a plain 303 would be followed by the AJAX request instead

HX-Location

client-side navigation without a full reload (like a boosted link)

HX-Push-Url / HX-Replace-Url

update the browser’s address bar and history

HX-Retarget / HX-Reswap

change the target element or swap style chosen by the client

HX-Refresh: true

reload the whole page

use axum::{Form, Router, http::HeaderMap, response::IntoResponse, routing::post};
use serde::Deserialize;

#[derive(Deserialize)]
struct Login {
    username: String,
}

async fn login(headers: HeaderMap, Form(form): Form<Login>) -> impl IntoResponse {
    // ... authenticate, start the session ...
    let target = format!("/account?welcome={}", form.username.len());
    if headers.contains_key("hx-request") {
        ([("hx-redirect", target)], "").into_response() // htmx performs a full navigation
    } else {
        axum::response::Redirect::to(&target).into_response() // plain form post: 303
    }
}

#[tokio::main]
async fn main() {
    let app = Router::new().route("/login", post(login));
    let listener = tokio::net::TcpListener::bind("127.0.0.1:3000").await.unwrap();
    axum::serve(listener, app).await.unwrap();
}

Real-time updates: SSE and WebSocket extensions

The htmx-ext-sse and htmx-ext-ws extensions connect an element to a Server-Sent Events stream or a WebSocket and swap incoming HTML messages into the page: <div hx-ext="sse" sse-connect="/events" sse-swap="loan-due">. The server side sends HTML fragments as event data; the endpoints are built on Server-Sent Events & Streaming and WebSockets.

CSRF with htmx

htmx requests are ordinary same-origin fetch requests carrying cookies, so they need the same CSRF protection as forms. tower-http’s CsrfLayer works unchanged: browsers send Sec-Fetch-Site: same-origin with htmx requests (see CSRF & Security Hardening). With token-based CSRF instead, put the token in every request with hx-headers='{"X-CSRF-Token": "..."}' on the <body>, rendered by the server template.

When htmx fits

htmx suits applications whose state lives on the server — catalogues, admin panels, forms, dashboards — and keeps the whole application in one language and one deployment. Rich client-side interaction (offline use, drag-and-drop editors, complex client state) is better served by a JavaScript SPA or a Rust/WebAssembly front end (see Static Files & SPAs and Full-stack with Leptos & Dioxus).