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:
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();
}
Source: htmx documentation and htmx reference
$ 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>
Source: htmx documentation and htmx reference
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">withhx-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/5xxresponses by default; thehtmx-configmeta tag’sresponseHandlingenables swapping422while keeping other errors unswapped. (Returning200for 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;
|safeis only applied to HTML we rendered ourselves. -
The htmx script is served from the application’s own
/staticdirectory, which keeps a strictscript-src 'self'CSP working; when loading it from a CDN instead, add a Subresource Integrityintegrityattribute.
Response headers
htmx reads instructions from response headers:
| Header | Effect |
|---|---|
|
fire client-side events after the swap (a name, or JSON with details as above); |
|
full-page redirect to a URL (e.g. after login); a plain |
|
client-side navigation without a full reload (like a boosted link) |
|
update the browser’s address bar and history |
|
change the target element or swap style chosen by the client |
|
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();
}
Source: htmx: Response headers
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).
References
-
Thymeleaf: htmx Fundamentals — the same htmx style with Thymeleaf on Spring Boot