// Build interactive hypermedia-driven applications with Axum and HTMX. Use when creating dynamic UIs, real-time updates, AJAX interactions, mentions 'HTMX', 'dynamic content', or 'interactive web app'.
| name | htmx-rust |
| description | Build interactive hypermedia-driven applications with Axum and HTMX. Use when creating dynamic UIs, real-time updates, AJAX interactions, mentions 'HTMX', 'dynamic content', or 'interactive web app'. |
HTMX enables modern, interactive web applications with minimal JavaScript. Combined with Rust's type safety and Axum's powerful routing, you get fast, reliable hypermedia-driven UIs with compile-time guarantees.
Key Benefits:
Use when:
[dependencies]
axum = "0.7"
tokio = { version = "1", features = ["full"] }
askama = "0.12"
serde = { version = "1.0", features = ["derive"] }
<!DOCTYPE html>
<html>
<head>
<title>{{ title }}</title>
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
</head>
<body>
{{ content }}
</body>
</html>
{# counter.html #}
<div id="counter">
<p>Count: {{ count }}</p>
<button
hx-post="/counter/increment"
hx-target="#counter"
hx-swap="outerHTML"
>
Increment
</button>
</div>
Define the template struct:
use askama::Template;
#[derive(Template)]
#[template(path = "counter.html")]
struct CounterTemplate {
count: i32,
}
use axum::{
extract::{State},
response::IntoResponse,
Json,
};
async fn increment_counter(
State(state): State<AppState>,
) -> impl IntoResponse {
let mut count = state.counter.lock().unwrap();
*count += 1;
CounterTemplate { count: *count }
}
use axum::{routing::post, Router};
#[tokio::main]
async fn main() {
let app = Router::new()
.route("/counter/increment", post(increment_counter))
.with_state(AppState::default());
let listener = tokio::net::TcpListener::bind("127.0.0.1:3000")
.await
.unwrap();
axum::serve(listener, app).await.unwrap();
}
Trigger HTTP requests with Axum extractors:
{# search.html #}
<input
type="text"
name="q"
hx-get="/search"
hx-trigger="keyup changed delay:500ms"
hx-target="#results"
/>
<div id="results"></div>
Handler with type-safe query parameters:
use axum::extract::Query;
use serde::Deserialize;
#[derive(Deserialize)]
struct SearchQuery {
q: String,
}
#[derive(Template)]
#[template(path = "search_results.html")]
struct SearchResults {
results: Vec<String>,
}
async fn search(Query(params): Query<SearchQuery>) -> impl IntoResponse {
let results = perform_search(¶ms.q);
SearchResults { results }
}
Specify where to insert response:
{# load_more.html #}
<button
hx-get="/posts?page=2"
hx-target="#posts"
hx-swap="beforeend"
>
Load More
</button>
Control how content is swapped:
{# swap options #}
<!-- innerHTML (default) -->
hx-swap="innerHTML"
<!-- outerHTML - replace element itself -->
hx-swap="outerHTML"
<!-- beforeend - append inside -->
hx-swap="beforeend"
<!-- afterend - insert after -->
hx-swap="afterend"
Control when requests fire:
<!-- On click (default for buttons) -->
<button hx-get="/data">Click me</button>
<!-- On change -->
<select hx-get="/filter" hx-trigger="change">
<!-- On keyup with delay -->
<input hx-get="/search" hx-trigger="keyup changed delay:300ms">
<!-- On page load -->
<div hx-get="/data" hx-trigger="load">
<!-- Every 5 seconds -->
<div hx-get="/updates" hx-trigger="every 5s">
Component template (search_box.html):
<div>
<input
type="text"
name="q"
placeholder="Search..."
hx-get="/search"
hx-trigger="keyup changed delay:500ms"
hx-target="#search-results"
hx-indicator="#spinner"
/>
<span id="spinner" class="htmx-indicator">
Searching...
</span>
</div>
<div id="search-results"></div>
Results template (search_results.html):
<ul>
{% for result in results %}
<li>{{ result }}</li>
{% endfor %}
</ul>
Handler:
use axum::extract::Query;
use askama::Template;
use serde::Deserialize;
#[derive(Deserialize)]
struct SearchParams {
q: String,
}
#[derive(Template)]
#[template(path = "search_results.html")]
struct SearchResults {
results: Vec<String>,
}
async fn search(Query(params): Query<SearchParams>) -> impl IntoResponse {
let results = perform_search(¶ms.q);
SearchResults { results }
}
Template (post_list.html):
<div id="posts">
{% for post in posts %}
<div class="post-card">{{ post.title }}</div>
{% endfor %}
</div>
{% if !posts.is_empty() %}
<div
hx-get="/posts?page={{ page + 1 }}"
hx-trigger="revealed"
hx-swap="outerHTML"
>
Loading more...
</div>
{% endif %}
Handler:
use axum::extract::Query;
use serde::Deserialize;
#[derive(Deserialize)]
struct PageParams {
page: u32,
}
#[derive(Template)]
#[template(path = "post_list.html")]
struct PostList {
posts: Vec<Post>,
page: u32,
}
async fn list_posts(Query(params): Query<PageParams>) -> impl IntoResponse {
let posts = fetch_posts(params.page);
PostList {
posts,
page: params.page,
}
}
Template (delete_button.html):
<button
hx-delete="/items/{{ item_id }}"
hx-confirm="Are you sure?"
hx-target="closest tr"
hx-swap="outerHTML swap:1s"
>
Delete
</button>
Handler:
use axum::extract::Path;
use axum::http::StatusCode;
async fn delete_item(Path(item_id): Path<String>) -> StatusCode {
delete_from_database(&item_id);
StatusCode::OK // Empty response removes element
}
Display template (editable_field.html):
<div id="field-{{ id }}">
<span>{{ value }}</span>
<button
hx-get="/edit/{{ id }}"
hx-target="#field-{{ id }}"
hx-swap="outerHTML"
>
Edit
</button>
</div>
Edit form template (edit_form.html):
<form
hx-post="/save/{{ id }}"
hx-target="#field-{{ id }}"
hx-swap="outerHTML"
>
<input type="text" name="value" value="{{ value }}" />
<button type="submit">Save</button>
<button
hx-get="/cancel/{{ id }}"
hx-target="#field-{{ id }}"
>
Cancel
</button>
</form>
Handlers:
use axum::extract::Path;
use axum::Form;
use serde::Deserialize;
#[derive(Deserialize)]
struct SaveData {
value: String,
}
#[derive(Template)]
#[template(path = "editable_field.html")]
struct EditableField {
id: String,
value: String,
}
#[derive(Template)]
#[template(path = "edit_form.html")]
struct EditForm {
id: String,
value: String,
}
async fn show_edit_form(Path(id): Path<String>) -> impl IntoResponse {
let value = fetch_field(&id);
EditForm { id, value }
}
async fn save_field(
Path(id): Path<String>,
Form(data): Form<SaveData>,
) -> impl IntoResponse {
update_field(&id, &data.value);
EditableField {
id,
value: data.value,
}
}
async fn cancel_edit(Path(id): Path<String>) -> impl IntoResponse {
let value = fetch_field(&id);
EditableField { id, value }
}
Template (signup_form.html):
<form hx-post="/signup" hx-target="#form-errors">
<div id="form-errors"></div>
<input
type="email"
name="email"
hx-post="/validate/email"
hx-trigger="blur"
hx-target="#email-error"
/>
<div id="email-error"></div>
<input type="password" name="password" />
<button type="submit">Sign Up</button>
</form>
Validation template (validation_error.html):
<span class="error">{{ message }}</span>
Handlers:
use axum::Form;
use serde::Deserialize;
#[derive(Deserialize)]
struct EmailValidation {
email: String,
}
#[derive(Template)]
#[template(path = "validation_error.html")]
struct ValidationError {
message: String,
}
async fn validate_email(Form(data): Form<EmailValidation>) -> impl IntoResponse {
if is_email_valid(&data.email) {
(StatusCode::OK, "").into_response()
} else {
ValidationError {
message: "Invalid email format".to_string(),
}
.into_response()
}
}
Template (live_stats.html):
<div
hx-get="/stats"
hx-trigger="load, every 5s"
hx-swap="innerHTML"
>
Loading stats...
</div>
Stats template (stats_display.html):
<div>
<p>Users online: {{ stats.users_online }}</p>
<p>Active sessions: {{ stats.sessions }}</p>
</div>
Handler:
use askama::Template;
#[derive(Template)]
#[template(path = "stats_display.html")]
struct StatsDisplay {
stats: Stats,
}
#[derive(Clone)]
struct Stats {
users_online: usize,
sessions: usize,
}
async fn get_stats() -> impl IntoResponse {
let stats = fetch_current_stats();
StatsDisplay { stats }
}
Update multiple parts of page in a single request:
Cart button template (cart_button.html):
<button id="cart-btn">
Cart ({{ count }})
</button>
Add to cart response template (add_to_cart_response.html):
<!-- Main response -->
<div class="notification">
Added {{ item.name }} to cart!
</div>
<!-- Update cart button (different part of page) -->
<div id="cart-btn" hx-swap-oob="true">
<button id="cart-btn">
Cart ({{ new_count }})
</button>
</div>
Handler:
use axum::Form;
use serde::Deserialize;
#[derive(Deserialize)]
struct AddToCart {
item_id: String,
}
#[derive(Template)]
#[template(path = "add_to_cart_response.html")]
struct AddToCartResponse {
item: Item,
new_count: usize,
}
async fn add_to_cart(Form(data): Form<AddToCart>) -> impl IntoResponse {
let item = fetch_item(&data.item_id);
let new_count = add_to_cart_db(&data.item_id);
AddToCartResponse { item, new_count }
}
Template that works with and without HTMX:
<form
action="/submit"
method="POST"
hx-post="/submit"
hx-target="#result"
>
<input type="text" name="data" />
<button type="submit">Submit</button>
</form>
<div id="result"></div>
Works without JavaScript (form submission), enhanced with HTMX (no page reload).
Template:
<div
hx-get="/data"
hx-trigger="load"
hx-indicator="#loading"
>
<div id="loading" class="htmx-indicator">
Loading data...
</div>
</div>
CSS:
.htmx-indicator {
display: none;
}
.htmx-request .htmx-indicator {
display: inline;
}
.htmx-request.htmx-indicator {
display: inline;
}
Trigger client-side custom events:
use axum::http::HeaderMap;
async fn create_item(Form(data): Form<ItemForm>) -> impl IntoResponse {
let item = create_in_db(data);
let mut headers = HeaderMap::new();
headers.insert("HX-Trigger", "itemCreated".parse().unwrap());
(headers, ItemTemplate { item })
}
Client side:
document.body.addEventListener("itemCreated", function(evt) {
console.log("Item created!");
});
Redirect to new page after form submission:
async fn login(Form(credentials): Form<LoginForm>) -> impl IntoResponse {
if authenticate(&credentials) {
let mut headers = HeaderMap::new();
headers.insert("HX-Redirect", "/dashboard".parse().unwrap());
(headers, StatusCode::OK)
} else {
(StatusCode::UNAUTHORIZED, "Invalid credentials")
}
}
Trigger a full page refresh:
async fn update_config(Form(config): Form<Config>) -> impl IntoResponse {
save_config(config);
let mut headers = HeaderMap::new();
headers.insert("HX-Refresh", "true".parse().unwrap());
(headers, StatusCode::OK)
}
Use the fluent Given-When-Then DSL pattern for acceptance testing HTMX interactions:
Template Setup:
{# search.html #}
<input
type="text"
name="q"
hx-get="/search"
hx-trigger="keyup changed delay:500ms"
hx-target="#search-results"
/>
<div id="search-results"></div>
Handler:
#[derive(Deserialize)]
struct SearchParams {
q: String,
}
#[derive(Template)]
#[template(path = "search_results.html")]
struct SearchResults {
results: Vec<String>,
}
async fn search(Query(params): Query<SearchParams>) -> impl IntoResponse {
let results = perform_search(¶ms.q);
SearchResults { results }
}
Acceptance Test:
#[tokio::test]
async fn live_search_should_return_matching_results() {
WebApp::given()
.a_file_with_content(
"## TT 2025-01-15\n\
- #frontend 2h Building search UI\n\
- #backend 1h Search API\n\
- #docs 30m Search documentation\n",
)
.when_get("/search")
.with_query("q=search")
.should_succeed()
.await
.expect_status(200)
.expect_contains("Search API")
.expect_contains("Search documentation");
}
#[tokio::test]
async fn live_search_should_handle_empty_query() {
WebApp::given()
.when_get("/search")
.with_query("q=")
.should_succeed()
.await
.expect_status(200)
.expect_not_contains("Search API");
}
★ Insight ─────────────────────────────────────
The test names describe the HTMX behavior users experience: "should return matching results" communicates the interaction pattern. By testing through the /search endpoint with query parameters, you're verifying the handler correctly processes HTMX requests without testing JavaScript—pure server-side hypermedia.
─────────────────────────────────────────────────
Template:
<form hx-post="/items" hx-target="#items">
<input type="text" name="title" required />
<button type="submit">Add Item</button>
</form>
<div id="items"></div>
Handler:
#[derive(Deserialize)]
struct CreateItem {
title: String,
}
#[derive(Template)]
#[template(path = "item.html")]
struct ItemTemplate {
item: Item,
}
async fn create_item(Form(data): Form<CreateItem>) -> impl IntoResponse {
if data.title.is_empty() {
return (StatusCode::BAD_REQUEST, "Title required").into_response();
}
let item = Item::create(data.title);
ItemTemplate { item }.into_response()
}
Acceptance Tests:
#[tokio::test]
async fn form_submission_should_create_item() {
WebApp::given()
.when_post("/items")
.with_form_data(&[("title", "New Task")])
.should_succeed()
.await
.expect_status(200)
.expect_contains("New Task");
}
#[tokio::test]
async fn form_submission_should_reject_empty_title() {
WebApp::given()
.when_post("/items")
.with_form_data(&[("title", "")])
.should_fail()
.await
.expect_status(400)
.expect_contains("Title required");
}
Handler:
async fn delete_item(Path(item_id): Path<String>) -> StatusCode {
delete_from_database(&item_id);
StatusCode::OK
}
Acceptance Test:
#[tokio::test]
async fn delete_button_should_remove_item() {
WebApp::given()
.a_file_with_content(
"## TT 2025-01-15\n\
- #project-alpha 2h Work\n",
)
.when_delete("/items/project-alpha")
.should_succeed()
.await
.expect_status(200);
}
Handler returning OOB response:
#[derive(Template)]
#[template(path = "add_to_cart_response.html")]
struct AddToCartResponse {
notification: String,
cart_count: usize,
}
async fn add_to_cart(Form(data): Form<AddToCart>) -> impl IntoResponse {
add_to_cart_db(&data.item_id);
let count = get_cart_count();
AddToCartResponse {
notification: format!("Added {} to cart", data.item_id),
cart_count: count,
}
}
Template with OOB:
{# add_to_cart_response.html #}
<!-- Main response -->
<div class="notification">{{ notification }}</div>
<!-- Out-of-band update: cart button elsewhere on page -->
<button id="cart-btn" hx-swap-oob="true">
Cart ({{ cart_count }})
</button>
Acceptance Test:
#[tokio::test]
async fn add_to_cart_should_update_cart_button() {
WebApp::given()
.when_post("/add-to-cart")
.with_form_data(&[("item_id", "widget-123")])
.should_succeed()
.await
.expect_status(200)
.expect_contains("Added widget-123 to cart")
.expect_contains("Cart (1)"); // OOB update verified
}
★ Insight ─────────────────────────────────────
Testing OOB updates verifies that a single response fragment updates multiple page sections—a powerful HTMX pattern. The test reads naturally: "should update cart button" communicates the user-visible effect without mentioning implementation details. This acceptance-test style ensures the actual rendered HTML behaves correctly.
─────────────────────────────────────────────────
Handler:
async fn get_stats() -> impl IntoResponse {
let stats = fetch_current_stats();
StatsDisplay { stats }
}
Acceptance Test:
#[tokio::test]
async fn stats_endpoint_should_return_current_data() {
WebApp::given()
.when_get("/stats")
.should_succeed()
.await
.expect_status(200)
.expect_contains("Users online")
.expect_contains("Active sessions");
}
Templates:
todo_app.html:
<!DOCTYPE html>
<html>
<head>
<title>Todo App</title>
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
</head>
<body>
<div>
<h1>My Todos</h1>
<form
hx-post="/todos"
hx-target="#todo-list"
hx-swap="beforeend"
hx-on::after-request="this.reset()"
>
<input
type="text"
name="text"
placeholder="New todo..."
required
/>
<button type="submit">Add</button>
</form>
<ul id="todo-list">
{% for todo in todos %}
<li id="todo-{{ todo.id }}">
<input
type="checkbox"
{% if todo.completed %}checked{% endif %}
hx-post="/todos/{{ todo.id }}/toggle"
hx-target="#todo-{{ todo.id }}"
hx-swap="outerHTML"
/>
<span class="{% if todo.completed %}completed{% endif %}">
{{ todo.text }}
</span>
<button
hx-delete="/todos/{{ todo.id }}"
hx-target="#todo-{{ todo.id }}"
hx-swap="outerHTML swap:500ms"
>
Delete
</button>
</li>
{% endfor %}
</ul>
</div>
</body>
</html>
Rust implementation:
use axum::{
extract::{Path, State},
Form, Router,
routing::{get, post, delete},
http::StatusCode,
response::IntoResponse,
};
use askama::Template;
use serde::Deserialize;
use std::sync::Mutex;
#[derive(Clone)]
struct Todo {
id: String,
text: String,
completed: bool,
}
#[derive(Clone)]
struct AppState {
todos: std::sync::Arc<Mutex<Vec<Todo>>>,
}
#[derive(Template)]
#[template(path = "todo_app.html")]
struct TodoApp {
todos: Vec<Todo>,
}
#[derive(Template)]
#[template(path = "todo_item.html")]
struct TodoItem {
todo: Todo,
}
#[derive(Deserialize)]
struct CreateTodo {
text: String,
}
async fn list_todos(State(state): State<AppState>) -> impl IntoResponse {
let todos = state.todos.lock().unwrap().clone();
TodoApp { todos }
}
async fn create_todo(
State(state): State<AppState>,
Form(form): Form<CreateTodo>,
) -> impl IntoResponse {
let todo = Todo {
id: uuid::Uuid::new_v4().to_string(),
text: form.text,
completed: false,
};
state.todos.lock().unwrap().push(todo.clone());
TodoItem { todo }
}
async fn toggle_todo(
State(state): State<AppState>,
Path(id): Path<String>,
) -> impl IntoResponse {
let mut todos = state.todos.lock().unwrap();
if let Some(todo) = todos.iter_mut().find(|t| t.id == id) {
todo.completed = !todo.completed;
TodoItem { todo: todo.clone() }
} else {
StatusCode::NOT_FOUND.into_response()
}
}
async fn delete_todo(
State(state): State<AppState>,
Path(id): Path<String>,
) -> StatusCode {
let mut todos = state.todos.lock().unwrap();
todos.retain(|t| t.id != id);
StatusCode::OK
}
#[tokio::main]
async fn main() {
let state = AppState {
todos: std::sync::Arc::new(Mutex::new(vec![])),
};
let app = Router::new()
.route("/todos", get(list_todos).post(create_todo))
.route("/todos/:id/toggle", post(toggle_todo))
.route("/todos/:id", delete(delete_todo))
.with_state(state);
let listener = tokio::net::TcpListener::bind("127.0.0.1:3000")
.await
.unwrap();
axum::serve(listener, app).await.unwrap();
}