maud-htmx-patterns
Server-side rendering patterns combining Maud templates with HTMX for dynamic web interactions. Covers HTMX attributes, partial rendering, form submissions, infinite scroll, polling, WebSocket fallbacks, and MASH/HARM stack patterns. Use when building interactive server-rendered UIs, creating SPAs without JavaScript frameworks, or implementing hypermedia-driven applications.
- Category: discovered
- Tags: github, community
- Version: 1.0.0
- Author: walid
- Downloads: 0
- Updated: 2025-11-22
Download the latest version (v1.0.0) from this page.
SKILL.md
---
name: maud-htmx-patterns
description: Server-side rendering patterns combining Maud templates with HTMX for dynamic web interactions. Covers HTMX attributes, partial rendering, form submissions, infinite scroll, polling, WebSocket fallbacks, and MASH/HARM stack patterns. Use when building interactive server-rendered UIs, creating SPAs without JavaScript frameworks, or implementing hypermedia-driven applications.
---
# Maud + HTMX Patterns
*Production patterns for building interactive server-side rendered applications with Maud and HTMX*
## Version Context
- **Maud**: 0.27.0
- **HTMX**: 2.0.0+
- **Axum**: 0.8.7
- **Stack**: MASH (Maud + Axum + SQLx + HTMX) / HARM (HTMX + Axum + Rust + Maud)
## When to Use This Skill
- Building interactive web UIs without heavy JavaScript frameworks
- Creating SPAs with server-side rendering
- Implementing dynamic forms and updates
- Building real-time features (polling, server-sent events)
- Implementing infinite scroll, lazy loading
- Creating CRUD interfaces with minimal client-side code
- Building hypermedia-driven applications
## Core Philosophy
**HTMX enables:**
1. Server-side rendering with client-side interactivity
2. HTML fragments as API responses (not JSON)
3. Progressive enhancement
4. Minimal JavaScript
5. Simplified state management (server is source of truth)
## Setup
### Include HTMX in Layout
```rust
use maud::{html, Markup, DOCTYPE};
fn base_layout(title: &str, content: Markup) -> Markup {
html! {
(DOCTYPE)
html lang="en" {
head {
meta charset="UTF-8";
meta name="viewport" content="width=device-width, initial-scale=1.0";
title { (title) }
link rel="stylesheet" href="/static/styles.css";
// HTMX 2.0
script src="https://unpkg.com/htmx.org@2.0.0" {}
// Optional: HTMX extensions
// script src="https://unpkg.com/htmx.org@2.0.0/dist/ext/ws.js" {}
}
body {
(content)
}
}
}
}
```
## HTMX Attributes in Maud
### Basic HTMX Attributes
```rust
html! {
// GET request on load
div hx-get="/api/data" hx-trigger="load" {
"Loading..."
}
// POST request on click
button hx-post="/api/action" hx-swap="outerHTML" {
"Click Me"
}
// DELETE with confirmation
button
hx-delete="/users/123"
hx-confirm="Are you sure?"
hx-target="#user-list"
{
"Delete User"
}
}
```
### Common HTMX Patterns
```rust
// Load content on page load
div hx-get="/partials/sidebar" hx-trigger="load" {}
// Swap strategy
button hx-post="/action" hx-swap="innerHTML" {} // Replace inner content
button hx-post="/action" hx-swap="outerHTML" {} // Replace element itself
button hx-post="/action" hx-swap="beforebegin" {} // Insert before
button hx-post="/action" hx-swap="afterend" {} // Insert after
// Target selection
button hx-post="/action" hx-target="#result" {}
button hx-post="/action" hx-target="closest .container" {}
button hx-post="/action" hx-target="next .card" {}
// Triggers
div hx-get="/data" hx-trigger="click" {}
div hx-get="/data" hx-trigger="mouseenter" {}
div hx-get="/data" hx-trigger="every 2s" {} // Polling
input hx-post="/search" hx-trigger="keyup changed delay:500ms" {}
```
## Patterns and Use Cases
### 1. Dynamic Content Loading
```rust
use axum::{routing::get, Router};
use maud::{html, Markup};
// Initial page
async fn page() -> Markup {
html! {
h1 { "Dashboard" }
div #stats hx-get="/partials/stats" hx-trigger="load" {
p { "Loading stats..." }
}
}
}
// Partial endpoint
async fn stats_partial(State(db): State<Arc<Database>>) -> Markup {
let stats = db.get_stats().await;
html! {
div.stats-grid {
div.stat-card {
h3 { "Users" }
p.stat-value { (stats.user_count) }
}
div.stat-card {
h3 { "Posts" }
p.stat-value { (stats.post_count) }
}
}
}
}
fn router() -> Router {
Router::new()
.route("/dashboard", get(page))
.route("/partials/stats", get(stats_partial))
}
```
### 2. Form Submission with Validation
```rust
use axum::{extract::Form, http::StatusCode};
use serde::Deserialize;
#[derive(Deserialize)]
struct CreateUserForm {
name: String,
email: String,
}
// Initial form
async fn user_form() -> Markup {
html! {
form
hx-post="/users"
hx-target="#form-container"
hx-swap="outerHTML"
{
(user_form_fields(None, None))
button type="submit" { "Create User" }
}
}
}
// Form fields (reusable)
fn user_form_fields(
errors: Option<&ValidationErrors>,
values: Option<&CreateUserForm>,
) -> Markup {
html! {
div.form-group {
label for="name" { "Name" }
input
type="text"
name="name"
id="name"
value=[values.map(|v| v.name.as_str())];
@if let Some(errs) = errors {
@if let Some(err) = errs.name {
span.error { (err) }
}
}
}
div.form-group {
label for="email" { "Email" }
input
type="email"
name="email"
id="email"
value=[values.map(|v| v.email.as_str())];
@if let Some(errs) = errors {
@if let Some(err) = errs.email {
span.error { (err) }
}
}
}
}
}
// POST handler
async fn create_user(
State(db): State<Arc<Database>>,
Form(form): Form<CreateUserForm>,
) -> Result<Markup, (StatusCode, Markup)> {
// Validate
if let Err(errors) = validate_user(&form) {
return Err((
StatusCode::BAD_REQUEST,
html! {
form
hx-post="/users"
hx-target="#form-container"
hx-swap="outerHTML"
{
(user_form_fields(Some(&errors), Some(&form)))
button type="submit" { "Create User" }
}
}
));
}
// Create user
let user = db.create_user(&form.name, &form.email).await.map_err(|_| {
(
StatusCode::INTERNAL_SERVER_ERROR,
html! {
div.error { "Failed to create user" }
}
)
})?;
// Return success message
Ok(html! {
div.success {
p { "User created successfully!" }
a href="/users" { "View all users" }
}
})
}
```
### 3. CRUD Operations
```rust
// List with inline actions
async fn todo_list(State(db): State<Arc<Database>>) -> Markup {
let todos = db.get_todos().await;
html! {
div #todo-list {
h2 { "Todo List" }
ul {
@for todo in todos {
(todo_item(&todo))
}
}
form
hx-post="/todos"
hx-target="#todo-list"
hx-swap="outerHTML"
{
input
type="text"
name="text"
placeholder="New todo...";
button type="submit" { "Add" }
}
}
}
}
fn todo_item(todo: &Todo) -> Markup {
html! {
li #{"todo-" (todo.id)} {
input
type="checkbox"
checked[todo.completed]
hx-post={"/todos/" (todo.id) "/toggle"}
hx-target={"#todo-" (todo.id)}
hx-swap="outerHTML";
span[todo.completed] { (todo.text) }
button
hx-delete={"/todos/" (todo.id)}
hx-target={"#todo-" (todo.id)}
hx-swap="outerHTML swap:1s"
hx-confirm="Delete this todo?"
{
"Delete"
}
}
}
}
// Toggle handler
async fn toggle_todo(
State(db): State<Arc<Database>>,
Path(id): Path<u64>,
) -> Result<Markup, AppError> {
let todo = db.toggle_todo(id).await?;
Ok(todo_item(&todo))
}
// Delete handler
async fn delete_todo(
State(db): State<Arc<Database>>,
Path(id): Path<u64>,
) -> Result<StatusCode, AppError> {
db.delete_todo(id).await?;
Ok(StatusCode::OK) // HTMX will remove the element
}
// Create handler
async fn create_todo(
State(db): State<Arc<Database>>,
Form(form): Form<CreateTodoForm>,
) -> Result<Markup, AppError> {
db.create_todo(&form.text).await?;
// Return the updated list
todo_list(State(db)).await
}
```
### 4. Search with Debouncing
```rust
async fn search_page() -> Markup {
html! {
div {
h1 { "Search" }
input
type="search"
name="q"
placeholder="Search..."
hx-get="/search/results"
hx-trigger="keyup changed delay:500ms"
hx-target="#search-results";
div #search-results {
p.text-muted { "Start typing to search..." }
}
}
}
}
async fn search_results(
State(db): State<Arc<Database>>,
Query(params): Query<SearchQuery>,
) -> Markup {
if params.q.is_empty() {
return html! {
p.text-muted { "Start typing to search..." }
};
}
let results = db.search(¶ms.q).await;
if results.is_empty() {
return html! {
p { "No results found for \"" (params.q) "\"" }
};
}
html! {
ul.search-results {
@for result in results {
li {
a href={"/posts/" (result.id)} {
strong { (result.title) }
p { (result.excerpt) }
}
}
}
}
}
}
```
### 5. Infinite Scroll
```rust
async fn posts_page() -> Markup {
html! {
div {
h1 { "Posts" }
div #posts {
(posts_partial(1))
}
}
}
}
fn posts_partial(page: u32) -> Markup {
let posts = fetch_posts(page, 10);
let has_more = posts.len() == 10;
html! {
@for post in posts {
article.post {
h2 { (post.title) }
p { (post.excerpt) }
}
}
@if has_more {
div
hx-get={"/posts/page/" (page + 1)}
hx-trigger="intersect once"
hx-swap="afterend"
{
p { "Loading more..." }
}
}
}
}
async fn posts_page_handler(Path(page): Path<u32>) -> Markup {
posts_partial(page)
}
```
### 6. Polling for Updates
```rust
// Auto-refresh every 5 seconds
async fn live_dashboard() -> Markup {
html! {
div
#dashboard
hx-get="/dashboard/refresh"
hx-trigger="every 5s"
hx-swap="innerHTML"
{
(dashboard_content())
}
}
}
async fn dashboard_content() -> Markup {
let stats = fetch_current_stats();
html! {
div.stats {
div.stat {
h3 { "Active Users" }
p.value { (stats.active_users) }
}
div.stat {
h3 { "Requests/min" }
p.value { (stats.requests_per_min) }
}
p.updated { "Last updated: " (stats.timestamp) }
}
}
}
```
### 7. Modal Dialog Pattern
```rust
// Trigger
fn trigger_button() -> Markup {
html! {
button
hx-get="/modal/user-profile/123"
hx-target="body"
hx-swap="beforeend"
{
"View Profile"
}
}
}
// Modal endpoint
async fn user_modal(Path(user_id): Path<u64>) -> Markup {
let user = fetch_user(user_id).await;
html! {
div.modal-overlay {
div.modal {
div.modal-header {
h2 { (user.name) }
button.close hx-on:click="this.closest('.modal-overlay').remove()" {
"×"
}
}
div.modal-body {
p { "Email: " (user.email) }
p { "Role: " (user.role) }
}
div.modal-footer {
button hx-on:click="this.closest('.modal-overlay').remove()" {
"Close"
}
}
}
}
}
}
```
### 8. Multi-Step Form
```rust
// Step 1
async fn signup_step1() -> Markup {
html! {
form
hx-post="/signup/step2"
hx-target="#form-container"
hx-swap="innerHTML"
{
h2 { "Step 1: Basic Info" }
input type="text" name="name" placeholder="Name" required;
input type="email" name="email" placeholder="Email" required;
button type="submit" { "Next" }
}
}
}
// Step 2
async fn signup_step2(Form(step1): Form<Step1Data>) -> Markup {
html! {
form
hx-post="/signup/step3"
hx-target="#form-container"
hx-swap="innerHTML"
{
h2 { "Step 2: Preferences" }
// Hidden fields from step 1
input type="hidden" name="name" value=(step1.name);
input type="hidden" name="email" value=(step1.email);
select name="role" {
option value="user" { "User" }
option value="admin" { "Admin" }
}
button type="button" hx-get="/signup/step1" { "Back" }
button type="submit" { "Next" }
}
}
}
// Final step
async fn signup_step3(Form(data): Form<SignupData>) -> Result<Markup, AppError> {
create_user(data).await?;
Ok(html! {
div.success {
h2 { "Account Created!" }
p { "Welcome, " (data.name) "!" }
a href="/dashboard" { "Go to Dashboard" }
}
})
}
```
### 9. File Upload with Progress
```rust
async fn upload_form() -> Markup {
html! {
form
hx-encoding="multipart/form-data"
hx-post="/upload"
hx-target="#upload-result"
{
input type="file" name="file";
button type="submit" { "Upload" }
div #upload-result {}
}
}
}
async fn handle_upload(
mut multipart: Multipart,
) -> Result<Markup, AppError> {
while let Some(field) = multipart.next_field().await? {
let name = field.name().unwrap_or("").to_string();
let data = field.bytes().await?;
// Process file...
}
Ok(html! {
div.success {
p { "File uploaded successfully!" }
}
})
}
```
### 10. Optimistic Updates
```rust
fn todo_item_optimistic(todo: &Todo) -> Markup {
html! {
li #{"todo-" (todo.id)} {
input
type="checkbox"
checked[todo.completed]
hx-post={"/todos/" (todo.id) "/toggle"}
hx-target={"#todo-" (todo.id)}
hx-swap="outerHTML"
// Immediately toggle appearance
"hx-on::before-request"="this.checked = !this.checked";
span[todo.completed] { (todo.text) }
}
}
}
```
## Advanced Patterns
### Server-Sent Events (SSE) for Real-Time Updates
```rust
use axum::{
response::sse::{Event, Sse},
response::IntoResponse,
};
use tokio_stream::{Stream, StreamExt};
use std::convert::Infallible;
async fn notifications_stream() -> Sse<impl Stream<Item = Result<Event, Infallible>>> {
let stream = async_stream::stream! {
let mut interval = tokio::time::interval(Duration::from_secs(5));
loop {
interval.tick().await;
let notification = fetch_latest_notification().await;
let html = html! {
div.notification {
(notification.message)
}
}.into_string();
yield Ok(Event::default().data(html));
}
};
Sse::new(stream)
}
// In template
fn notifications_panel() -> Markup {
html! {
div
hx-ext="sse"
sse-connect="/notifications/stream"
sse-swap="message"
hx-swap="beforeend"
{
p { "Waiting for notifications..." }
}
}
}
```
### Out-of-Band Swaps (Multiple Updates)
```rust
async fn update_with_side_effects() -> Markup {
html! {
// Main update
div #main-content {
p { "Content updated" }
}
// Out-of-band update (updates another part of the page)
div
#notification-count
hx-swap-oob="true"
{
span.badge { "5" }
}
}
}
```
### Request Headers and HX-* Headers
```rust
use axum::http::HeaderMap;
async fn smart_handler(headers: HeaderMap) -> Markup {
// Check if request is from HTMX
let is_htmx = headers
.get("HX-Request")
.and_then(|v| v.to_str().ok())
.map(|v| v == "true")
.unwrap_or(false);
if is_htmx {
// Return partial
html! {
div { "Partial content" }
}
} else {
// Return full page
base_layout("Page", html! {
div { "Full page content" }
})
}
}
```
## Production Considerations
### Error Handling
```rust
impl IntoResponse for AppError {
fn into_response(self) -> Response {
let status = match &self {
AppError::NotFound => StatusCode::NOT_FOUND,
AppError::Validation(_) => StatusCode::BAD_REQUEST,
_ => StatusCode::INTERNAL_SERVER_ERROR,
};
let markup = html! {
div.alert.alert-error hx-swap-oob="true" #error-container {
(self.to_string())
}
};
(status, markup).into_response()
}
}
```
### CSRF Protection
```rust
use axum_csrf::{CsrfConfig, CsrfToken};
async fn form_with_csrf(csrf_token: CsrfToken) -> Markup {
html! {
form hx-post="/submit" {
input type="hidden" name="csrf_token" value=(csrf_token.authenticity_token());
input type="text" name="data";
button type="submit" { "Submit" }
}
}
}
```
### Loading States
```rust
fn button_with_loading() -> Markup {
html! {
button
hx-post="/action"
hx-indicator="#spinner"
{
span { "Submit" }
span #spinner .htmx-indicator {
"Loading..."
}
}
}
}
```
## Best Practices
1. **Return HTML fragments, not JSON**: HTMX expects HTML responses
2. **Use semantic HTTP status codes**: HTMX respects status codes
3. **Progressive enhancement**: Ensure forms work without HTMX
4. **Idempotent operations**: GET should be safe, PUT/DELETE idempotent
5. **Use HX-Target wisely**: Specify targets to avoid page jumps
6. **Debounce user input**: Use `delay:` for search/autocomplete
7. **Handle errors gracefully**: Return error HTML, not JSON
8. **Use OOB swaps sparingly**: Too many can be confusing
9. **Test without JavaScript**: Forms should have fallback behavior
## Common Attributes Reference
```rust
// Triggers
"hx-trigger"="click" // On click
"hx-trigger"="load" // On page load
"hx-trigger"="every 2s" // Polling
"hx-trigger"="intersect once" // Infinite scroll
"hx-trigger"="keyup changed delay:500ms" // Debounced input
// Swapping
"hx-swap"="innerHTML" // Replace inner HTML
"hx-swap"="outerHTML" // Replace element itself
"hx-swap"="beforebegin" // Insert before element
"hx-swap"="