Production patterns for integrating Maud HTML templates with Axum 0.8.x web services. Covers IntoResponse implementation, handler patterns, state management with templates, error pages, layouts, and server-side rendering architecture. Use when building Axum HTTP endpoints that return HTML, creating web UIs, or implementing server-side rendered applications.
Installation
Details
Usage
After installing, this skill will be available to your AI coding assistant.
Verify installation:
npx agent-skills-cli listSkill Instructions
name: maud-axum-integration description: Production patterns for integrating Maud HTML templates with Axum 0.8.x web services. Covers IntoResponse implementation, handler patterns, state management with templates, error pages, layouts, and server-side rendering architecture. Use when building Axum HTTP endpoints that return HTML, creating web UIs, or implementing server-side rendered applications.
Maud + Axum Integration
Production patterns for server-side rendered HTML with Maud and Axum
Version Context
- Maud: 0.27.0 (with
axumfeature) - Axum: 0.8.7
- Tokio: 1.48.0
- Rust Edition: 2021
When to Use This Skill
- Building server-side rendered web applications with Axum
- Creating HTML endpoints in Axum routers
- Implementing layouts and page templates
- Rendering error pages with proper HTTP status codes
- Combining Axum state with Maud templates
- Building MASH/HARM stack applications (Maud + Axum + SQLx + HTMX)
Setup
Cargo.toml
[dependencies]
# Maud with Axum integration
maud = { version = "0.27", features = ["axum"] }
# Axum and runtime
axum = { version = "0.8", features = ["macros"] }
tokio = { version = "1", features = ["full"] }
# Tower middleware
tower = "0.5"
tower-http = { version = "0.6", features = ["trace", "compression", "fs"] }
# Serialization and errors
serde = { version = "1", features = ["derive"] }
thiserror = "2"
Basic Integration
Simple Handler Returning Markup
use axum::{routing::get, Router};
use maud::{html, Markup, DOCTYPE};
async fn index() -> Markup {
html! {
(DOCTYPE)
html lang="en" {
head {
meta charset="UTF-8";
title { "Hello Axum + Maud" }
}
body {
h1 { "Hello from Axum and Maud!" }
p { "Server-side rendered HTML" }
}
}
}
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let app = Router::new().route("/", get(index));
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await?;
axum::serve(listener, app).await?;
Ok(())
}
Key: The axum feature enables Markup to implement IntoResponse automatically.
Handler with State
use axum::{
extract::State,
routing::get,
Router,
};
use maud::{html, Markup};
use std::sync::Arc;
#[derive(Clone)]
struct AppState {
app_name: String,
version: String,
}
async fn index(State(state): State<Arc<AppState>>) -> Markup {
html! {
(DOCTYPE)
html {
head {
title { (state.app_name) }
}
body {
h1 { (state.app_name) }
p { "Version: " (state.version) }
}
}
}
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let state = Arc::new(AppState {
app_name: "My App".to_string(),
version: "1.0.0".to_string(),
});
let app = Router::new()
.route("/", get(index))
.with_state(state);
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await?;
axum::serve(listener, app).await?;
Ok(())
}
Layout Pattern
Base Layout Component
use maud::{html, Markup, DOCTYPE};
pub 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";
// Include HTMX for dynamic interactions
script src="https://unpkg.com/htmx.org@2.0.0" {}
}
body {
header {
nav.navbar {
a.nav-link href="/" { "Home" }
a.nav-link href="/about" { "About" }
a.nav-link href="/contact" { "Contact" }
}
}
main.container {
(content)
}
footer {
p { "© 2025 My Application" }
}
}
}
}
}
// Usage in handlers
async fn home_page() -> Markup {
base_layout("Home", html! {
h1 { "Welcome Home" }
p { "This is the home page content" }
})
}
Layout with State
use axum::extract::State;
use std::sync::Arc;
pub fn authenticated_layout(
user_name: &str,
page_title: &str,
content: Markup,
) -> Markup {
html! {
(DOCTYPE)
html {
head {
title { (page_title) " - My App" }
link rel="stylesheet" href="/static/styles.css";
}
body {
header {
nav {
span { "Welcome, " (user_name) }
a href="/logout" { "Logout" }
}
}
main {
(content)
}
}
}
}
}
#[derive(Clone)]
struct AppState {
current_user: String,
}
async fn dashboard(State(state): State<Arc<AppState>>) -> Markup {
authenticated_layout(
&state.current_user,
"Dashboard",
html! {
h1 { "Dashboard" }
p { "User-specific content here" }
},
)
}
Error Handling with IntoResponse
Custom Error Type
use axum::{
http::StatusCode,
response::{IntoResponse, Response},
};
use maud::{html, Markup};
use thiserror::Error;
#[derive(Debug, Error)]
pub enum AppError {
#[error("not found")]
NotFound,
#[error("unauthorized")]
Unauthorized,
#[error("database error: {0}")]
Database(#[from] sqlx::Error),
#[error("internal error: {0}")]
Internal(String),
}
impl IntoResponse for AppError {
fn into_response(self) -> Response {
let (status, title, message) = match self {
AppError::NotFound => (
StatusCode::NOT_FOUND,
"404 - Not Found",
"The page you're looking for doesn't exist.",
),
AppError::Unauthorized => (
StatusCode::UNAUTHORIZED,
"401 - Unauthorized",
"You must be logged in to view this page.",
),
AppError::Database(_) => (
StatusCode::INTERNAL_SERVER_ERROR,
"500 - Internal Server Error",
"A database error occurred. Please try again later.",
),
AppError::Internal(_) => (
StatusCode::INTERNAL_SERVER_ERROR,
"500 - Internal Server Error",
"An internal error occurred. Please try again later.",
),
};
let markup = error_page(status.as_u16(), title, message);
(status, markup).into_response()
}
}
fn error_page(code: u16, title: &str, message: &str) -> Markup {
base_layout(title, html! {
div.error-container {
h1 { (code) }
h2 { (title) }
p { (message) }
a href="/" { "← Return Home" }
}
})
}
// Handler using Result
async fn get_user(
State(db): State<Arc<Database>>,
Path(id): Path<String>,
) -> Result<Markup, AppError> {
let user = db.find_user(&id).await?; // Automatically converts DB errors
Ok(html! {
div.user-profile {
h1 { (user.name) }
p { (user.email) }
}
})
}
Dynamic Routes with Path Extraction
use axum::{
extract::{Path, State},
routing::get,
Router,
};
use maud::{html, Markup};
use std::sync::Arc;
// Post model
#[derive(Clone)]
struct Post {
id: u64,
title: String,
content: String,
author: String,
}
#[derive(Clone)]
struct AppState {
posts: Vec<Post>,
}
async fn post_detail(
State(state): State<Arc<AppState>>,
Path(id): Path<u64>,
) -> Result<Markup, AppError> {
let post = state
.posts
.iter()
.find(|p| p.id == id)
.ok_or(AppError::NotFound)?;
Ok(base_layout(&post.title, html! {
article {
h1 { (post.title) }
p.author { "By " (post.author) }
div.content {
p { (post.content) }
}
a href="/posts" { "← Back to all posts" }
}
}))
}
async fn post_list(State(state): State<Arc<AppState>>) -> Markup {
base_layout("All Posts", html! {
h1 { "Blog Posts" }
ul.post-list {
@for post in &state.posts {
li {
a href={ "/posts/" (post.id) } {
(post.title)
}
" by " (post.author)
}
}
}
})
}
fn create_router(state: Arc<AppState>) -> Router {
Router::new()
.route("/posts", get(post_list))
.route("/posts/:id", get(post_detail))
.with_state(state)
}
Query Parameters
use axum::extract::Query;
use serde::Deserialize;
#[derive(Deserialize)]
struct Pagination {
page: Option<u32>,
per_page: Option<u32>,
}
async fn search_page(
Query(params): Query<Pagination>,
) -> Markup {
let page = params.page.unwrap_or(1);
let per_page = params.per_page.unwrap_or(20);
base_layout("Search Results", html! {
h1 { "Search Results" }
p { "Page " (page) " (showing " (per_page) " per page)" }
nav.pagination {
@if page > 1 {
a href={ "?page=" (page - 1) "&per_page=" (per_page) } {
"← Previous"
}
}
a href={ "?page=" (page + 1) "&per_page=" (per_page) } {
"Next →"
}
}
})
}
Forms with POST Handlers
use axum::{
extract::Form,
response::Redirect,
routing::{get, post},
};
use serde::Deserialize;
#[derive(Deserialize)]
struct CreateUserForm {
name: String,
email: String,
}
async fn user_form() -> Markup {
base_layout("Create User", html! {
h1 { "Create New User" }
form method="POST" action="/users" {
div.form-group {
label for="name" { "Name" }
input type="text" name="name" id="name" required;
}
div.form-group {
label for="email" { "Email" }
input type="email" name="email" id="email" required;
}
button type="submit" { "Create User" }
}
})
}
async fn create_user(
State(db): State<Arc<Database>>,
Form(form): Form<CreateUserForm>,
) -> Result<Redirect, AppError> {
db.create_user(&form.name, &form.email).await?;
// Redirect to success page
Ok(Redirect::to("/users/success"))
}
fn user_routes(state: Arc<AppState>) -> Router {
Router::new()
.route("/users/new", get(user_form))
.route("/users", post(create_user))
.with_state(state)
}
Production Router Setup
use axum::{
middleware,
routing::get,
Router,
};
use tower::ServiceBuilder;
use tower_http::{
compression::CompressionLayer,
services::ServeDir,
trace::TraceLayer,
timeout::TimeoutLayer,
};
use std::time::Duration;
pub fn create_app(state: Arc<AppState>) -> Router {
// API routes (return Markup)
let api_routes = Router::new()
.route("/", get(home_page))
.route("/about", get(about_page))
.route("/posts", get(post_list))
.route("/posts/:id", get(post_detail))
.layer(
ServiceBuilder::new()
.layer(TraceLayer::new_for_http())
.layer(TimeoutLayer::new(Duration::from_secs(30)))
);
// Static file serving
let static_routes = Router::new()
.nest_service("/static", ServeDir::new("static"));
// Combine routes
Router::new()
.merge(api_routes)
.merge(static_routes)
.layer(CompressionLayer::new())
.with_state(state)
}
Static Assets Management
Serving CSS/JS
use tower_http::services::ServeDir;
let app = Router::new()
.route("/", get(index))
// Serve static files from ./static directory
.nest_service("/static", ServeDir::new("static"))
.with_state(state);
// In templates, reference as:
html! {
link rel="stylesheet" href="/static/styles.css";
script src="/static/app.js" {}
}
Inline Styles (for Small Apps)
fn inline_styles() -> Markup {
html! {
style {
(PreEscaped(r#"
body { font-family: sans-serif; margin: 0; padding: 20px; }
.container { max-width: 1200px; margin: 0 auto; }
.error { color: red; }
"#))
}
}
}
fn base_layout(title: &str, content: Markup) -> Markup {
html! {
(DOCTYPE)
html {
head {
title { (title) }
(inline_styles())
}
body {
(content)
}
}
}
}
Production Patterns
Health Check Endpoint
use axum::http::StatusCode;
async fn health() -> (StatusCode, Markup) {
(StatusCode::OK, html! {
(PreEscaped(r#"{"status":"ok"}"#))
})
}
// Better: Return JSON for health checks
use axum::Json;
use serde_json::json;
async fn health_json() -> Json<serde_json::Value> {
Json(json!({
"status": "ok",
"version": env!("CARGO_PKG_VERSION")
}))
}
Observability with Tracing
use tracing::instrument;
#[instrument(skip(state), fields(post_id = %id))]
async fn post_detail(
State(state): State<Arc<AppState>>,
Path(id): Path<u64>,
) -> Result<Markup, AppError> {
tracing::info!("fetching post");
let post = state
.posts
.iter()
.find(|p| p.id == id)
.ok_or(AppError::NotFound)?;
tracing::info!("post found, rendering");
Ok(base_layout(&post.title, html! {
article { /* ... */ }
}))
}
Content Security Policy
use axum::{
http::header,
response::Response,
};
async fn with_csp(markup: Markup) -> Response {
let mut response = markup.into_response();
response.headers_mut().insert(
header::CONTENT_SECURITY_POLICY,
"default-src 'self'; script-src 'self' https://unpkg.com"
.parse()
.unwrap(),
);
response
}
// Or use middleware
use axum::middleware::Next;
use axum::extract::Request;
async fn add_csp_header(request: Request, next: Next) -> Response {
let mut response = next.run(request).await;
response.headers_mut().insert(
header::CONTENT_SECURITY_POLICY,
"default-src 'self'".parse().unwrap(),
);
response
}
let app = Router::new()
.route("/", get(index))
.layer(middleware::from_fn(add_csp_header));
Common Patterns
Navigation with Active State
use axum::extract::OriginalUri;
use axum::http::Uri;
fn navbar(current_path: &str) -> Markup {
html! {
nav.navbar {
a.nav-link[current_path == "/"] href="/" { "Home" }
a.nav-link[current_path.starts_with("/posts")] href="/posts" { "Posts" }
a.nav-link[current_path == "/about"] href="/about" { "About" }
}
}
}
async fn with_navbar(OriginalUri(uri): OriginalUri) -> Markup {
let path = uri.path();
base_layout_with_nav(path, "Home", html! {
h1 { "Welcome" }
})
}
fn base_layout_with_nav(current_path: &str, title: &str, content: Markup) -> Markup {
html! {
(DOCTYPE)
html {
head { title { (title) } }
body {
(navbar(current_path))
main { (content) }
}
}
}
}
Key Integration Points
- Markup → IntoResponse: Automatic with
axumfeature - State Management: Use
Stateextractor withArc<AppState> - Error Handling: Implement
IntoResponsefor custom errors - Layouts: Function composition with
Markupparameters - Static Assets: Use
ServeDirfromtower-http
Best Practices
- Separate concerns: Keep templates in dedicated module (
templates/) - Reusable layouts: Create base layouts for consistency
- Type-safe errors: Implement
IntoResponsefor all error types - Use extractors: Leverage Axum's extractors for path, query, form data
- Add middleware: Use Tower layers for timeouts, compression, tracing
- Security headers: Add CSP, X-Frame-Options via middleware
- Instrument handlers: Use
#[instrument]for observability
Common Dependencies
[dependencies]
maud = { version = "0.27", features = ["axum"] }
axum = { version = "0.8", features = ["macros"] }
tokio = { version = "1", features = ["full"] }
tower = "0.5"
tower-http = { version = "0.6", features = ["trace", "compression", "fs"] }
serde = { version = "1", features = ["derive"] }
thiserror = "2"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
References
- Maud Docs: https://maud.lambda.xyz
- Axum Docs: https://docs.rs/axum
- HARM Stack Article: https://nguyenhuythanh.com/posts/the-harm-stack-considered-unharmful/
More by matthewharwood
View allProduction patterns for Axum 0.8.x HTTP services with Tower middleware, type-safe routing, state management with FromRef, extractors, and error handling. Use when building REST APIs, HTTP services, web applications, or adding middleware to Axum routers.
Reusable component patterns for Maud including the Render trait, function components, parameterized components, layout composition, partials, and component organization. Use when building reusable UI elements, creating component libraries, structuring templates, or implementing design systems with type-safe components.
Service architecture patterns for Axum applications including layered design (Router → Handler → Service → Repository), AppState with FromRef for dependency injection, Tower ServiceBuilder for middleware composition, and modular router organization. Use when designing service layers, managing dependencies, composing middleware stacks, or structuring Axum applications.
Production error patterns with thiserror and anyhow, including error classification, HTTP/gRPC protocol mappings, context chains, retry logic, and testing. Use when designing error types for libraries or applications, mapping errors to API responses, or implementing retry mechanisms.
