Server-side Templates
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. The reference material consulted while preparing these pages is listed in the section bibliography. |
This page is written against askama 0.16.1, askama_web 0.16.0, minijinja 2.24.0 and maud 0.27.0 with axum 0.8.9 on Rust 1.99 (edition 2024), checked on 2026-10-06. Bookshelf's catalogue is server-rendered HTML, progressively enhanced with htmx (next page). HTML and CSS themselves are covered in this site’s HTML & CSS section, and accessible markup on Accessibility.
Where it runs in the request
In the request lifecycle (tokio accepts the connection, hyper parses the request, tower layers wrap the router, the router matches a route, middleware and extractors run, the handler runs, the response travels back out), template rendering is the last step inside the handler phase: it turns the handler’s data into the response body.
-
With
Html(template.render()?)the template renders in the handler, so a rendering error is an ordinaryErrthe handler (or itsAppError) converts into a response. -
With askama_web’s
WebTemplateor maud’sMarkupthe handler returns the template value, and rendering happens in itsIntoResponseimplementation — after the handler has returned, before the response passes back out through the tower layers. Those layers (compression,TraceLayer, security headers) therefore see a finished HTML body withcontent-type: text/html; charset=utf-8. -
Extractors have run by then: a template never sees a request that failed validation, and HTML error pages for rejections must be produced by the rejection’s or the error type’s
IntoResponse, as shown below.
askama: templates checked at compile time
askama compiles Jinja-like templates into Rust code when the crate builds. A typo in a variable name or a type error is
a compile error, rendering needs no parsing at runtime, and HTML escaping is on by default for .html templates.
Templates live in a templates/ directory next to Cargo.toml:
[dependencies]
askama = "0.16.1"
askama_web = { version = "0.16.0", features = ["axum-0.8"] }
Source: askama book: Getting started and askama_web 0.16.0
{# templates/base.html #}
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>{% block title %}Bookshelf{% endblock %}</title>
<link rel="stylesheet" href="/static/app.css">
</head>
<body>
<header>{% include "partials/nav.html" %}</header>
<main>{% block content %}{% endblock %}</main>
</body>
</html>
{# templates/partials/nav.html #}
<nav><a href="/">Catalogue</a>{% if let Some(name) = user %} | Signed in as {{ name }}{% endif %}</nav>
{# templates/book.html #}
{% extends "base.html" %}
{% block title %}{{ book.title }} - Bookshelf{% endblock %}
{% block content %}
<h1>{{ book.title|title }}</h1>
<p>by {{ book.author }}, {{ book.year }}</p>
<ul>
{% for review in reviews %}
<li>{{ review.rating }}/5: {{ review.text|truncate(80) }}</li>
{% else %}
<li>No reviews yet.</li>
{% endfor %}
</ul>
{% endblock %}
Source: askama book: Template syntax
use askama::Template;
use askama_web::WebTemplate;
use axum::{
Router,
extract::Path,
http::StatusCode,
response::{Html, IntoResponse},
routing::get,
};
struct Book {
title: String,
author: String,
year: u16,
}
struct Review {
rating: u8,
text: String,
}
/// `WebTemplate` (askama_web) implements axum's IntoResponse: render, set content-type, 500 on error.
#[derive(Template, WebTemplate)]
#[template(path = "book.html")]
struct BookPage {
user: Option<String>,
book: Book,
reviews: Vec<Review>,
}
async fn show_book(Path(isbn): Path<String>) -> Result<BookPage, StatusCode> {
if isbn != "9780441013593" {
return Err(StatusCode::NOT_FOUND);
}
Ok(BookPage {
user: Some("Ursula".into()),
book: Book { title: "dune".into(), author: "Frank Herbert".into(), year: 1965 },
reviews: vec![Review { rating: 5, text: "<script>alert('xss')</script> A classic.".into() }],
})
}
/// Without askama_web: render explicitly and wrap in Html.
async fn show_book_manual() -> Result<impl IntoResponse, StatusCode> {
let page = BookPage {
user: None,
book: Book { title: "dune messiah".into(), author: "Frank Herbert".into(), year: 1969 },
reviews: vec![],
};
let html = page.render().map_err(|err| {
tracing::error!(%err, "template rendering failed");
StatusCode::INTERNAL_SERVER_ERROR
})?;
Ok(Html(html))
}
#[tokio::main]
async fn main() {
let app = Router::new().route("/books/{isbn}", get(show_book)).route("/books-manual", get(show_book_manual));
let listener = tokio::net::TcpListener::bind("127.0.0.1:3000").await.unwrap();
axum::serve(listener, app).await.unwrap();
}
Source: askama book, askama_web and axum example: templates
$ curl -s localhost:3000/books/9780441013593 | grep -E '<h1>|<li>|Signed'
<header><nav><a href="/">Catalogue</a> | Signed in as Ursula</nav></header>
<h1>Dune</h1>
<li>5/5: <script>alert('xss')</script> A classic.</li>
-
Inheritance:
{% extends "base.html" %}with{% block %}overrides; includes with{% include %}; macros with{% macro %}for repeated fragments such as form fields. -
Filters: built-ins such as
title,truncate,upper,join,json,urlencode, plus your own as Rust functions in afiltersmodule. Template expressions are Rust:{% if let Some(name) = user %}and method calls work. -
Escaping: every
{{ }}in an.htmltemplate is HTML-escaped, which is why the injected<script>above renders as text.|safedisables escaping for a value — use it only for HTML you produced and sanitised yourself. -
Errors: a missing field or a type mismatch is reported by
cargo buildwith the template line;render()itself can only fail at runtime for things like a failingDisplayimpl, whichWebTemplateturns into a500.
Escaping: what autoescape does and does not cover
askama escapes {{ }} output when the template’s extension is html, htm, xml, svg or a Jinja-style one
(j2, jinja, jinja2, askama, rinja); md, txt, yml and none are not escaped, and the escapers table
in askama.toml can change the mapping. A template given inline with source = "..." needs ext = "html" to be
escaped, and escape = "none" turns escaping off for one template. minijinja decides by the template name: .html,
.htm and .xml escape, after stripping a trailing .j2, .jinja or .jinja2. maud escapes every (expression)
unless it is wrapped in PreEscaped.
Autoescaping encodes <, >, &, " and ' — correct for element content and quoted attribute values, and
nothing else:
-
Unquoted attributes (
<input value={{ q }}>) can be broken out of with a space. Always quote attribute values. -
URLs: escaping does not stop
javascript:URLs inhref/src. Validate the scheme of user-supplied links, and useurlencodefor values placed inside a path or query string, as/books/{{ book.isbn|urlencode }}does below. -
Inside
<script>andon*attributes HTML escaping is the wrong encoding. Pass data to JavaScript through adata-attribute or a<script type="application/json">block filled with askama’sjsonfilter (featureserde_json) or minijinja’stojson(featurejson, not on by default), which escape for that context. -
|safe(askama, minijinja) andPreEscaped(maud) disable escaping. Use them only for HTML you generated or sanitised yourself (for example Markdown rendered and then cleaned with an allow-list sanitiser), never for raw input.
Pair escaping with a Content-Security-Policy that forbids inline scripts (see CSRF & Security Hardening) so a missed escape is not immediately exploitable.
Layouts, macros, filters and htmx partials
A real catalogue page combines the pieces: it extends the layout, defines a macro for one row, uses a built-in
filter (pluralize), a custom filter (stars) and urlencode, and wraps the result list in a named block. askama
can render one block of a template on its own (#[template(path = "...", block = "results")]), so the same file
serves the full page and the fragment htmx swaps in — no duplicated markup, and both are checked at compile time:
{# templates/catalogue.html #}
{% extends "base.html" %}
{% macro book_row(book) %}
<li><a href="/books/{{ book.isbn|urlencode }}">{{ book.title }}</a> ({{ book.year }}) {{ book.rating|stars }}</li>
{% endmacro %}
{% block title %}Catalogue - Bookshelf{% endblock %}
{% block content %}
<h1>Catalogue</h1>
<input type="search" name="q" value="{{ q }}" hx-get="/catalogue" hx-target="#results" hx-trigger="input changed delay:300ms">
<div id="results">
{% block results %}
<p>{{ books.len() }} book{{ books.len()|pluralize }} matching "{{ q }}"</p>
<ul>
{% for book in books %}{% call book_row(book) %}{% endcall %}{% endfor %}
</ul>
{% endblock %}
</div>
{% endblock %}
{# templates/error.html -- used in the next section #}
{% extends "base.html" %}
{% block title %}{{ status.as_u16() }} - Bookshelf{% endblock %}
{% block content %}
<h1>{{ status.canonical_reason().unwrap_or("Error") }}</h1>
<p>{{ message }}</p>
<p><a href="/">Back to the catalogue</a></p>
{% endblock %}
Source: askama book: Template syntax and htmx: hx-get
use askama::Template;
use askama_web::WebTemplate;
use axum::{
Router,
extract::Query,
http::HeaderMap,
response::{IntoResponse, Response},
routing::get,
};
use serde::Deserialize;
struct Book {
isbn: String,
title: String,
year: u16,
rating: u8,
}
mod filters {
/// `{{ rating|stars }}` -- five-star rating; a custom filter is a function in a `filters` module.
#[askama::filter_fn]
pub fn stars(rating: &u8, _: &dyn askama::Values) -> askama::Result<String> {
let full = usize::from((*rating).min(5));
Ok(format!("{}{}", "★".repeat(full), "☆".repeat(5 - full)))
}
}
/// The whole page: layout, search box and results.
#[derive(Template, WebTemplate)]
#[template(path = "catalogue.html")]
struct CataloguePage {
user: Option<String>,
q: String,
books: Vec<Book>,
}
/// Only the `results` block of the same file -- the fragment htmx swaps in.
#[derive(Template, WebTemplate)]
#[template(path = "catalogue.html", block = "results")]
struct CatalogueResults {
q: String,
books: Vec<Book>,
}
#[derive(Deserialize)]
struct Search {
#[serde(default)]
q: String,
}
fn search(q: &str) -> Vec<Book> {
let all = [("9780441013593", "Dune", 1965, 5), ("9780441478125", "The Left Hand of Darkness", 1969, 4)];
all.into_iter()
.filter(|(_, title, _, _)| title.to_lowercase().contains(&q.to_lowercase()))
.map(|(isbn, title, year, rating)| Book { isbn: isbn.into(), title: title.into(), year, rating })
.collect()
}
async fn catalogue(headers: HeaderMap, Query(Search { q }): Query<Search>) -> Response {
let books = search(&q);
if headers.contains_key("hx-request") {
CatalogueResults { q, books }.into_response() // htmx request: just the fragment
} else {
CataloguePage { user: None, q, books }.into_response()
}
}
#[tokio::main]
async fn main() {
let app = Router::new().route("/catalogue", get(catalogue));
let listener = tokio::net::TcpListener::bind("127.0.0.1:3000").await.unwrap();
axum::serve(listener, app).await.unwrap();
}
$ curl -s 'localhost:3000/catalogue?q=dune' | grep -E '<title>|<li>|matching'
<title>Catalogue - Bookshelf</title>
<p>1 book matching "dune"</p>
<li><a href="/books/9780441013593">Dune</a> (1965) ★★★★★</li>
$ curl -s -H 'hx-request: true' 'localhost:3000/catalogue?q=dark' # the fragment only
<p>1 book matching "dark"</p>
<ul>
<li><a href="/books/9780441478125">The Left Hand of Darkness</a> (1969) ★★★★☆</li>
</ul>
$ curl -s -H 'hx-request: true' --get --data-urlencode 'q=<b>"x' localhost:3000/catalogue | grep matching
<p>0 books matching "<b>"x"</p>
Source: htmx reference: request headers
-
A custom filter is a function in a module named
filtersnext to the template struct, annotated with#[askama::filter_fn]; its first argument is the filtered value, the second the template’s runtimeValues, and further arguments are the filter’s own parameters. -
In askama 0.16 a macro is invoked with a
{% call name(args) %}{% endcall %}block; a lone{% call %}without{% endcall %}is a compile error. -
The fragment struct only needs the fields its block uses (
qandbooks, notuser). -
The blank lines come from the tags themselves;
{%-and-%}trim whitespace when it matters (it rarely does in HTML). -
The full htmx side —
HX-Request,Vary: HX-Requestfor caches, response headers such asHX-Trigger, and out-of-band swaps — is on the htmx page.
HTML error pages
An HTML application should answer 404 and 500 with a page in the site’s layout, not with axum’s empty body or a
JSON problem document. The error type renders a template in its IntoResponse implementation, and Router::fallback
uses the same type for unmatched paths. The tuple (status, Html(html)) sets the status code, because WebTemplate
always answers 200 OK:
use askama::Template;
use axum::{
Router,
extract::Path,
http::{StatusCode, Uri},
response::{Html, IntoResponse, Response},
routing::get,
};
#[derive(Template)]
#[template(path = "error.html")]
struct ErrorPage<'a> {
user: Option<String>,
status: StatusCode,
message: &'a str,
}
/// The HTML application's error type: every variant renders the same error template.
enum PageError {
NotFound,
Internal(String),
}
impl IntoResponse for PageError {
fn into_response(self) -> Response {
let (status, message) = match &self {
PageError::NotFound => (StatusCode::NOT_FOUND, "We could not find that page."),
PageError::Internal(detail) => {
tracing::error!(%detail, "request failed");
(StatusCode::INTERNAL_SERVER_ERROR, "Something went wrong on our side.")
}
};
match (ErrorPage { user: None, status, message }).render() {
Ok(html) => (status, Html(html)).into_response(),
// never fail while reporting a failure: fall back to plain text
Err(_) => (status, message.to_owned()).into_response(),
}
}
}
async fn show_book(Path(isbn): Path<String>) -> Result<Html<String>, PageError> {
match isbn.as_str() {
"9780441013593" => Ok(Html("<h1>Dune</h1>".into())),
"boom" => Err(PageError::Internal("database unavailable".into())),
_ => Err(PageError::NotFound),
}
}
/// Unmatched paths get the same page instead of axum's empty 404.
async fn not_found(uri: Uri) -> PageError {
tracing::debug!(%uri, "no route");
PageError::NotFound
}
#[tokio::main]
async fn main() {
let app = Router::new().route("/books/{isbn}", get(show_book)).fallback(not_found);
let listener = tokio::net::TcpListener::bind("127.0.0.1:3000").await.unwrap();
axum::serve(listener, app).await.unwrap();
}
$ curl -si localhost:3000/books/123 | grep -E 'HTTP|<title>|<h1>'
HTTP/1.1 404 Not Found
<title>404 - Bookshelf</title>
<h1>Not Found</h1>
$ curl -si localhost:3000/books/boom | grep -E 'HTTP|<h1>'
HTTP/1.1 500 Internal Server Error
<h1>Internal Server Error</h1>
$ curl -s -o /dev/null -w '%{http_code}\n' localhost:3000/nope
404
Source: axum: Router::fallback
-
Never put the internal error detail in the page; log it (with the request ID) and show a generic message.
-
If rendering the error page itself fails, fall back to plain text rather than recursing into the error handler.
-
An API under
/apishould keep returning Problem Details JSON (see Error Handling); pick the representation per router, or by theAcceptheader as in Forms & Content Negotiation. -
Extractor rejections (a malformed form, an unparsable path) produce axum’s plain-text responses unless you wrap the extractor (
WithRejection, or a custom extractor) and convert the rejection into the same error type.
Where askama_axum went
askama 0.12 shipped integration crates such as askama_axum. askama 0.13 removed them; the replacement is either the
explicit Html(template.render()?) (no extra dependency) or askama_web with the axum-0.8 feature, whose
#[derive(WebTemplate)] restores impl IntoResponse. Yoon (Beginning Axum) builds a React front end and does not
use askama; online tutorials that use askama_axum::Template need this change.
minijinja: runtime templates
minijinja is a Jinja2 implementation from the author of Jinja itself. Templates are loaded and parsed at runtime, so designers can edit them without recompiling, and a development server can reload them on every request. The price is that errors surface at runtime and there is no type checking:
use std::sync::Arc;
use axum::{
Router,
extract::State,
http::StatusCode,
response::Html,
routing::get,
};
use minijinja::{Environment, context};
#[derive(Clone)]
struct AppState {
templates: Arc<Environment<'static>>,
}
async fn catalogue(State(state): State<AppState>) -> Result<Html<String>, StatusCode> {
let template = state.templates.get_template("catalogue.html").map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
let html = template
.render(context! { books => vec!["Dune", "The Left Hand of Darkness"], user => "Ursula" })
.map_err(|err| {
tracing::error!(%err, "render failed");
StatusCode::INTERNAL_SERVER_ERROR
})?;
Ok(Html(html))
}
#[tokio::main]
async fn main() {
let mut env = Environment::new();
// fail on undefined variables instead of silently rendering an empty string
env.set_undefined_behavior(minijinja::UndefinedBehavior::Strict);
// In production use env.set_loader(minijinja::path_loader("templates")) (feature "loader"),
// or embed templates with include_str! as here.
env.add_template(
"catalogue.html",
"<h1>Catalogue</h1><p>Hello {{ user }}</p><ul>{% for b in books %}<li>{{ b }}</li>{% endfor %}</ul>",
)
.expect("valid template");
let app = Router::new().route("/", get(catalogue)).with_state(AppState { templates: Arc::new(env) });
let listener = tokio::net::TcpListener::bind("127.0.0.1:3000").await.unwrap();
axum::serve(listener, app).await.unwrap();
}
Source: minijinja 2.24 and axum example: templates-minijinja
Auto-escaping is decided by the template name’s extension (.html escapes). For live reloading during development,
the minijinja-autoreload crate re-reads changed files; keep it behind a configuration flag.
-
Build the
Environmentonce at start-up and share it in state; creating one per request re-parses every template. -
The default
UndefinedBehavior::Lenientprints a misspelt variable as an empty string.Strict(as above) turns that into a render error, which is the closest minijinja gets to askama’s compile-time check;SemiStrictstill allows{% if maybe_missing %}. -
minijinja can also render a single block for htmx:
template.eval_to_state(ctx)?.render_block("results"). -
add_functionandadd_filterregister Rust closures callable from templates — the hook for URL builders, formatting helpers and translations.
maud: HTML as a Rust macro
maud’s html! macro is not a template language at all: markup is written in a compact Rust-like syntax inside your
code, checked by the compiler, and escaped by default. Its Markup type implements axum’s IntoResponse with the
axum feature:
use axum::{Router, routing::get};
use maud::{DOCTYPE, Markup, html};
fn layout(title: &str, body: Markup) -> Markup {
html! {
(DOCTYPE)
html lang="en" {
head { meta charset="utf-8"; title { (title) } }
body { main { (body) } }
}
}
}
async fn catalogue() -> Markup {
let books = [("Dune", 1965), ("Kindred", 1979)];
layout(
"Catalogue",
html! {
h1 { "Catalogue" }
ul {
@for (title, year) in books {
li.book { (title) " (" (year) ")" }
}
}
},
)
}
#[tokio::main]
async fn main() {
let app = Router::new().route("/", get(catalogue));
let listener = tokio::net::TcpListener::bind("127.0.0.1:3000").await.unwrap();
axum::serve(listener, app).await.unwrap();
}
Internationalisation
None of the three engines translates text by itself; they render whatever strings you give them. The usual setup:
-
Negotiate the locale per request — from a user setting, a cookie, or the
Accept-Languageheader — in a small extractor, and pass it to the template. -
Keep messages in a translation format with plural and gender rules, such as Project Fluent (
.ftlfiles, with Rust crates from the Fluent project) or gettext catalogues. -
Expose a lookup to the templates: a method on a field of the askama struct (
{{ i18n.t("catalogue-title") }}), a function registered with minijinja’sadd_function, or a plain Rust call inside maud’shtml!. -
Format numbers and dates for the locale in Rust before rendering, and set
<html lang="...">from the negotiated locale for screen readers (see Accessibility).
Pitfalls
-
Templates are compiled into the binary (askama) or read at runtime (minijinja). A Docker image for a minijinja app must copy the
templates/directory into the runtime stage; an askama app must have it in the build stage (cargo-chef recipes included), and editing a template triggers a recompilation of its crate. -
Template paths are relative to the crate’s
CARGO_MANIFEST_DIR, not the workspace root: each crate in a workspace has its owntemplates/directory unlessaskama.tomllists otherdirs. -
Returning
Stringinstead ofHtml<String>sendscontent-type: text/plain, and the browser shows the markup as text. ReturnHtml, aWebTemplateorMarkup. -
Large pages and the async runtime. Rendering is CPU work on a tokio worker thread. Normal pages take microseconds; a report with tens of thousands of rows should be paginated or rendered in
spawn_blocking. -
Treating
|safeas a formatting tool. Every|safeon data that came from a user is an XSS hole; grep for it in review. -
Duplicated markup for htmx fragments. Copying a list into a second template drifts out of sync; render the block of the full template instead, as above.
-
Caching personalised pages. A page that contains "Signed in as …" must not be cached by a shared cache; send
Cache-Control: private(orno-store) andVary: CookieorVary: HX-Requestwhere responses differ.
Common errors
| Error | Cause and fix |
|---|---|
|
The file is not under |
|
The template uses a field or method the struct does not have; askama templates are Rust code. |
|
The handler returns a type that is not |
|
The crate no longer exists for askama 0.13 and later; see the section above. |
|
A macro call without |
minijinja: |
The loader path is wrong in this environment (often a missing |
Which one?
| askama 0.16 | minijinja 2 | maud 0.27 | |
|---|---|---|---|
Syntax |
Jinja-like, in |
Jinja2, in files or strings |
Rust macro in |
Checked at |
compile time (fields, types) |
runtime |
compile time |
Change without rebuild |
no |
yes (reloadable) |
no |
Escaping |
automatic for |
automatic by extension |
automatic |
Performance |
fastest (compiled Rust) |
fast (bytecode) |
fastest (compiled Rust) |
Best for |
most server-rendered apps; designers comfortable with Jinja |
user-editable templates, CMS-like features |
developers who prefer one language and composable functions |
Version notes
| Version | Change |
|---|---|
askama 0.12 |
Last release with framework integration crates ( |
askama 0.13 |
The rinja fork merged back into askama; the integration crates were removed. Use |
askama 0.16 / askama_web 0.16 |
Current versions on this page. Custom filters are declared with |
minijinja 2.24 |
|
maud 0.27 |
The |
The upcoming axum 0.9 does not change how templates are returned: IntoResponse stays the integration point. Check
that askama_web and maud publish an axum 0.9 feature before upgrading.