HTML Templating with Maud

Maud is a compile-time HTML templating library for Rust. Its html! macro checks your markup at compile time and expands it to efficient string-building code, so there is no runtime template parsing, no template files to deploy, and no possibility of a missing closing tag appearing in production.

The Web Server with Axum section used Html<String> with format! for responses. That works for trivial cases, but it gives you no structure, no escaping, and no compile-time checking. Maud replaces it entirely. Handlers return Markup instead of Html<String>, and the compiler catches template errors before the server starts.

Setup

Add Maud to your Cargo.toml with the axum feature:

[dependencies]
maud = { version = "0.27", features = ["axum"] }

The axum feature implements IntoResponse for Maud’s Markup type, so handlers can return markup directly. It targets axum-core 0.5, which corresponds to Axum 0.8.

The html! macro

The html! macro is the core of Maud. It takes a custom syntax that resembles HTML but follows Rust conventions, and returns a Markup value:

use maud::{html, Markup};

let greeting = "world";
let page: Markup = html! {
    h1 { "Hello, " (greeting) "!" }
};

Elements

Elements with content use curly braces. Void elements (those that cannot have children) use a semicolon:

html! {
    h1 { "Page title" }
    p {
        strong { "Bold text" }
        " followed by normal text."
    }
    br;
    input type="text" name="query";
}

Non-void elements that need no content still use braces:

html! {
    script src="/static/app.js" {}
    div.placeholder {}
}

Attributes

Attributes appear after the element name, before the braces or semicolon:

html! {
    input type="email" name="user_email" required placeholder="you@example.com";
    a href="/about" { "About" }
    article data-id="12345" { "Content" }
}

Classes and IDs have a shorthand syntax, chained directly onto the element:

html! {
    input #search-input .form-control type="text";
    div.card.shadow-sm { "Card content" }
}
// Produces:
// <input id="search-input" class="form-control" type="text">
// <div class="card shadow-sm">Card content</div>

A class or ID without an element name produces a div:

html! {
    #main { "Main content" }
    .sidebar { "Sidebar content" }
}
// Produces:
// <div id="main">Main content</div>
// <div class="sidebar">Sidebar content</div>

Quote class names that contain characters Maud’s parser would choke on:

html! {
    div."col-sm-6" { "Column" }
}

Dynamic values with splices

Parentheses insert a Rust expression into the output. Maud automatically escapes HTML special characters:

let username = "Alice <script>alert('xss')</script>";
html! {
    p { "Hello, " (username) "!" }
}
// Output: <p>Hello, Alice &lt;script&gt;alert('xss')&lt;/script&gt;!</p>

Any type implementing std::fmt::Display can be spliced. This includes strings, numbers, and any type with a Display implementation.

For dynamic attribute values, use parentheses for a single expression or braces for concatenation:

let user_id = 42;
let base = "/users";

html! {
    // Single expression
    span data-id=(user_id) { "User" }

    // Concatenation
    a href={ (base) "/" (user_id) } { "Profile" }
}

Boolean attributes and toggles

Square brackets conditionally toggle boolean attributes and classes:

let is_active = true;
let is_disabled = false;

html! {
    button disabled[is_disabled] { "Submit" }
    a.nav-link.active[is_active] href="/" { "Home" }
}
// Produces:
// <button>Submit</button>
// <a class="nav-link active" href="/">Home</a>

Optional attributes

Attributes that take an Option value render only when Some:

let tooltip: Option<&str> = Some("More info");
let label: Option<&str> = None;

html! {
    span title=[tooltip] { "Hover me" }
    span aria-label=[label] { "No aria-label rendered" }
}

Control flow

Prefix control structures with @:

let user: Option<&str> = Some("Alice");
let items = vec!["Bread", "Milk", "Eggs"];

html! {
    // if / else
    @if let Some(name) = user {
        p { "Welcome, " (name) }
    } @else {
        p { a href="/login" { "Log in" } }
    }

    // Loops
    ul {
        @for item in &items {
            li { (item) }
        }
    }

    // Let bindings
    @for (i, item) in items.iter().enumerate() {
        @let label = format!("{}. {}", i + 1, item);
        p { (label) }
    }

    // Match
    @match items.len() {
        0 => { p { "No items." } },
        1 => { p { "One item." } },
        n => { p { (n) " items." } },
    }
}

DOCTYPE

Maud provides a DOCTYPE constant:

use maud::DOCTYPE;

html! {
    (DOCTYPE)
    html lang="en" {
        head { title { "My App" } }
        body { h1 { "Hello" } }
    }
}
// Outputs: <!DOCTYPE html><html lang="en">...

Raw HTML with PreEscaped

Maud escapes all spliced content by default. When you have trusted HTML that should not be escaped, wrap it in PreEscaped:

use maud::PreEscaped;

let svg = r#"<svg viewBox="0 0 100 100"><circle cx="50" cy="50" r="40"/></svg>"#;

