This version is in beta. Some features may change before release.

Rendering HTML

Server-side templates via minijinja, with autoescape on by default.

umbral::templates is the server-side HTML rendering substrate. Templates live on disk under one or more directories, load at boot, and render through one ambient accessor that picks up the engine from App::build()'s OnceLock - the same pattern as the DB pool. The engine is minijinja, which speaks Jinja2 syntax ({% %} / {{ }}), so anyone who's used Jinja2 or Flask templates will recognise the shape immediately.

Point the builder at a templates directory

Code
rust
use umbral::prelude::*;
 
App::builder()
.settings(settings)
.database("default", pool)
.templates_dir("templates") // default is `./templates`
.routes(Routes::new().get("/", home))
.build()?;

When templates_dir isn't called the engine looks for ./templates relative to the binary's cwd. If the directory doesn't exist the engine still boots (empty) - the absence isn't an error until something tries to render.

A base template and a child

Code
html
{/* templates/base.html */}
<!doctype html>
<html>
<head><title>{% block title %}umbral app{% endblock %}</title></head>
<body>
<header><nav><a href="/">Home</a> <a href="/articles">Articles</a></nav></header>
<main>{% block content %}{% endblock %}</main>
</body>
</html>
Code
html
{/* templates/articles_list.html */}
{% extends "base.html" %}
{% block title %}Articles - umbral app{% endblock %}
{% block content %}
<h1>Articles</h1>
{% for article in articles %}
<article>
<h2><a href="/articles/{{ article.id }}">{{ article.title }}</a></h2>
<p>{{ article.body }}</p>
</article>
{% endfor %}
{% endblock %}

Inheritance, loops, conditionals, and variable interpolation are all standard Jinja2.

Overriding a block replaces it - use `super()` to extend

When a deeper template overrides a block its parent already defined, the parent's contents are wiped. If base.html puts `

in{% block extra_head %}andchild.htmlwrites{% block extra_head %}

