--- name: frontend-htmx description: | Axum + Askama + HTMX stack for single-binary web apps. Use when: building server-rendered UI, lightweight frontends, single deployable binary. Triggers: "htmx", "askama", "templates", "server-rendered", "single binary frontend". --- # 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** | `cargo build --release` → deploy anywhere | ## Dependencies ```toml # 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 ```html {% block title %}App{% endblock %} {% block head %}{% endblock %} {% block content %}{% endblock %} ``` ## Template Structs (Askama) ```rust // 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, } #[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, } #[derive(Template)] #[template(path = "partials/note_form.html")] pub struct NoteFormTemplate { pub note: Option, // None for create, Some for edit } ``` ## Page Template ```html {% extends "base.html" %} {% block title %}Notes{% endblock %} {% block content %}

Notes

Loading...
{% endblock %} ``` ## Partial Templates ```html
{{ note.title }}

{{ note.content }}

``` ```html {% for note in notes %} {% include "partials/note_item.html" %} {% endfor %} {% if notes.is_empty() %}

No notes yet.

{% endif %} ``` ## Handlers ```rust // 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) -> 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, Form(input): Form, ) -> 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, Path(id): Path, ) -> 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, ) -> 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 { 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 | `hx-swap` | Use Case | |---------|-----------|----------| | Replace content | `innerHTML` (default) | Update container | | Replace element | `outerHTML` | Update item in list | | Add to start | `afterbegin` | New items at top | | Add to end | `beforeend` | New items at bottom | | Delete | `delete` | Remove element | ### Common Attributes ```html
Loading...
"#, ext = "html")] struct IndexTemplate { title: String } #[derive(Template)] #[template(source = "", 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.