Skip to content

HTML Templates & View Rendering ​

HTTPX ships a Jinja-compatible template engine (httpx.templates.Engine) with HTML autoescaping to prevent Cross-Site Scripting (XSS). Templates are tokenized with a Tree-sitter grammar (expressions, statements, comments), compiled to an AST, and rendered in a separate cached pass over that AST. Tree-sitter stays internal: user code only ever imports httpx.

Template Syntax ​

Variables and expressions ​

html
<h1>{{ title }}</h1>
<p>Welcome, {{ user.name }} ({{ user.role }})</p>
<p>{{ items[0] }} costs {{ price * quantity }}</p>
<p>{{ user.age >= 18 }} {{ greeting ~ "!" }}</p>

Expressions support literals (strings, numbers, booleans, null/none, lists, tuples (1, 2), dicts), property access (user.name), index access (items[0], user["name"]), chained access, calls with positional and keyword arguments, filters (with positional and key=value args), tests, arithmetic (+ - * / // % **), comparisons (== != > >= < <=), membership (in, not in), identity tests (is, is not), logic (and or not), concatenation (~), parentheses, ternary (x if cond else y), and calls.

Operator precedence follows Jinja/Python: or < and < not < comparisons/in/is < ~ < + - < * / // % < ** (right-associative, tighter than unary minus, so -2**2 == -4 and 2**3**2 == 512).

Conditionals ​

html
{% if showAdmin %}
  <p>Admin panel</p>
{% elif is_member %}
  <p>Member view</p>
{% else %}
  <p>Guest view</p>
{% endif %}

Loops ​

html
<ul>
{% for item in items %}
  <li>#{{ loop.index }}: {{ item }}</li>
  {% if loop.last %}<hr>{% endif %}
{% endfor %}
</ul>

Inside a loop, loop.index (1-based), loop.index0, loop.first, loop.last, loop.length, loop.revindex, loop.revindex0, loop.depth, and loop.depth0 are available. {% break %} and {% continue %} control loop flow. {% for i in range(3) %} iterates a generated sequence. An {% else %} branch renders when the loop is empty:

html
{% for user in users %}
  <p>{{ user.name }}</p>
{% else %}
  <p>No users.</p>
{% endfor %}

Loops filter inline ({% for x in items if x.active %}), destructure ({% for key, value in pairs %}, {% for a, b, c in rows %}, {% for (k, v) in pairs %}), iterate maps by key and strings by character, and recurse:

html
{% for node in tree recursive %}
  {{ node.name }}
  {% if node.kids %}<ul>{{ loop(node.kids) }}</ul>{% endif %}
{% endfor %}

Map helpers compose with loops: {% for k, v in user.items() %}.

Variables and assignment ​

html
{% set greeting = "Hello, " ~ user.name %}
<p>{{ greeting }}</p>
{% set a, b = 1, 2 %}

{% set a, b = pair %} unpacks tuples/lists pairwise.

Inheritance and partials ​

html
{% extends "base.html" %}
{% block content %}<p>Page body</p>{% endblock %}
{% include "partials/nav.html" %}

super() chains through every inheritance level. Includes support ignore missing, with/without context, and computed paths:

html
{% include "sidebar.html" ignore missing %}
{% include "ads.html" without context %}
{% include layout_name %}

Imports ​

html
{% import "forms.html" as forms %}
{% from "forms.html" import input as textInput %}
{{ forms.input("name") }}

Imported macros are isolated from template data by default; add with context to share it ({% import "m.html" as m with context %}).

Macros ​

html
{% macro input(name, value="") %}
  <input name="{{ name }}" value="{{ value }}">
{% endmacro %}

{{ input("username")|safe }}
{{ input("role", value="admin")|safe }}

Macro output is escaped like any other expression; mark trusted markup with |safe. Macros accept positional, defaulted, and keyword arguments, plus *args/**kwargs collectors (with varargs/kwargs visible inside). Same-template macros cannot see template variables (Jinja isolation); caller() powers call blocks, including declared caller parameters:

html
{% macro wrap(cls) %}<section class="{{ cls }}">{{ caller() }}</section>{% endmacro %}
{% call(item) wrap("wide") %}<p>{{ item }}</p>{% endcall %}

Filter, with, and autoescape blocks ​

html
{% filter upper %}shout this{% endfilter %}
{% with total = price * qty %}{{ total }}{% endwith %}
{% autoescape false %}{{ trusted_html }}{% endautoescape %}

{% apply %} is accepted as an alias of {% filter %}. {% with %} creates a scoped block; assignments vanish afterwards.

Filters ​

html
{{ name|trim|upper }}
{{ nickname|default("anonymous") }}
{{ tags|join(", ") }} ({{ tags|length }})
{{ bio|striptags|truncate(80) }}
{{ description|replace("old", "new") }}
{{ users|map(attribute="name")|join(", ") }}
{{ users|selectattr("age", ">", 18)|length }}
{{ data|tojson }}

Builtins: abs attr batch capitalize center default dictsort escape filesizeformat first float forceescape format groupby indent int join last length list lower map max min pprint random reject rejectattr replace reverse round safe select selectattr slice sort string striptags sum title tojson trim truncate unique upper urlencode wordcount wordwrap xmlattr (plus e d len count aliases). Filter arguments may be positional or keyword (truncate(30, killwords=true)). Register custom filters once on the engine:

zig
fn shout(alloc: std.mem.Allocator, v: httpx.templates.Value, args: []const httpx.templates.Value, kwargs: []const httpx.templates.FilterKwarg) anyerror!httpx.templates.Value {
    _ = args;
    _ = kwargs;
    const s = try alloc.dupe(u8, v.string);
    for (s) |*c| c.* = std.ascii.toUpper(c.*);
    return .{ .string = s };
}
try engine.registerFilter("shout", shout);

Whitespace control ​

html
<ul>
  {%- for item in items -%}
    <li>{{ item }}</li>
  {%- endfor -%}
</ul>

A - adjacent to a delimiter strips surrounding whitespace ({%- ... -%}, {{- ... -}}, {#- ... -#}). A dash separated by space is not control: {{ -x }} keeps its unary minus.

Tests ​

html
{% if user is defined %}...{% endif %}
{% if value is none %}...{% endif %}
{% if name is string and age is number %}...{% endif %}
{% if items is sequence and user is mapping %}...{% endif %}
{% if n is divisibleby(3) %}...{% endif %}
{% if id is in(allowed) %}...{% endif %}

Available tests: defined undefined none true false boolean integer float number string lower upper sequence mapping iterable callable escaped odd even divisibleby eq equalto ne lt le gt ge sameas in, plus is not negation ({% if x is not none %}). Tests taking arguments use call syntax as shown above.

Undefined values ​

Missing variables render empty and are falsy:

html
{{ missing }}          <!-- renders empty -->
{{ missing.name }}     <!-- renders empty, never crashes -->
{{ missing|default("anonymous") }}

Enable strict mode to fail loudly instead (.strictUndefined = true in the engine config): missing output, conditions, iterations, and arithmetic operands return error.UnknownVariable. |default(...) and is defined keep working because they resolve before the strict check.

Trusted raw HTML ​

Values are escaped by default. Bypass escaping only for trusted markup with templates.raw(...):

zig
templates.raw("<small>&copy; 2026 HTTPX</small>")

or the |safe filter for values already known safe:

jinja
{{ trusted_html|safe }}

escape leaves already-safe markup untouched (Jinja Markup semantics); forceescape escapes even safe values.

Raw blocks ​

html
{% raw %}
  {{ this is emitted literally }}
{% endraw %}

Server view handler ​

zig
const std = @import("std");
const httpx = @import("httpx");

pub fn main() !void {
    var gpa: std.heap.DebugAllocator(.{}) = .init;
    defer _ = gpa.deinit();
    const allocator = gpa.allocator();
    const io = std.Io.Threaded.global_single_threaded.io();

    var server = try httpx.Server.init(allocator, io, .{ .port = 8080 });
    defer server.deinit();

    const ProfileHandler = struct {
        fn handle(_: *httpx.Context) anyerror!httpx.Response {
            const username = "Jane Doe";
            var page_buf: [1024]u8 = undefined;
            const html_page = try std.fmt.bufPrint(&page_buf,
                \\<!DOCTYPE html>
                \\<html>
                \\<head><title>Profile</title></head>
                \\<body>
                \\  <h1>Welcome, {s}!</h1>
                \\</body>
                \\</html>
            , .{username});

            return .{
                .status = 200,
                .body = html_page,
                .contentType = "text/html; charset=utf-8",
            };
        }
    };
    try server.get("/profile", ProfileHandler.handle);

    server.run();
}

Security: HTML escaping ​

Template variables are HTML-escaped by default (& → &amp;, < → &lt;, > → &gt;, " → &quot;, ' → &#39;). Bypass escaping only for trusted markup with templates.raw(...) (see above). Rendered output is capped (.maxOutputBytes, default 64 MiB), macro nesting is bounded (.maxMacroDepth), and range() sequences are capped (.maxRangeItems).

Set blocks, call blocks, and super ​

html
{% set card %}<div class="card">{{ body }}</div>{% endset %}
{{ card }}

{% macro wrap(cls) %}<section class="{{ cls }}">{{ caller() }}</section>{% endmacro %}
{% call wrap("wide") %}<p>Content</p>{% endcall %}

{% extends "base.html" %}
{% block content %}{{ super() }}<p>More</p>{% endblock %}

{% set name %}...{% endset %} captures rendered markup (safe HTML). {% call %} renders its body and exposes it as caller() inside the macro. {{ super() }} renders the overridden parent block, chaining through every inheritance level.

Custom filters and globals ​

zig
fn shout(alloc: std.mem.Allocator, v: httpx.templates.Value, args: []const httpx.templates.Value, kwargs: []const httpx.templates.FilterKwarg) anyerror!httpx.templates.Value {
    _ = args;
    _ = kwargs;
    const s = try alloc.dupe(u8, v.string);
    for (s) |*c| c.* = std.ascii.toUpper(c.*);
    return .{ .string = s };
}
try engine.registerFilter("shout", shout);

fn urlFor(_: ?*const anyopaque, alloc: std.mem.Allocator, args: []const httpx.templates.Value, kwargs: []const httpx.templates.GlobalKwarg) anyerror!httpx.templates.Value {
    _ = alloc;
    // args[0] is the route name; kwargs carry route params (id=42, ...).
    var out = std.ArrayList(u8).empty;
    // ... resolve against your router ...
    return .{ .string = try out.toOwnedSlice(alloc) };
}
try engine.addGlobal("url_for", urlFor, null);
jinja
<a href="{{ url_for("user-profile", id=user.id) }}">Profile</a>

One mechanism covers both: registerFilter for value|name pipelines, addGlobal for name(args) callables. Macros and range() resolve before globals.

Inheritance cycles and error locations ​

Cyclic {% extends %} chains fail with error.CircularInheritance. Syntax errors carry template:line:column locations, e.g. templates/index.html:14:5: unexpected endif. Runtime failures are recorded on engine.lastError with the failing node's location.

Engine configuration and caching ​

zig
var engine = try httpx.templates.Engine.init(allocator, io, .{
    .directory = "templates",
    .enableCache = true,
    .strictUndefined = true,
});
defer engine.deinit();

// Render a template file with data into any writer
var list = std.ArrayList(u8).empty;
defer list.deinit(allocator);
var lw = httpx.templates.renderer.ListWriter{ .list = &list, .allocator = allocator };
try engine.render("index.html", .{ .title = "Hello" }, &lw);

// Or render an in-memory string
try engine.renderString("<h1>{{ title }}</h1>", .{ .title = "Hello" }, &lw);

Compiled templates are cached in memory (enableCache). Cached renders are safe under concurrent load, and invalidating a template evicts its dependents without freeing ASTs under in-flight renders. Template loading resolves safe relative paths only, blocking directory traversal outside the template directory. Pair with the file watcher and engine.invalidate(path) for hot reload during development.

Compatibility notes ​

The language tracks Jinja (3.x) semantics: expression precedence, filters, tests, loop metadata, macro scoping (macros are isolated from template data unless imported with context), super() chains, whitespace control, and autoescaping. Deliberate boundaries:

  • {% extends %} paths must be string literals (no dynamic parents).
  • {% include %} accepts a literal path or a context expression.
  • Zig values convert structurally (structs, slices, arrays, optionals); Zig functions are not callable from templates — expose behavior through addGlobal instead.
  • {% do %} and {% trans %} are not implemented.
  • format supports %s %d %i %u %f %c %x %X %o %% with width, precision, and flags; %e/%g reject explicit precision.

Released under the MIT License.