Use when working with htmxology ā the type-safe HTMX routing framework for Rust/Axum...
htmxology is a type-safe HTMX routing framework for Rust/Axum. It replaces string-based routing with enum-based routes that are verified at compile time, and provides automatic HTMX request detection, response building, and out-of-band swap support.
Define routes as enum variants with #[derive(htmxology::Route)]:
#[derive(Debug, Clone, htmxology::Route)]
pub enum CoffeesRoute {
/// GET /coffees/ (with query params)
#[route("")]
ListCoffees(#[query] SortParams),
/// GET /coffees/new
#[route("new")]
NewCoffee,
/// POST /coffees/
#[route("", method = "POST")]
CreateCoffee(#[body] CreateCoffee),
/// GET /coffees/{id}
#[route("{id}")]
ShowCoffee(Uuid),
/// GET /coffees/{id}/edit
#[route("{id}/edit")]
EditCoffee(Uuid),
/// PUT /coffees/{id}
#[route("{id}", method = "PUT")]
UpdateCoffee(Uuid, #[body] CreateCoffee),
/// POST /coffees/{id} (form compatibility)
#[route("{id}", method = "POST")]
UpdateCoffeePost(Uuid, #[body] CreateCoffee),
/// DELETE /coffees/{id}
#[route("{id}", method = "DELETE")]
DeleteCoffee(Uuid),
}
| Attribute | Purpose | Example |
|---|---|---|
#[route("path")] |
URL path pattern | #[route("{id}/edit")] |
method = "..." |
HTTP method (default: GET) | method = "POST", method = "DELETE" |
#[query] |
Extract query parameters | ListCoffees(#[query] SortParams) |
#[body] |
Extract request body (form/JSON) | CreateCoffee(#[body] CreateCoffee) |
| Path params | Tuple fields become path segments | ShowCoffee(Uuid) matches {id} |
Use serde attributes for clean URLs ā defaults are skipped:
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct SortParams {
#[serde(default = "default_sort", skip_serializing_if = "is_default_sort")]
pub sort: String,
#[serde(default = "default_page", skip_serializing_if = "is_default_page")]
pub page: i64,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub q: String,
}
Each feature implements Controller to handle its routes:
use htmxology::{Controller, ServerInfo, htmx::Request as HtmxRequest};
impl Controller for CoffeesController {
type Route = CoffeesRoute;
type Args = AppArgs;
type Response = Result<crate::response::Response, axum::response::Response>;
async fn handle_request(
&self,
route: Self::Route,
_htmx: HtmxRequest,
mut parts: Parts,
_server_info: &ServerInfo,
_args: Self::Args,
) -> Self::Response {
let state = State(self.pool.clone());
let auth_ctx = AuthContext::from_request_parts(&mut parts, &()).await?;
match route {
CoffeesRoute::ListCoffees(params) => {
handlers::list_coffees(state, auth_ctx, params).await
}
CoffeesRoute::NewCoffee => handlers::show_new_coffee_form(state).await,
CoffeesRoute::CreateCoffee(body) => {
handlers::create_coffee(state, auth_ctx, Form(body)).await
}
CoffeesRoute::ShowCoffee(id) => {
handlers::show_coffee_detail(state, auth_ctx, Path(id)).await
}
CoffeesRoute::EditCoffee(id) => {
handlers::show_edit_coffee_form(state, auth_ctx, Path(id)).await
}
CoffeesRoute::UpdateCoffee(id, body) | CoffeesRoute::UpdateCoffeePost(id, body) => {
handlers::update_coffee(state, auth_ctx, Path(id), Form(body)).await
}
CoffeesRoute::DeleteCoffee(id) => {
handlers::delete_coffee(state, auth_ctx, Path(id)).await
}
}
}
}
| Type | Purpose |
|---|---|
Route |
The route enum this controller handles |
Args |
Shared context passed to handlers (e.g., DB pool + user prefs) |
Response |
Return type ā typically Result<Response, axum::response::Response> |
Controllers provide a from_app factory for the RoutingController to instantiate them:
impl CoffeesController {
pub fn new(pool: sqlx::PgPool) -> Self {
Self { pool }
}
pub fn from_app(app: &AppController) -> Self {
Self::new(app.pool().clone())
}
}
Use #[derive(RoutingController)] to compose multiple controllers into a hierarchy:
use htmxology::RoutingController;
#[derive(Clone, RoutingController)]
#[controller(AppControllerRoute, response = Result<crate::response::Response, axum::response::Response>, args = AppArgs)]
#[subcontroller(HomeController, route = Home)]
#[subcontroller(AuthController, route = Auth, path = "auth/", convert_response = "AppController::convert_auth_response")]
#[subcontroller(CoffeesController, route = Coffees, path = "coffees/", convert_with = "CoffeesController::from_app")]
#[subcontroller(RoastersController, route = Roasters, path = "roasters/", convert_with = "RoastersController::from_app")]
#[subcontroller(UserController, route = User, path = "user/", convert_with = "UserController::from_app")]
#[subcontroller(PreferencesController, route = Preferences, path = "preferences/", convert_response = "AppController::convert_preferences_response")]
pub struct AppController {
pool: PgPool,
}
| Attribute | Purpose |
|---|---|
#[controller(RouteName, response = ..., args = ...)] |
Defines the auto-generated route enum name and types |
route = Variant |
Variant name in the generated AppControllerRoute enum |
path = "prefix/" |
URL path prefix for this subcontroller |
convert_with = "fn" |
Factory function to create subcontroller from parent |
convert_response = "fn" |
Custom function to convert subcontroller response to parent response |
The macro generates a composite route enum:
// Auto-generated by RoutingController derive
pub enum AppControllerRoute {
Home(HomeRoute),
Auth(AuthRoute),
Coffees(CoffeesRoute),
Roasters(RoastersRoute),
User(UserRoute),
Preferences(PreferencesRoute),
}
This enum implements Display (for URL generation) and Route (for parsing).
When a subcontroller has a different response type, provide a converter:
impl AppController {
fn convert_auth_response(
&self,
_htmx: &htmxology::htmx::Request,
_parts: &http::request::Parts,
_server_info: &htmxology::ServerInfo,
_args: &AppArgs,
response: <AuthController as htmxology::Controller>::Response,
) -> <Self as htmxology::Controller>::Response {
Err(response.unwrap_or_else(|e| e))
}
}
Template structs expose methods that return AppControllerRoute values for type-safe URL generation:
use askama::Template;
#[derive(Template)]
#[template(path = "coffees/templates/list.html")]
pub struct CoffeesListTemplate {
pub coffees: Vec<CoffeeListItem>,
pub sort: String,
pub q: String,
}
impl CoffeesListTemplate {
pub fn new_route(&self) -> AppControllerRoute {
AppControllerRoute::Coffees(CoffeesRoute::NewCoffee)
}
pub fn list_route(&self) -> AppControllerRoute {
AppControllerRoute::Coffees(CoffeesRoute::ListCoffees(SortParams::default()))
}
pub fn detail_route(&self, id: &Uuid) -> AppControllerRoute {
AppControllerRoute::Coffees(CoffeesRoute::ShowCoffee(*id))
}
pub fn edit_route(&self, id: &Uuid) -> AppControllerRoute {
AppControllerRoute::Coffees(CoffeesRoute::EditCoffee(*id))
}
pub fn delete_route(&self, id: &Uuid) -> AppControllerRoute {
AppControllerRoute::Coffees(CoffeesRoute::DeleteCoffee(*id))
}
}
Routes auto-convert to URL strings via their Display implementation:
{# Regular link ā route renders as URL string #}
<a href="{{ self.new_route() }}" class="btn btn-primary">+ Add Coffee</a>
{# Link with path parameter #}
<a href="{{ self.detail_route(coffee.id) }}">{{ coffee.name }}</a>
{# HTMX GET with type-safe route #}
<input hx-get="{{ self.list_route() }}"
hx-trigger="input changed delay:500ms, search"
hx-target="#coffee-list-container"
hx-select="#coffee-list-container"
hx-swap="innerHTML"
hx-push-url="true" />
as_htmx_attribute() for Method-Aware RoutesFor routes with non-GET methods (POST, PUT, DELETE), use as_htmx_attribute() to automatically generate the correct hx-* attribute:
{# Generates: hx-delete="/coffees/{id}" #}
<button {{ self.delete_route(coffee.id).as_htmx_attribute() | safe }}
hx-confirm="Delete this coffee?"
hx-target="closest tr"
hx-swap="outerHTML swap:1s">
Delete
</button>
Critical: Always use | safe filter with as_htmx_attribute() ā it outputs raw HTML attributes.
Template route helpers can reference routes from other controllers:
impl NewCoffeeTemplate {
pub fn roaster_autocomplete_route(&self) -> AppControllerRoute {
AppControllerRoute::Roasters(RoastersRoute::Autocomplete(AutocompleteParams::default()))
}
}
htmxology automatically detects HTMX requests via the HX-Request header and provides a typed enum:
use htmxology::htmx::Request as HtmxRequest;
match htmx {
HtmxRequest::Classic => {
// Full browser navigation ā wrap content in Root template
let root = Root {
breadcrumbs: breadcrumbs.unwrap_or_default(),
main_container: MainContainer(page),
color_theme,
};
root.render_into_response()
}
HtmxRequest::Htmx { .. } => {
// HTMX request ā return partial HTML (MainContainer only)
let main_container = MainContainer(page);
let mut response = main_container.into_htmx_response();
// Attach out-of-band updates
if let Some(breadcrumbs) = breadcrumbs {
response = response.with_oob(breadcrumbs);
}
response.into_response()
}
}
The HtmxRequest::Htmx variant also carries metadata:
boosted: bool ā whether hx-boost triggered the requestcurrent_url: String ā the page URL when the request was madetarget: Option<...> ā the hx-target element IDtrigger: Option<...> ā the triggering element IDUse a response enum to standardize what handlers return:
pub enum Response {
/// Empty response ā e.g., after a DELETE
Empty,
/// Page with optional breadcrumbs ā App wrapper handles classic vs HTMX
Page {
page: Page,
breadcrumbs: Option<Breadcrumbs>,
},
/// Direct HTMX response ā bypasses App wrapper entirely
Htmx(htmxology::htmx::Response<String>),
}
pub type HtmxResponse = htmxology::htmx::Response<String>;
// Page response (most common) ā App wraps in Root or MainContainer
Ok(Response::Page {
page: Page::Coffees(Box::new(Pages::List(template))),
breadcrumbs: None,
})
// Empty response ā returns 200 OK with no body
Ok(Response::Empty)
// Redirect via error path ā Axum's Redirect is not a Response variant
Err(Redirect::to("/coffees/").into_response())
// Error response
Err((StatusCode::NOT_FOUND, "Coffee not found").into_response())
OOB swaps let a single response update multiple page sections. htmxology provides typed support.
Implement Identity (stable HTML id) and Fragment (swap strategy) traits:
use askama::Template;
use htmxology::htmx::{Fragment, HtmlId, Identity, InsertStrategy};
#[derive(Template, Default)]
#[template(path = "breadcrumbs.html")]
pub struct Breadcrumbs {
items: Vec<BreadcrumbItem>,
}
impl Identity for Breadcrumbs {
fn id(&self) -> HtmlId {
HtmlId::from_static("breadcrumbs").expect("valid id")
}
}
impl Fragment for Breadcrumbs {
fn insert_strategy(&self) -> InsertStrategy {
InsertStrategy::OuterHtml
}
}
let main_container = MainContainer(page);
let mut response = main_container.into_htmx_response();
if let Some(breadcrumbs) = breadcrumbs {
response = response.with_oob(breadcrumbs);
}
response.into_response()
In templates, use hx-swap-oob="true" on elements with stable IDs:
<!-- OOB: updates the hidden sort input -->
<input type="hidden" id="current-sort" name="sort" value="{{ sort }}" hx-swap-oob="true" />
<!-- OOB: updates the sort buttons -->
<div id="sort-buttons" class="d-flex gap-2" hx-swap-oob="true">
{% include "coffees/templates/sort_buttons.html" %}
</div>
<!-- Primary content (goes to hx-target) -->
{% include "coffees/templates/list_table.html" %}
Use #[derive(DisplayDelegate)] to auto-implement Display for page enums that delegate to their inner types:
use htmxology::DisplayDelegate;
#[derive(DisplayDelegate)]
pub enum Page {
Coffees(Box<coffees::pages::Pages>),
Roasters(roasters::pages::Pages),
User(user::pages::Pages),
}
#[derive(DisplayDelegate)]
pub enum Pages {
List(CoffeesListTemplate),
Detail(CoffeeDetailTemplate),
New(NewCoffeeTemplate),
Edit(EditCoffeeTemplate),
}
This allows {{ page|safe }} in templates to render whichever variant is active.
Wire the controller hierarchy into Axum:
use htmxology::ControllerRouter;
// Create router from controller + args factory
let controller_router = ControllerRouter::new(controller, |c| {
let pool = c.pool().clone();
async move { pool }
});
// Convert to Axum Router
let router: axum::Router = controller_router.into();
use htmxology::{Server, ServerOptions};
let base_url: http::Uri = "http://localhost:3000".parse()?;
let options = ServerOptions {
base_url: Some(base_url),
};
let server = Server::builder(listener)
.with_options(options)
.build();
server.serve_with_router(controller_router).await?;
Each feature is self-contained ā Rust code and HTML templates live together in the same module directory under src/. This keeps features cohesive and easy to move or refactor.
Standard directory layout for each feature:
src/{feature}/
āāā mod.rs # Module exports
āāā controller.rs # Route enum + Controller impl
āāā handlers.rs # Async handler functions
āāā pages.rs # Pages enum with DisplayDelegate
āāā templates.rs # Askama template structs + route helpers
āāā data.rs # Data access layer (optional)
āāā queries.rs # SQL queries (optional)
āāā templates/
āāā list.html # List page
āāā list_table.html # Table partial (HTMX swappable)
āāā list_results.html # Results with OOB updates
āāā sort_buttons.html # Sort controls
āāā new.html # Create form
āāā edit.html # Edit form
āāā detail.html # Detail view
src/{feature}/ directory with the files aboveRoute enum with #[derive(htmxology::Route)]Controller trait with route matchingAppController with #[subcontroller(...)]Set on the body to boost all links and forms:
<body hx-boost="true"
hx-target="#main-container"
hx-swap="outerHTML"
hx-indicator=".htmx-indicator">
All regular links become HTMX requests. The App controller detects HtmxRequest::Htmx and returns partials.
<button {{ self.delete_route(coffee.id).as_htmx_attribute() | safe }}
hx-confirm="Delete this coffee?"
hx-target="closest tr"
hx-swap="outerHTML swap:1s">
<i class="bi bi-trash"></i>
</button>
Handler returns Ok(Response::Empty) ā the row is removed from the DOM.
Combine hx-include and hx-vals to compose query parameters from multiple inputs:
<!-- Search input includes sort param -->
<input type="search" name="q"
hx-get="{{ self.list_route() }}"
hx-trigger="input changed delay:500ms, search"
hx-target="#coffee-list-container"
hx-select="#coffee-list-container"
hx-swap="innerHTML"
hx-include="#current-sort"
hx-push-url="true" />
<!-- Sort buttons include search param -->
<button hx-get="{{ self.list_route() }}"
hx-target="#coffee-list-container"
hx-select="#coffee-list-container"
hx-swap="innerHTML"
hx-vals='{"sort": "name"}'
hx-include="#coffee-filter"
hx-push-url="true">
Name
</button>
use htmxology::RenderIntoResponse;
// Renders Askama template directly into an axum Response
root.render_into_response()
// Renders template into an HTMX-aware response (supports .with_oob())
let response = main_container.into_htmx_response();