Claude-skill-registry frontend-htmx
install
source · Clone the upstream repo
git clone https://github.com/majiayu000/claude-skill-registry
Claude Code · Install into ~/.claude/skills/
T=$(mktemp -d) && git clone --depth=1 https://github.com/majiayu000/claude-skill-registry "$T" && mkdir -p ~/.claude/skills && cp -r "$T/skills/data/frontend-htmx" ~/.claude/skills/majiayu000-claude-skill-registry-frontend-htmx && rm -rf "$T"
manifest:
skills/data/frontend-htmx/SKILL.mdsource content
Frontend: Axum + Askama + HTMX
Single binary web apps. No node_modules, no build pipeline.
Why This Stack
| Feature | Benefit |
|---|---|
| Askama | Templates compile into binary, type-checked |
| HTMX | 14kb, no JS build, hypermedia-driven |
| Single binary | → deploy anywhere |
Dependencies
# Cargo.toml additions for HTMX frontend [dependencies] askama = "0.12" askama_axum = "0.4" axum-htmx = "0.6" # HTMX header extractors tower-http = { version = "0.6", features = ["fs"] } # Static files (dev only) # HTMX served from CDN or embedded # https://unpkg.com/htmx.org@2.0.4/dist/htmx.min.js
Project Structure
src/ ├── lib.rs # API + create_app() ├── main.rs # Server entry ├── error.rs # AppError └── templates/ ├── mod.rs # Template structs ├── base.html # Layout with HTMX ├── pages/ │ ├── index.html # Home page │ └── notes.html # Notes list page └── partials/ ├── note_item.html # Single note (for HTMX swap) ├── note_list.html # Notes list partial └── note_form.html # Create/edit form templates/ # Askama looks here by default └── (symlink to src/templates or copy)
Base Template
<!-- templates/base.html --> <!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>{% block title %}App{% endblock %}</title> <script src="https://unpkg.com/htmx.org@2.0.4"></script> <style> /* Minimal CSS - extend as needed */ body { font-family: system-ui; max-width: 800px; margin: 0 auto; padding: 1rem; } .htmx-request { opacity: 0.5; } </style> {% block head %}{% endblock %} </head> <body> {% block content %}{% endblock %} </body> </html>
Template Structs (Askama)
// src/templates/mod.rs use askama::Template; #[derive(Template)] #[template(path = "pages/index.html")] pub struct IndexTemplate { pub title: String, } #[derive(Template)] #[template(path = "pages/notes.html")] pub struct NotesPageTemplate { pub notes: Vec<Note>, } #[derive(Template)] #[template(path = "partials/note_item.html")] pub struct NoteItemTemplate { pub note: Note, } #[derive(Template)] #[template(path = "partials/note_list.html")] pub struct NoteListTemplate { pub notes: Vec<Note>, } #[derive(Template)] #[template(path = "partials/note_form.html")] pub struct NoteFormTemplate { pub note: Option<Note>, // None for create, Some for edit }
Page Template
<!-- templates/pages/notes.html --> {% extends "base.html" %} {% block title %}Notes{% endblock %} {% block content %} <h1>Notes</h1> <!-- Form: POST creates note, swaps into list --> <form hx-post="/notes" hx-target="#notes-list" hx-swap="afterbegin" hx-on::after-request="this.reset()"> <input type="text" name="title" placeholder="Title" required> <input type="text" name="content" placeholder="Content" required> <button type="submit">Add</button> </form> <!-- Notes list container --> <div id="notes-list" hx-get="/notes/list" hx-trigger="load"> Loading... </div> {% endblock %}
Partial Templates
<!-- templates/partials/note_item.html --> <div id="note-{{ note.id }}" class="note"> <strong>{{ note.title }}</strong> <p>{{ note.content }}</p> <button hx-delete="/notes/{{ note.id }}" hx-target="#note-{{ note.id }}" hx-swap="outerHTML"> Delete </button> </div>
<!-- templates/partials/note_list.html --> {% for note in notes %} {% include "partials/note_item.html" %} {% endfor %} {% if notes.is_empty() %} <p>No notes yet.</p> {% endif %}
Handlers
// src/lib.rs use askama::Template; use askama_axum::IntoResponse; use axum::{ extract::{Path, State}, http::StatusCode, response::Html, routing::{delete, get, post}, Form, Router, }; use axum_htmx::HxRequest; // Page handler - returns full HTML page pub async fn notes_page() -> impl IntoResponse { NotesPageTemplate { notes: vec![] } } // Partial handler - returns HTML fragment for HTMX pub async fn notes_list(State(db): State<AppState>) -> impl IntoResponse { let notes = db.get_all_notes().await; NoteListTemplate { notes } } // Create handler - returns new item partial pub async fn create_note( State(db): State<AppState>, Form(input): Form<CreateNote>, ) -> impl IntoResponse { let note = db.create_note(input).await; (StatusCode::CREATED, NoteItemTemplate { note }) } // Delete handler - returns empty (HTMX removes element) pub async fn delete_note( State(db): State<AppState>, Path(id): Path<i64>, ) -> impl IntoResponse { db.delete_note(id).await; StatusCode::OK } // Conditional: full page vs partial based on HX-Request header pub async fn smart_notes( HxRequest(is_htmx): HxRequest, State(db): State<AppState>, ) -> impl IntoResponse { let notes = db.get_all_notes().await; if is_htmx { // HTMX request - return partial NoteListTemplate { notes }.into_response() } else { // Full page request NotesPageTemplate { notes }.into_response() } } pub fn create_app() -> Router<AppState> { Router::new() // Pages .route("/", get(notes_page)) // Partials (HTMX targets) .route("/notes/list", get(notes_list)) // API actions .route("/notes", post(create_note)) .route("/notes/:id", delete(delete_note)) }
HTMX Patterns
Swap Strategies
| Pattern | | Use Case |
|---|---|---|
| Replace content | (default) | Update container |
| Replace element | | Update item in list |
| Add to start | | New items at top |
| Add to end | | New items at bottom |
| Delete | | Remove element |
Common Attributes
<!-- Load on page load --> <div hx-get="/data" hx-trigger="load">Loading...</div> <!-- Submit form, update target --> <form hx-post="/items" hx-target="#list" hx-swap="afterbegin"> <!-- Delete with confirmation --> <button hx-delete="/items/1" hx-confirm="Are you sure?" hx-target="closest .item" hx-swap="outerHTML"> <!-- Inline editing --> <span hx-get="/items/1/edit" hx-trigger="click" hx-swap="outerHTML"> Click to edit </span> <!-- Search with debounce --> <input type="search" hx-get="/search" hx-trigger="keyup changed delay:300ms" hx-target="#results"> <!-- Infinite scroll --> <div hx-get="/items?page=2" hx-trigger="revealed" hx-swap="afterend">
Response Headers (axum-htmx)
use axum_htmx::{HxRedirect, HxRefresh, HxTrigger}; // Redirect after action pub async fn logout() -> impl IntoResponse { (HxRedirect("/login".parse().unwrap()), StatusCode::OK) } // Trigger client-side event pub async fn save() -> impl IntoResponse { (HxTrigger::normal("saved"), "OK") } // Refresh page pub async fn reset() -> impl IntoResponse { HxRefresh(true) }
Testing HTMX
#[cfg(test)] mod tests { use super::*; use axum_test::TestServer; #[tokio::test] async fn test_notes_page_returns_html() { let app = create_app(); let server = TestServer::new(app).unwrap(); let response = server.get("/").await; response.assert_status_ok(); response.assert_text_contains("<title>Notes</title>"); response.assert_text_contains("hx-get"); } #[tokio::test] async fn test_htmx_partial_returns_fragment() { let app = create_app(); let server = TestServer::new(app).unwrap(); let response = server .get("/notes/list") .add_header("HX-Request", "true") .await; response.assert_status_ok(); // Should NOT contain full HTML structure assert!(!response.text().contains("<!DOCTYPE")); } #[tokio::test] async fn test_create_note_returns_partial() { let app = create_app(); let server = TestServer::new(app).unwrap(); let response = server .post("/notes") .form(&[("title", "Test"), ("content", "Content")]) .await; response.assert_status(StatusCode::CREATED); response.assert_text_contains("Test"); } }
Embedding HTMX (No CDN)
For true single-binary without external dependencies:
// Download htmx.min.js to src/static/htmx.min.js // Then serve it embedded use axum::response::Html; const HTMX_JS: &str = include_str!("static/htmx.min.js"); pub async fn htmx_js() -> impl IntoResponse { ( [("content-type", "application/javascript")], HTMX_JS, ) } // Add route .route("/static/htmx.js", get(htmx_js)) // Update base.html <script src="/static/htmx.js"></script>
Anti-patterns
| Don't | Do Instead |
|---|---|
| Full page reload on every action | Use HTMX partials |
| Complex JS state management | Keep state on server |
| Client-side routing | Server routes + HTMX |
| Manual DOM manipulation | Let HTMX handle swaps |
| Inline styles everywhere | CSS classes + minimal inline |
in template data | Handle errors before template |
TDD for HTMX
1. tdd-test-writer: "test notes page returns HTML with hx-get" 2. rust-developer: implement page handler 3. tdd-test-writer: "test create note returns partial" 4. rust-developer: implement create handler 5. rust-code-reviewer: check all patterns
Minimal Full Example
// src/main.rs - Complete minimal app use askama::Template; use axum::{routing::get, Router}; #[derive(Template)] #[template(source = r#" <!DOCTYPE html> <html> <head> <script src="https://unpkg.com/htmx.org@2.0.4"></script> </head> <body> <h1>{{ title }}</h1> <button hx-get="/click" hx-swap="outerHTML">Click me</button> </body> </html> "#, ext = "html")] struct IndexTemplate { title: String } #[derive(Template)] #[template(source = "<button hx-get=\"/click\" hx-swap=\"outerHTML\">Clicked {{ count }} times</button>", ext = "html")] struct ButtonTemplate { count: i32 } static COUNTER: std::sync::atomic::AtomicI32 = std::sync::atomic::AtomicI32::new(0); async fn index() -> IndexTemplate { IndexTemplate { title: "HTMX Demo".into() } } async fn click() -> ButtonTemplate { let count = COUNTER.fetch_add(1, std::sync::atomic::Ordering::SeqCst) + 1; ButtonTemplate { count } } #[tokio::main] async fn main() { let app = Router::new() .route("/", get(index)) .route("/click", get(click)); let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap(); println!("Listening on http://localhost:3000"); axum::serve(listener, app).await.unwrap(); }
Build:
cargo build --release → 5-10MB binary with everything included.