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 ordinary Err the handler (or its AppError) converts into a response.

  • With askama_web’s WebTemplate or maud’s Markup the handler returns the template value, and rendering happens in its IntoResponse implementation — 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 with content-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"] }
{# 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 %}
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();
}
$ 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: &#60;script&#62;alert(&#39;xss&#39;)&#60;/script&#62; 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 a filters module. Template expressions are Rust: {% if let Some(name) = user %} and method calls work.

  • Escaping: every {{ }} in an .html template is HTML-escaped, which is why the injected <script> above renders as text. |safe disables 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 build with the template line; render() itself can only fail at runtime for things like a failing Display impl, which WebTemplate turns into a 500.

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 in href/src. Validate the scheme of user-supplied links, and use urlencode for values placed inside a path or query string, as /books/{{ book.isbn|urlencode }} does below.

  • Inside <script> and on* attributes HTML escaping is the wrong encoding. Pass data to JavaScript through a data- attribute or a <script type="application/json"> block filled with askama’s json filter (feature serde_json) or minijinja’s tojson (feature json, not on by default), which escape for that context.

  • |safe (askama, minijinja) and PreEscaped (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 %}
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 "&#60;b&#62;&#34;x"</p>
  • A custom filter is a function in a module named filters next to the template struct, annotated with #[askama::filter_fn]; its first argument is the filtered value, the second the template’s runtime Values, 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 (q and books, not user).

  • 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-Request for caches, response headers such as HX-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
  • 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 /api should keep returning Problem Details JSON (see Error Handling); pick the representation per router, or by the Accept header 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();
}

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 Environment once at start-up and share it in state; creating one per request re-parses every template.

  • The default UndefinedBehavior::Lenient prints 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; SemiStrict still allows {% if maybe_missing %}.

  • minijinja can also render a single block for htmx: template.eval_to_state(ctx)?.render_block("results").

  • add_function and add_filter register 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();
}

Source: maud book and maud 0.27

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-Language header — 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 (.ftl files, 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’s add_function, or a plain Rust call inside maud’s html!.

  • 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 own templates/ directory unless askama.toml lists other dirs.

  • Returning String instead of Html<String> sends content-type: text/plain, and the browser shows the markup as text. Return Html, a WebTemplate or Markup.

  • 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 |safe as a formatting tool. Every |safe on 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 (or no-store) and Vary: Cookie or Vary: HX-Request where responses differ.

Common errors

Error Cause and fix

template "book.html" not found in directories [".../templates"] (compile time)

The file is not under templates/ of this crate, or the path has a typo. Check askama.toml dirs.

error[E0609]: no field `title` on type `&BookPage` (compile time, at the derive)

The template uses a field or method the struct does not have; askama templates are Rust code.

error[E0277]: the trait bound `fn() -> impl Future<Output = BookPage> {h}: Handler<_, _>` is not satisfied

The handler returns a type that is not IntoResponse: the struct derives only Template. Add #[derive(WebTemplate)] (askama_web with feature axum-0.8) or return Html(page.render()?); #[axum::debug_handler] points at the return type directly.

error[E0432]: unresolved import `askama_axum`

The crate no longer exists for askama 0.13 and later; see the section above.

expected `endcall` to terminate `call` node

A macro call without {% endcall %}.

minijinja: template not found or undefined value at runtime, answered with 500

The loader path is wrong in this environment (often a missing COPY templates in the image), or a variable is missing with UndefinedBehavior::Strict. Log the error; it includes the template name and line.

Which one?

askama 0.16 minijinja 2 maud 0.27

Syntax

Jinja-like, in .html files

Jinja2, in files or strings

Rust macro in .rs files

Checked at

compile time (fields, types)

runtime

compile time

Change without rebuild

no

yes (reloadable)

no

Escaping

automatic for .html

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

Bookshelf uses askama: compile-time checks catch template bugs in CI, and the syntax is familiar to anyone who knows Jinja, Django or Twig templates (compare this site’s Django and Laravel sections).

Version notes

Version Change

askama 0.12

Last release with framework integration crates (askama_axum, askama_actix, …​).

askama 0.13

The rinja fork merged back into askama; the integration crates were removed. Use askama_web or Html(template.render()?).

askama 0.16 / askama_web 0.16

Current versions on this page. Custom filters are declared with #[askama::filter_fn] and receive the runtime Values as their second argument; askama_web supports axum 0.7 (axum-0.7) and 0.8 (axum-0.8) by feature.

minijinja 2.24

path_loader needs the loader feature; four undefined behaviours: Lenient (default), Chainable, SemiStrict and Strict.

maud 0.27

The axum feature implements IntoResponse for Markup against axum-core 0.5, the core of axum 0.8.

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.