{% endblock %}, the Inter font declaration disappears - the child only has .foo`.

To extend rather than replace, call {{ super() }} at the top of the child's block:

Code
html
{% extends "base.html" %}
{% block extra_head %}
{{ super() }}
<style>.foo { color: red; }</style>
{% endblock %}

This bites hardest with multi-level inheritance (wrapper.htmlbase.htmlchild.html). Each child that overrides a block needs {{ super() }} for the parent's contents to survive. Forgetting it produces pages with missing fonts, missing scripts, broken layouts - all because one intermediate template silently wiped the chain. The umbral-admin shell hit this exact bug: the changelist's extra_head override wiped the font-family and scrollbar styles base.html had set.

If you find yourself wanting super() in every override, consider whether the styles belong in wrapper.html (the root) instead, where they can't be wiped.

Fragment templates and direct browser navigation

A "fragment" template (no {% extends %}) is meant to be swapped into an existing page via HTMX (or returned as a partial). Serving a fragment to a direct browser nav (back button, bookmark, copy-paste URL) produces a naked HTML response with no <head>, no CSS, no fonts - the browser falls back to OS defaults and the page looks broken.

The pattern in umbral-admin: handlers that return fragments check the HX-Request header and redirect to a full page when the request is a direct nav:

Code
rust
async fn rows_fragment(headers: HeaderMap, ...) -> Response {
if !is_htmx(&headers) {
// Direct nav - redirect to the full page that wraps these rows.
return Redirect::to("/admin/post/?...").into_response();
}
// HTMX swap - return just the tbody.
render("admin/rows_fragment.html", ...).into_response()
}

The full-page template is responsible for emitting the HTMX call that loads the fragment back in. This way every URL is reachable both as a fragment (for HTMX) and as a usable page (for direct nav).

Render from a handler

Code
rust
use umbral::prelude::*;
use umbral::templates::context;
use umbral::web::Html;
 
async fn list_articles() -> Result<Html<String>, (StatusCode, String)> {
let articles = Article::objects().fetch().await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
let body = umbral::templates::render("articles_list.html", &context!(articles))
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
Ok(Html(body))
}

context! is minijinja's macro, re-exported through the facade so your crate doesn't need a direct minijinja dependency. umbral::web::Html is an axum re-export that sets the response's Content-Type: text/html.

Autoescape is on by default

Any template whose name ends in .html or .htm renders with autoescape on. A value containing <script> comes out as &lt;script&gt; - the XSS guarantee from arch.md §4.5. Text templates (.txt) render verbatim, so an email-body template that wants raw output works the same way.

Warning
Opting out of autoescape for one value is a deliberate keystroke: pipe through the `safe` filter (`{{ value|safe }}`). There's no global escape hatch. If you find yourself reaching for `safe` often, you're probably one helper function away from a cleaner data shape.

Cross-plugin templates

Plugins can contribute their own templates/ directories by overriding Plugin::templates_dirs(). The template engine searches all registered directories in order and the first directory that contains a given template name wins.

Code
rust
use umbral::prelude::*;
use std::path::PathBuf;
 
pub struct AdminPlugin;
 
impl Plugin for AdminPlugin {
fn name(&self) -> &'static str { "admin" }
 
fn templates_dirs(&self) -> Vec<PathBuf> {
// Bundled templates ship alongside the plugin's source.
vec![PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("templates")]
}
}

Search order

The full search order at boot is:

  1. The app-level directory set via AppBuilder::templates_dir (or ./templates). Always first - the app developer gets the final word.
  2. Each registered plugin's templates_dirs() contribution, in topological dependency order (dependencies before dependents).

Cross-plugin {% extends %}

Because every directory is in a single search list, {% extends "base.html" %} in plugin A resolves base.html from plugin B automatically - no special syntax needed. The Jinja2 inheritance lookup uses the same path the initial render call does.

Code
html
{/* plugin_a/templates/article.html */}
{% extends "base.html" %}
{% block content %}
<article>{{ post.title }}</article>
{% endblock %}

If base.html lives in plugin B's templates directory and plugin B is registered before plugin A, the render finds it.

Collision policy: first-match-wins with a boot warning

When two directories ship a template with the same name, the first directory in the search order wins and umbral emits a tracing::warn! at boot so the shadowing is visible in the log:

Code
txt
WARN umbral templates: template `base.html` is provided by multiple directories; the first-registered copy wins

Plugin template directories are searched in registration order, with the app-level templates/ directory first. To override a plugin's template intentionally, place a same-named file in the app-level templates/ directory - it is always searched first. Namespace-prefixed paths (admin/base.html) are the conventional way to avoid accidental collisions between unrelated plugins.

What ships at v1

Shipped

App-level templates directory. Per-plugin templates directories via Plugin::templates_dirs(). First-match-wins search with collision warnings. Cross-plugin extends/block inheritance. Autoescape on .html. Forward-slash names on every OS. Core custom filters/functions (img, markdown, sanitize, currency, static, media_url, now, querystring_with, highlight_styles). Plugin-contributed filters/functions via Plugin::template_registrars(). Ambient user / csrf_token / csrf_input globals. Dev-mode hot reload (env rebuilt per render when settings.environment is Dev).

Deferred

Humanize-style filters (naturaltime, intcomma) for app templates.

Accessing FK fields in templates

When a model has a ForeignKey<T> field, the template context carries only the raw integer by default. To access nested fields like {{ post.author.username }}, fetch the post with select_related so the FK is serialised as the full T object:

Code
rust
let post = Post::objects()
.filter(post::ID.eq(id))
.select_related("author") // serialises author as {id, username, ...} not 42
.get()
.await?;
let ctx = context!(post);
// template: {{ post.author.username }} now resolves correctly

Without select_related, {{ post.author }} renders the bare integer. See Relationships for the full serialisation table.

Worked example

examples/derive-demo exercises this end-to-end: five templates under templates/ (base + home + articles list + article detail + 404), four routes serving HTML, one keeping the JSON shape, plus the XSS guard verified by injecting <script> into a row body and watching it render as &lt;script&gt;.

The substrate spec promotes from the outline at docs/specs/outlines/templates.md when the admin plugin lands.