Maud vs Tera, part 2: the same dashboard, built twice
One task dashboard, one shared domain model, two templating layers. Both compile, both render byte-identical HTML — and the differences that show up along the way are the ones that matter in a real codebase.
On this page
# The setup
Part one argued that Maud and Tera differ on one axis — whether HTML is a Rust expression or a runtime asset — and that everything else follows from it. This post tests that by building the same thing twice.
One cargo crate. Two binaries. A shared lib.rs holding the domain model, so the only variable between them is the templating layer. A task dashboard on axum 0.8: list tasks, add one, toggle done, filter by status. Fixed seed data so the two outputs are directly comparable.
Versions everything below was compiled against: axum 0.8.9, maud 0.27.0, tera 2.0.0, rustc 1.88.0. Both binaries build clean, and I diffed their rendered HTML rather than assuming it matched — more on that at the end, because the answer is “yes, but.”
[dependencies]
axum = "0.8"
maud = { version = "0.27", features = ["axum"] }
serde = { version = "1", features = ["derive"] }
tera = "2"
tokio = { version = "1", features = ["full"] }# The shared half
Both binaries import this, unchanged:
#[(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[(= "lowercase")]
pub enum Priority { Low, Normal, High }
impl Priority {
pub fn label(self) -> &'static str {
match self {
Priority::Low => "Low",
Priority::Normal => "Normal",
Priority::High => "High",
}
}
pub fn css_class(self) -> &'static str {
match self {
Priority::Low => "badge badge-low",
Priority::Normal => "badge badge-normal",
Priority::High => "badge badge-high",
}
}
}
#[(Clone, Debug, Serialize)]
pub struct Task {
pub id: u32,
pub title: String,
pub done: bool,
pub priority: Priority,
}Two methods on an enum. Utterly ordinary Rust — and the thing that ends up splitting the two implementations apart.
# Maud: components are functions
A Maud “component” is a function that returns Markup. There’s no registration step, no template directory, no name resolution. You call it.
fn task_row(task: &Task) -> Markup {
html! {
li class=@if task.done { "task task-done" } @else { "task" } {
form method="post" action={ "/tasks/" (task.id) "/toggle" } {
button type="submit" class="toggle" aria-label="Toggle task" {
@if task.done { "✓" } @else { "○" }
}
}
span class="title" { (task.title) }
span class=(task.priority.css_class()) { (task.priority.label()) }
}
}
}Three things worth pausing on.
The conditional in attribute position — class=@if task.done { … } @else { … } — is checked like any other Rust expression. So is the action={ "/tasks/" (task.id) "/toggle" } concatenation.
task.priority.css_class() is a method call from inside markup. Rename that method and the build fails at this line. That’s the entire Maud value proposition in one expression.
And the layout is just another function taking Markup:
fn layout(title: &str, body: 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="/style.css";
}
body {
main class="wrap" { (body) }
script { (maud::PreEscaped(SCRIPT)) }
}
}
}
}Template inheritance, in Maud, is function composition. There’s no extends, no block overriding, no resolution order to reason about — a page calls layout(…) and passes its body in.
Void elements end with a semicolon: meta charset="utf-8";, link rel="stylesheet" href="/style.css";, input type="text" name="title" required;. Forget it and the macro tries to parse the next element as that element’s body, and the error you get points somewhere confusing. It’s the one piece of Maud syntax that isn’t guessable.
The handler returns Markup directly, because maud’s axum feature implements IntoResponse for it:
async fn index(State(store): State<Store>, Query(q): Query<HashMap<String, String>>) -> Markup {
let filter = Filter::parse(q.get("filter").map(String::as_str));
let tasks = store.lock().unwrap();
dashboard(&tasks, filter)
}# Tera 2.0: the API changed more than you’d expect
First thing to know, because it invalidates essentially every Tera tutorial currently online:
// Tera 1.x
let tera = Tera::new("templates/**/*.html")?;
// Tera 2.0
let mut tera = Tera::new(); // no arguments
tera.load_from_glob("templates/**/*.html")?; // globbing moved hereI used add_raw_template with include_str! instead, which bakes the template into the binary and turns a malformed template into a startup failure:
fn build_tera() -> Tera {
let mut tera = Tera::new();
tera.add_raw_template(
"dashboard.html",
include_str!("../../templates/dashboard.html"),
)
.expect("template failed to parse");
tera
}That choice is worth making deliberately. include_str! gives you a self-contained binary and parse errors at boot. load_from_glob reads from disk, which is what makes full_reload() and hot reload possible. It’s a deployment decision dressed up as an API preference.
Then the view model, which is where the serialization boundary shows up as actual code:
#[(Serialize)]
struct TaskView {
id: u32,
title: String, // cloned — Context needs owned, serializable data
done: bool,
priority_label: &'static str, // Priority::label() precomputed
priority_class: &'static str, // Priority::css_class() precomputed
row_class: &'static str, // the conditional Maud did inline
}Every one of those last three fields exists because a Tera template can’t call a Rust method. The logic didn’t disappear — it moved from the template into a From<&Task> impl. Which is arguably better separation, and is also unambiguously more code.
The template itself is conventional Jinja-family markup:
<ul class="tasks">
{% if tasks %}
{% for task in tasks %}
<li class="{{ task.row_class }}">
<form method="post" action="/tasks/{{ task.id }}/toggle">
<button type="submit" class="toggle" aria-label="Toggle task">{% if task.done %}✓{% else %}○{% endif %}</button>
</form>
<span class="title">{{ task.title }}</span>
<span class="{{ task.priority_class }}">{{ task.priority_label }}</span>
</li>
{% endfor %}
{% else %}
<li class="empty">Nothing here.</li>
{% endif %}
</ul>Anyone who’s written Django or Jinja templates can edit that without knowing Rust exists. That’s the point of Tera, and it’s a real advantage the moment your team includes someone who isn’t a Rust programmer.
Beyond Tera::new(): macros are removed entirely (components replace them), escape → escape_html, as_str → str, divisibleby → divisible_by, linebreaksbr → newlines_to_br, truncate now requires its length argument, and my_vec.0 indexing is gone in favour of my_vec[0]. Built-ins with heavy dependencies moved to an opt-in tera-contrib crate.
# Escaping: verified, not assumed
I seeded the task list with a title designed to be hostile:
Task {
id: 4,
title: "Write up the <script> escaping gotcha".into(),
done: false,
priority: Priority::Low,
}Both engines rendered it as <script>. I checked the actual bytes rather than trusting the docs, and Tera 2’s default autoescape list — confirmed from the crate source — is .html, .htm and .xml, using escape_html.
So both are safe by default, and the escape hatches are the things to watch: maud::PreEscaped on the Rust side, | safe on the Tera side. Both are legitimate — I used PreEscaped myself for the inline <script> body — and both are the first thing to grep for in a security review.
The caveat that applies equally to both: neither escapes contextually. A value going into HTML text, into an unquoted attribute, into a URL, or into a <script> block gets the same treatment. OWASP’s position is that correct escaping depends on the context you’re landing in, and no Rust template engine in common use does that analysis. Interpolating user data into a script body is unsafe in Maud and unsafe in Tera, and the compile-time checking doesn’t save you — it checks that your types line up, not that your context is safe.
# Does the output actually match?
I ran both servers and diffed:
maud 1919 bytes
tera 1990 bytes
raw identical? NO
normalized identical? YESStructurally identical, byte-wise not. Maud emits everything on one line, because the macro only outputs what you told it to — indentation in your Rust source isn’t part of the markup. Tera emits the whitespace that’s in the template file, since to a text template a newline is content.
71 bytes, about 3.7% on this page, and gzip closes most of the gap. But it’s a genuine difference with a genuine trade-off: getting Tera to match would mean minifying the template by hand or threading {%- / -%} whitespace-control tags throughout, and the template stops being pleasant to read. Maud gets minified output for free precisely because it never had a text file to preserve whitespace from.
maud_app.rs is about 150 lines, self-contained. tera_app.rs is about 145 lines — plus a 42-line template file and two view-model structs. Tera’s total surface is larger and split across two languages and two files. Whether that’s a cost or a feature depends entirely on whether someone who doesn’t write Rust needs to open the second file.
# Practical notes from building both
Maud. Compose with functions and take Markup parameters — it’s the whole composition story and it’s better than block inheritance for component-shaped UI. Put PreEscaped behind a named constant rather than inline, so grep PreEscaped gives a reviewable list. Return Markup from handlers directly; the axum feature handles the response conversion, and 0.27 removed an allocation on that path. Remember the semicolons on void elements.
Tera. Decide include_str! versus load_from_glob on deployment grounds, not taste — one gives you a sealed binary and boot-time errors, the other gives you hot reload. Build view models with From impls rather than assembling Context inline in handlers; it keeps the precompute logic testable and out of the request path. Register every filter and function before adding templates, since 2.0 validates registration at add-template time and will now tell you off for getting the order wrong. And if you’re coming from 1.x, read MIGRATION.md before estimating — the macro removal alone is not a mechanical change.
# Putting it together
Building both back to back, the difference that mattered day to day wasn’t syntax and wasn’t speed. It was that in Maud I never left Rust, and in Tera I was continuously moving data across a boundary so a text file could read it.
For a component-shaped UI owned entirely by engineers, Maud removed work — no view models, no precomputation, methods callable from markup, the compiler catching my typos. For anything where a template needs to be editable by someone who doesn’t write Rust, that same tight coupling is exactly wrong, and Tera’s boundary is the feature you’re paying for.
The output is the same either way. What differs is who can change it, and when you find out you broke it.
Next: part three has the benchmark numbers, the security comparison, and a decision framework.
# References
- Maud documentation — maud.lambda.xyz
- lambda-fairy/maud — GitHub
- Tera v1 → v2 migration guide — GitHub
- Tera documentation — keats.github.io
- axum 0.8 — docs.rs
- OWASP XSS Prevention Cheat Sheet — OWASP
- Building a fast website with the MASH stack in Rust — Evan Schwartz
Click to show appreciation
Discussion