html! {
    div.icon { (PreEscaped(svg)) }
}

Use this for inline SVGs, pre-rendered markdown, or other HTML you control. Never pass user input to PreEscaped.

Components as functions

Maud has no built-in component system. Components are Rust functions that return Markup. This is simpler and more flexible than a template inheritance system, because you have the full language for composition, branching, and parameterisation.

A basic component:

use maud::{html, Markup};

fn nav_link(href: &str, text: &str, active: bool) -> Markup {
    html! {
        a.nav-link.active[active] href=(href) { (text) }
    }
}

Use it by calling the function inside a splice:

html! {
    nav {
        (nav_link("/", "Home", true))
        (nav_link("/about", "About", false))
        (nav_link("/contact", "Contact", false))
    }
}

Passing content blocks

The simplest approach is accepting Markup directly:

fn card(title: &str, body: Markup) -> Markup {
    html! {
        div.card {
            div.card-header { h3 { (title) } }
            div.card-body { (body) }
        }
    }
}

// Usage
let output = card("Settings", html! {
    p { "Adjust your preferences below." }
    form method="post" {
        // form fields
    }
});

A more flexible approach is accepting anything that implements Render. This lets callers pass Markup, strings, numbers, or any custom type with a Render implementation, without forcing them to wrap everything in html!:

use maud::Render;

fn card(title: &str, body: impl Render) -> Markup {
    html! {
        div.card {
            div.card-header { h3 { (title) } }
            div.card-body { (body) }
        }
    }
}

// All of these work:
card("Note", html! { p { "Rich content." } });
card("Note", "Plain text content");
card("Note", my_renderable_struct);

Prefer impl Render over Markup for component parameters. It is a small change that makes components more composable.

Components as structs with Render

When a component has several fields, or when you want it to compose via splice syntax rather than a function call, make it a struct that implements Render:

use maud::{html, Markup, Render};

enum AlertLevel {
    Info,
    Warning,
    Error,
}

struct Alert<'a, B: Render> {
    level: AlertLevel,
    title: &'a str,
    body: B,
    dismissible: bool,
}

impl<B: Render> Render for Alert<'_, B> {
    fn render(&self) -> Markup {
        let class = match self.level {
            AlertLevel::Info => "alert-info",
            AlertLevel::Warning => "alert-warning",
            AlertLevel::Error => "alert-error",
        };
        html! {
            div.alert.(class) role="alert" {
                strong { (self.title) }
                div { (self.body) }
                @if self.dismissible {
                    button.close type="button" { "×" }
                }
            }
        }
    }
}

Splice it directly, no wrapper function needed:

html! {
    (Alert {
        level: AlertLevel::Warning,
        title: "Disk space low",
        body: html! { p { "Less than 10% remaining." } },
        dismissible: true,
    })
}

Another example, a breadcrumb navigation:

struct Breadcrumb {
    segments: Vec<(String, String)>, // (label, href)
}

impl Render for Breadcrumb {
    fn render(&self) -> Markup {
        html! {
            nav aria-label="breadcrumb" {
                ol.breadcrumb {
                    @for (i, (label, href)) in self.segments.iter().enumerate() {
                        @let is_last = i == self.segments.len() - 1;
                        li.breadcrumb-item.active[is_last] {
                            @if is_last {
                                (label)
                            } @else {
                                a href=(href) { (label) }
                            }
                        }
                    }
                }
            }
        }
    }
}

Reach for Render when a component has enough fields that a function signature would get unwieldy, when it will be stored in collections and rendered in loops, or when other crates need to provide renderable types. For simple one- or two-parameter components, plain functions are shorter and sufficient.

Page layouts

A layout is a function that wraps content in a full HTML document. Since layouts in an HDA application typically need request context (the current user, flash messages, navigation state), build the layout as an Axum extractor.

First, a minimal layout function to show the shape:

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";
                title { (title) }
                link rel="stylesheet" href="/assets/style.css";
                script src="/assets/htmx.min.js" defer {}
            }
            body {
                main { (content) }
            }
        }
    }
}

In practice, layouts need data from the request: the authenticated user for navigation, flash messages from the session, the current path for active link highlighting. Extract all of this in a layout struct that implements FromRequestParts:

use axum::extract::FromRequestParts;
use axum::http::request::Parts;
use maud::{html, Markup, DOCTYPE};

struct PageLayout {
    user: Option<User>,
    current_path: String,
}

impl<S: Send + Sync> FromRequestParts<S> for PageLayout {
    type Rejection = std::convert::Infallible;

    async fn from_request_parts(
        parts: &mut Parts,
        _state: &S,
    ) -> Result<Self, Self::Rejection> {
        let user = parts.extensions.get::<User>().cloned();
        let current_path = parts.uri.path().to_string();
        Ok(PageLayout { user, current_path })
    }
}

