Maud (Rust HTML Macro)
Captured 2026-07-10 (maud 0.27.0). To update: re-verify against maud.lambda.xyz + docs.rs/maud, then diff for changes.
Maud is a compile-time HTML macro (html!); markup lives inline in Rust, type-checked at compile time. This skill targets maud 0.27.x (0.27.0, released 2025-02-02).
Maud runs on stable Rust and nightly (better error messages on nightly). Stable-Rust-capable since 0.22.1 (2020-11-02); earlier it needed nightly-only compiler features. MSRV is not documented.
1. Setup
Cargo.toml
[dependencies]
maud = "0.27"
2. Rendering API
html! { ... } returns a Markup value. Markup is a type alias for PreEscaped<String>, a wrapper around an already-escaped HTML string.
| Operation | Result | Notes |
|---|---|---|
html! { ... } | Markup | The macro expression's value. |
markup.into_string() | String | Consumes the Markup, hands back the built string. |
(DOCTYPE) inside html! | emits <!DOCTYPE html> | maud::DOCTYPE is a constant equal to the literal string <!DOCTYPE html>. |
use maud::{html, DOCTYPE};
let page = html! {
(DOCTYPE)
html {
head { title { "My site" } }
body { p { "Hello" } }
}
};
Rendering a Display value: maud::display(x) is a free function that renders any value through its Display impl inside a template. It replaced the old blanket Render for Display impl removed in 0.24.0.
use maud::{html, display};
let n = 42;
html! { p { (display(n)) } };
Note: Markup/PreEscaped has no Display impl (checked against docs.rs 0.27; it implements Render, not Display). Convert to String with .into_string() when you need the raw string. There is no html_to! / write-into-buffer macro (html_debug! was removed in 0.25.0). To write into an existing buffer, use the Render trait's render_to (see §9).
3. Web Framework Integration
Turn on the matching feature flag from the table below and Markup implements the corresponding trait. A handler can then just return Markup directly.
| Framework | Feature flag | Trait Markup implements | Dep pins |
|---|---|---|---|
| default | — | — | nothing enabled |
| Actix-web | actix-web | actix_web::Responder | actix-web-dep + futures-util |
| Axum | axum | IntoResponse | axum-core ^0.5 + http ^1 |
| Rocket | rocket | Rocket's Responder | rocket ^0.5 |
| Warp | warp | warp::Reply | warp ^0.3.6 |
| Poem | poem | poem::IntoResponse | poem ^3 |
| Tide | tide | From<PreEscaped<String>> for tide::Response | tide ^0.16.0 |
| Submillisecond | submillisecond (new in 0.27.0) | IntoResponse | submillisecond ^0.4.1 |
| Rouille | manual, see below | none | — |
Dependency version pins came from the docs.rs feature graph, single source. Verify against Cargo.toml if exact ranges matter.
maud = { version = "0.27", features = ["axum"] }
Handler Example
Rocket:
#[get("/<name>")]
fn hello(name: &str) -> Markup {
html! { h1 { "Hello, " (name) "!" } }
}
Actix-web and Tide handlers wrap the return in a Result: Actix async fn index() -> AwResult<Markup> { Ok(html! { ... }) }; Tide returns Ok(html! { ... }) from a closure inferred as tide::Result<impl Into<Response>> (its feature adds From<PreEscaped<String>>, not IntoResponse, so a bare -> Markup handler won't compile). Axum, Poem, Submillisecond return Markup directly from a sync or async handler. The trait impl converts it to the response type each one expects.
Manual Path (No Feature Flag)
Call .into_string() and wrap the string yourself.
// Rouille has no trait impl:
Response::html(html! { h1 { "Hello, " (name) "!" } })
4. Elements and Nesting
Elements with content use braces; text and nested elements go inside.
Void elements end with ; and take no body. Maud emits HTML5 syntax (<br>), not XHTML (<br />).
html! {
link rel="stylesheet" href="poetry.css";
p {
"Rock, you are a rock."
br;
"Gray, you are gray,"
}
}
Elements and attributes with hyphens are supported (custom elements, data-*, ARIA).
"Type-checked at compile time" means Rust splice-expression types only. Maud does not validate element or attribute names against the HTML5 spec. It does not check content-model nesting either (a <div> inside a <p> compiles fine). Any valid Rust ident compiles as a tag, typos included.
Text
Plain double-quoted Rust string literals are bare expressions inside html!. Raw string literals handle long or special-character text.
html! {
pre {
r#"
Rocks, these are my rocks.
Smooth and round,
Asleep in the ground.
"#
}
}
5. Attributes
Value attribute: name="value".
html! {
a href="about:blank" { "Apple Bloom" }
li class="lower-middle" { "Sweetie Belle" }
}
Boolean / empty attribute: bare name, no =.
html! {
input type="checkbox" name="cupcakes" checked;
label for="cupcakes" { "Do you like cupcakes?" }
}
#id / .class Shorthand
html! {
input #cannon .big.scary.bright-red type="button" value="Launch";
div."col-sm-2" { "Bootstrap column!" }
}
- Multiple classes chain:
.big.scary.bright-red. - Quoted names allow characters not valid as bare idents:
."col-sm-2". - Rust 2021 whitespace gotcha:
#must be preceded by a space (input #pinkie;). Older editions allowedinput#pinkie;.
Implicit div
Omitting the tag name when a #id / .class shorthand is present defaults the element to div.
html! {
#main {
"Main content!"
.tip { "Storing food in a refrigerator can make it 20% cooler." }
}
}
Toggles [condition]
A bracketed bool expression toggles a boolean attribute or a class on or off.
let allow_editing = true;
html! {
p contenteditable[allow_editing] { "Edit me." }
}
let cuteness = 95;
html! {
p.cute[cuteness > 50] { "Squee!" }
}
Optional Attribute Values attr=[Option<T>]
With =, a bracketed Option sets the attribute's value only when Some. None omits the attribute entirely. This differs from bare [cond], which toggles presence.
html! {
p title=[Some("Good password")] { "Correct horse" }
@let value = Some(42);
input value=[value];
@let title: Option<&str> = None;
p title=[title] { "Battery staple" } // title omitted
}
Dynamic Names
Splice a computed id with #(...). Build a class from a literal plus splice inside .{ }.
let name = "rarity";
let severity = "critical";
html! {
aside #(name) {
p.{ "color-" (severity) } { "This is the worst! Possible! Thing!" }
}
}
6. Splices and Interpolation
(expr) inserts a runtime value into markup. HTML special characters in the value are escaped by default.
let best_pony = "Pinkie Pie";
let numbers = [1, 2, 3, 4];
html! {
p { "Hi, " (best_pony) "!" }
p {
"I have " (numbers.len()) " numbers, "
"and the first one is " (numbers[0])
}
}
Splice any type that has a maud::Render impl. Most primitives (str, i32, ...) already do.
Block-expression splice for arbitrary Rust logic:
html! {
p {
({
let f: Foo = something_convertible_to_foo()?;
f.time().format("%H%Mh")
})
}
}
Splices in Attributes
Single value with =:
let secret_message = "Surprise!";
html! {
p title=(secret_message) { "Nothing to see here." }
}
Concatenating literal plus splice needs { } wrapping:
const GITHUB: &'static str = "https://github.com";
html! {
a href={ (GITHUB) "/lambda-fairy/maud" } { "Fork me on GitHub" }
}
7. Control Structures
Every control keyword takes an @ prefix. Bare if / for / match / let are not recognized as control flow inside html!. Braces are mandatory on every branch.
@if / @else if / @else
#[derive(PartialEq)]
enum Princess { Celestia, Luna, Cadance, TwilightSparkle }
let user = Princess::Celestia;
html! {
@if user == Princess::Luna {
h1 { "Super secret woona to-do list" }
ul { li { "Evil laugh" } }
} @else if user == Princess::Celestia {
p { "Sister, please stop reading my diary." }
} @else {
p { "Nothing to see here; move along." }
}
}
@if let works (the condition is a plain Rust expression, so let PAT = EXPR parses natively):
let user = Some("Pinkie Pie");
html! {
p {
"Hello, "
@if let Some(name) = user { (name) } @else { "stranger" }
"!"
}
}
@for
let names = ["Applejack", "Rarity", "Fluttershy"];
html! {
ol {
@for name in &names {
li { (name) }
}
}
}
@while
Present in the parser AST (WhileExpr), absent from the published book. No documented example exists. Structure matches @if, so @while cond { ... } and by analogy @while let PAT = EXPR { ... } are expected to work. Treat @while let as unverified until you compile it.
@match
html! {
@match user {
Princess::Luna => h1 { "Woona's here" },
Princess::Celestia | Princess::Cadance => p { "A princess." },
_ => p { "Nothing to see here; move along." }
}
}
Reuses the Princess enum and user binding from @if above. Match arms accept full Rust patterns per the parser source: or-patterns (A | B, shown above), bindings, ranges, destructuring, guards (pat if cond =>). The book shows only basic arms, so verify guard / or-pattern arms by compiling.
@let
Declares a variable inside html!, most useful inside loops. Accepts any Rust let pattern including type ascription.
let names = ["Applejack", "Rarity", "Fluttershy"];
html! {
@for name in &names {
@let first_letter = name.chars().next().unwrap();
p {
"The first letter of " b { (name) } " is " b { (first_letter) } "."
}
}
}
8. Escaping and Raw HTML
Splices and text are escaped by default. Maud escapes exactly four characters: &, <, >, ". Single quotes pass through unescaped.
Bypass escaping with maud::PreEscaped, which renders its inner value verbatim.
use maud::PreEscaped;
html! {
"<script>alert(\"XSS\")</script>" // <script>...
(PreEscaped("<script>alert(\"XSS\")</script>")) // <script>...
}
Only wrap content you have already sanitized. PreEscaped on untrusted input is an XSS hole. The AsRef<str> bound restriction on PreEscaped was removed in 0.26.0, so it wraps a wider range of inner types now.
9. Custom Rendering and Composition
The Render Trait
Write a maud::Render impl to teach maud how to splice your own type. The trait has two methods, both with default impls that call each other:
pub trait Render {
fn render(&self) -> Markup {
let mut buffer = String::new();
self.render_to(&mut buffer);
PreEscaped(buffer)
}
fn render_to(&self, buffer: &mut String) {
buffer.push_str(&self.render().into_string());
}
}
Override at least one. Overriding neither causes infinite recursion (each default calls the other). Override render for the simple case. Override render_to when you want to write straight into the output buffer, for example to append multiple pieces without an intermediate Markup.
A render_to override is responsible for its own escaping. Raw writes into buffer are not escaped for you. Need that inside one? Reach for maud::Escaper, it escapes as it writes.
render/render_to are sync-only. There is no async Render trait or streaming variant. Resolve async data (a DB or HTTP call) to a value before entering html! and splice the resolved value.
Components Are Just Functions
A partial or component is a plain function returning Markup. Compose by splicing the call.
use maud::{html, Markup, DOCTYPE};
fn header(title: &str) -> Markup {
html! {
head { title { (title) } }
}
}
fn page(title: &str, body: Markup) -> Markup {
html! {
(DOCTYPE)
html {
(header(title))
body { (body) }
}
}
}
// usage
let markup = page("Home", html! { h1 { "Hello" } });
Splicing a Markup value never re-escapes it, so nesting components is safe. Maud does not currently export a RenderOnce trait (it had one from 0.8.0 through 0.15.0, removed 2017-01-26); don't confuse the name with horrorshow's still-existing RenderOnce trait.
10. Gotchas and Footguns
Each is flagged inline at its section; §11 consolidates. Index:
- Void
;not self-close; control flow needs@(every branch braced); §4, §7 [cond](toggle) vsattr=[Option<T>](value only whenSome); attr concat needs{ }; §5, §6- Splices escape by default;
PreEscapedis an XSS hole on untrusted input; only&<>"escaped (single quote not); §6, §8 - Rust 2021
#spacing (input #pinkie;); §5 Renderwith neither method overridden recurses forever; arender_tooverride does its own escaping (maud::Escaper); §9render/render_toare sync-only; no asyncRendertrait, no streaming variant; resolve async values beforehtml!(§9)Markuphas noDisplayimpl (.into_string()/maud::display(x)); no template files, nohtml_to!; §2- "Type-checked" covers Rust splice types only; no HTML5 tag/attribute validation, no content-model nesting checks (§4)
11. Quick Reference Card
Value: html! { ... } -> Markup Get String: .into_string()
Doctype: (DOCTYPE) -> <!DOCTYPE html>
Element: p { "text" nested { } }
Void element: br; link rel="x" href="y"; (trailing ; , renders <br>)
Text: "literal" r#"raw literal"#
Attr: name="value"
Bool attr: checked (bare name, no =)
Shorthand: input #id .class1.class2; .class -> implies <div> if no tag
Toggle: p contenteditable[cond] { } p.cute[n > 50] { }
Optional attr: input value=[Some(42)]; title=[opt] (None omits attr)
Splice: (expr) escapes by default
Attr splice: title=(x) href={ (a) "/" (b) }
Dyn name: #(id_expr) .{ "color-" (sev) }
Block splice: ({ let x = f()?; x })
If: @if c { } @else if d { } @else { }
If let: @if let Some(x) = opt { (x) } @else { }
For: @for x in &xs { li { (x) } }
Match: @match v { A => { }, _ => p { } } (guards / or-pats via Rust patterns)
While: @while cond { } (@while let unverified)
Let: @let x = expr; @let y: Option<&str> = None;
Raw HTML: (PreEscaped(safe_html)) disables escaping
Display value: (display(x))
Component: fn c(...) -> Markup { html! { } } splice with (c(...))
Render trait: fn render(&self) -> Markup fn render_to(&self, buf: &mut String) (both have default impls, both sync-only)
Escapes: & < > " (single quote NOT escaped)
Book: https://maud.lambda.xyz API docs: https://docs.rs/maud