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 %} and child.html writes {% 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.
Autoescape only covers HTML context - never inline a variable into JavaScript

The escape guarantee applies to HTML text and quoted-attribute contexts (

{{ x }}

, title="{{ x }}"). It does not make a value safe inside a JavaScript string, an href="javascript:...", an inline on* event handler, or a