impl PageLayout {
    fn render(self, 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";
                    title { (title) }
                    link rel="stylesheet" href="/assets/style.css";
                    script src="/assets/htmx.min.js" defer {}
                }
                body {
                    nav {
                        a.active[self.current_path == "/"] href="/" { "Home" }
                        a.active[self.current_path.starts_with("/users")]
                            href="/users" { "Users" }

                        div.nav-right {
                            @if let Some(user) = &self.user {
                                span { (user.name) }
                                a href="/logout" { "Log out" }
                            } @else {
                                a href="/login" { "Log in" }
                            }
                        }
                    }
                    main { (content) }
                    footer {
                        p { "© 2026" }
                    }
                }
            }
        }
    }
}

Handlers extract the layout alongside other parameters:

async fn user_list(
    layout: PageLayout,
    State(state): State<AppState>,
) -> Markup {
    let users = fetch_users(&state.db).await;

    layout.render("Users", html! {
        h1 { "Users" }
        ul {
            @for user in &users {
                li { (user.name) }
            }
        }
    })
}

The handler focuses on its content. The layout handles the document shell, navigation, and any request-scoped data. Add fields to PageLayout as the application grows (flash messages, CSRF tokens, feature flags) without changing handler signatures.

Full pages vs HTML fragments

In an HDA application, the same handler often needs to return a full HTML page for normal browser requests and a bare HTML fragment for htmx requests. A normal navigation loads the entire page. An htmx-boosted link or hx-get request only needs the content that will be swapped into the page.

The axum-htmx crate provides typed extractors for htmx request headers:

[dependencies]
axum-htmx = "0.6"

Use HxBoosted to detect boosted navigation (where htmx intercepts a normal link click and swaps just the body), or HxRequest to detect any htmx-initiated request:

use axum_htmx::HxBoosted;

async fn user_list(
    HxBoosted(boosted): HxBoosted,
    layout: PageLayout,
    State(state): State<AppState>,
) -> Markup {
    let users = fetch_users(&state.db).await;

    let content = html! {
        h1 { "Users" }
        ul {
            @for user in &users {
                li { (user.name) }
            }
        }
    };

    if boosted {
        content
    } else {
        layout.render("Users", content)
    }
}

For targeted fragment swaps (where htmx replaces a specific element on the page), handlers return only the fragment:

use axum_htmx::HxRequest;

async fn user_search(
    HxRequest(is_htmx): HxRequest,
    Query(params): Query<SearchParams>,
    layout: PageLayout,
    State(state): State<AppState>,
) -> Markup {
    let users = search_users(&state.db, &params.q).await;

    let results = html! {
        ul #search-results {
            @for user in &users {
                li { (user.name) }
            }
        }
    };

    if is_htmx {
        results
    } else {
        layout.render("Search", html! {
            h1 { "Search users" }
            input type="search" name="q" value=(params.q)
                hx-get="/users/search"
                hx-target="#search-results"
                hx-trigger="input changed delay:300ms";
            (results)
        })
    }
}

This pattern means every URL works as a full page when accessed directly (bookmarks, shared links, first page load) and as a fragment when accessed via htmx. No separate endpoint needed.

htmx attributes in Maud

htmx attributes use the hx- prefix, which works naturally in Maud:

html! {
    // Click to load
    button hx-get="/api/data" hx-target="#results" hx-swap="innerHTML" {
        "Load data"
    }

    // Form submission
    form hx-post="/contacts" hx-target="#contact-list" hx-swap="beforeend" {
        input type="text" name="name" required;
        button type="submit" { "Add contact" }
    }

    // Inline editing
    tr hx-get={ "/users/" (user.id) "/edit" } hx-trigger="click"
       hx-target="this" hx-swap="outerHTML" {
        td { (user.name) }
        td { (user.email) }
    }

    // Delete with confirmation
    button hx-delete={ "/users/" (user.id) }
           hx-confirm="Delete this user?"
           hx-target="closest tr"
           hx-swap="outerHTML swap:500ms" {
        "Delete"
    }
}

The Interactivity with htmx section covers htmx patterns in full.

Gotchas

Semicolons on void elements. Forgetting the semicolon on input, br, meta, link, or img causes a compile error. If the compiler complains about unexpected tokens after an element name, check for a missing semicolon.

// Wrong: Maud expects children
input type="text" { }

// Correct: semicolon terminates void elements
input type="text";

The @ prefix is mandatory for control flow. All if, for, let, and match inside html! must start with @. Without it, Maud tries to parse the keyword as an element name.

Brace vs parenthesis in attributes. Parentheses splice a single expression. Braces concatenate multiple parts. Using parentheses when you need concatenation silently drops everything after the first expression:

let id = 42;

// Wrong: only inserts base, "/users/" and id are treated as element content
a href=("/users/") (id) { "Profile" }

// Correct: braces concatenate
a href={ "/users/" (id) } { "Profile" }

Compile-time cost. Maud macros expand at compile time, which is good for runtime performance but can slow incremental builds on large templates. Breaking templates into smaller functions across modules helps, because Rust only recompiles the modules that changed.