Skip to content

Template Syntax

The zzz template engine uses a Mustache-inspired syntax with double and triple curly braces. All templates are parsed at compile time, so syntax errors produce compiler diagnostics rather than runtime failures.

SyntaxPurposeHTML escaped
{{variable}}Output a valueYes
{{{variable}}}Output a value without escapingNo
{{! comment }}Comment (no output)
{{#if cond}}...{{/if}}Conditional block
{{#if cond}}...{{else}}...{{/if}}Conditional with else
{{#each items}}...{{/each}}Loop over a slice
{{#with expr}}...{{/with}}Scope into a nested struct
{{> partial}}Include a partial template
{{{{raw}}}}...{{{{/raw}}}}Raw block (no tag processing)No

Use double braces to output a value. The value is automatically HTML-escaped to prevent XSS:

<h1>{{title}}</h1>
<p>Written by {{author}}</p>
try ctx.render(PageTemplate, .ok, .{
.title = "My Post",
.author = "Alice",
});

Output:

<h1>My Post</h1>
<p>Written by Alice</p>

Double-brace variables escape the following characters:

CharacterEscaped to
&&amp;
<&lt;
>&gt;
"&quot;
'&#x27;

For example, if content is <script>alert('xss')</script>, then {{content}} renders as:

&lt;script&gt;alert(&#x27;xss&#x27;)&lt;/script&gt;

Use triple braces when you need to output trusted HTML without escaping:

<div class="content">{{{body_html}}}</div>

Only use {{{...}}} for values you control (such as pre-rendered HTML from your own code). Never use it for user-supplied input.

Both string ([]const u8) and integer fields are supported. Integer values are automatically formatted to their decimal string representation:

<p>Count: {{count}}</p>
try ctx.render(Tmpl, .ok, .{ .count = @as(u32, 42) });
// Output: <p>Count: 42</p>

Access nested struct fields using dot-separated paths:

<p>{{user.name}} ({{user.email}})</p>
try ctx.render(Tmpl, .ok, .{
.user = .{ .name = "Alice", .email = "alice@example.com" },
});
// Output: <p>Alice (alice@example.com)</p>

Dot paths can be arbitrarily deep: {{profile.address.city}} resolves @field(@field(@field(data, "profile"), "address"), "city") at comptime.

Render a block only when a condition is truthy:

{{#if show_banner}}
<div class="banner">Welcome back!</div>
{{/if}}

Provide an alternative when the condition is falsy:

{{#if logged_in}}
<span>Welcome, {{username}}!</span>
{{else}}
<a href="/login">Log in</a>
{{/if}}

The template engine evaluates truthiness based on the Zig type of the field:

TypeTruthy when
booltrue
?T (optional)Not null
[]const T (slice)len > 0
Any other typeAlways truthy

This means you can use optionals and slices directly in conditions without converting to bool:

{{#if items}}
<p>You have items in your cart.</p>
{{else}}
<p>Your cart is empty.</p>
{{/if}}
const empty: []const Item = &.{};
try ctx.render(Tmpl, .ok, .{ .items = empty });
// Renders the else branch because the slice is empty

Iterate over a slice with {{#each}}. Inside the loop body, the context switches to each element:

<ul>
{{#each items}}
<li>{{name}} - {{price}}</li>
{{/each}}
</ul>
const Item = struct { name: []const u8, price: []const u8 };
const items = [_]Item{
.{ .name = "Widget", .price = "$9.99" },
.{ .name = "Gadget", .price = "$19.99" },
};
try ctx.render(Tmpl, .ok, .{
.items = @as([]const Item, &items),
});

Output:

<ul>
<li>Widget - $9.99</li>
<li>Gadget - $19.99</li>
</ul>

The collection field must be a slice ([]const T) or a pointer to an array. Inside the loop body, all tags resolve against the element type T rather than the root data struct.

The {{#with}} block changes the scope to a nested field, reducing repetition when accessing deeply nested data:

{{#with user}}
<p>{{name}} -- {{email}}</p>
{{/with}}

This is equivalent to:

<p>{{user.name}} -- {{user.email}}</p>

{{#with}} also works with dot paths:

{{#with profile.address}}
<p>{{city}}, {{state}}</p>
{{/with}}

Comments produce no output. Use them for documentation inside templates:

{{! This section shows the user profile }}
<div class="profile">
{{name}}
</div>

The comment content (everything between {{! and }}) is discarded during rendering.

Raw blocks pass their content through without any template processing. This is useful when you need to include literal curly braces in your output, such as client-side template syntax or code examples:

{{{{raw}}}}
This {{will not}} be processed as a variable.
Neither will {{{this}}} or {{#if anything}}.
{{{{/raw}}}}

Output:

This {{will not}} be processed as a variable.
Neither will {{{this}}} or {{#if anything}}.

Only {{{{raw}}}} is supported as the block name for raw blocks.

Because templates are parsed at compile time, you get immediate feedback on errors:

  • Unclosed tags{{name without }} produces a compile error
  • Unclosed blocks{{#if show}} without {{/if}} produces a compile error
  • Missing fields — referencing {{nonexistent}} with a data struct that lacks that field produces a compile error
  • Type mismatches — using {{#each}} on a non-slice field produces a compile error
  • Unresolved partials{{> name}} without providing the partial source produces a compile error