Type-Safe HTML Templating in Zig Without Leaving HTML Behind
A refactor can turn account.display_name into account.name in seconds. In a dynamic HTML template, that typo may survive until a user requests the page. In Zig, the more useful failure is a compiler error during the build.
That is the idea behind zt, a small template language for Zig. A template mixes HTML markup with dynamic values and program logic. zt transpiles—converts one source language into another—.zt files into Zig source during zig build, so the ordinary Zig compiler can check the result. Here, type-safe means the compiler checks that values passed into a template match its declared types instead of arriving through an unstructured map.
What does type-safe HTML templating in Zig buy you? A misspelled field, wrong argument type, or malformed element can fail before the server sends a response.
HTML first, Zig where it belongs
The syntax does not ask you to redraw a webpage as a Zig data structure. You write elements, attributes, and text as HTML, then use braces for Zig expressions and ordinary Zig control flow for larger decisions.
The button in this example uses htmx, a browser library that reads HTML attributes to issue requests and replace returned HTML. Remove those attributes and the rest remains ordinary server-rendered markup.
const Account = @import("../models.zig").Account;
pub templ AccountPanel(account: Account) {
<section class="account-panel">
<h1>{account.display_name}</h1>
if (account.is_active) {
<span class="status active">Active</span>
} else {
<span class="status paused">Paused</span>
}
<button
hx-post="/accounts/{account.id}/toggle"
hx-target="#account-status">
Change status
</button>
</section>
}
Expressions in braces can read fields, call Zig functions, and format values. The parser also requires elements to be closed. That may feel strict if you have written forgiving HTML for years, but it catches a class of broken-page mistakes while the template is being compiled.
Build-time generation, runtime writing
zt reads templates during the build and emits Zig files. Your application imports those generated files like any other module. The important step looks like this inside build.zig:
const zt_dep = b.dependency("zt",.{
.target = target,
.optimize = optimize,
});
const templates = zt.addTemplates(b, zt_dep, &.{
b.path("src/templates/account.zt"),
});
The executable depends on templates, so generation happens before Zig analyzes the application. zt only needs to separate HTML from Zig; the real compiler remains responsible for checking imports, fields, optional values, loops, and function calls.
A std.Io.Writer is Zig's destination for a stream of bytes. It might point at a response buffer, a file, standard output, or a network connection. Rendering writes directly to that destination:
try pages.AccountPanel.render(.{account}, response_writer);
There is no request-time template parser and no intermediate HTML document that must be assembled first. Literal markup and dynamic values are emitted in order, which keeps the rendering path small and lets the HTTP layer choose between buffering and streaming.
The compiler becomes your template checker
Suppose the model contains display_name, but the template says {account.dispay_name}. zt does not need a separate runtime diagnostic system to understand the mistake. The generated Zig contains that field access, so the Zig compiler reports that the field does not exist.
The same applies at the call site. If AccountPanel expects an Account and a handler passes a string, the render call fails to compile. The template's parameters are real Zig parameters, not names looked up from a loosely typed dictionary.
Generated source can make an error look slightly indirect because the compiler points into the emitted .zig file. zt preserves the original template location in comments, though, so the trail leads back to the line you wrote.
Escaping is the default
HTML has a sharp edge: untrusted text can become markup. A comment containing <script> should appear as text, not execute in the browser. That class of vulnerability is called cross-site scripting, or XSS.
zt treats interpolated values as escaped output by default:
pub templ Comment(body: []const u8) {
<p class="comment">{body}</p>
}
A value containing < or & is encoded for HTML before it reaches the writer. The same protection matters inside attributes, where an unescaped quote can change the shape of an element. Raw HTML is still possible, but it has a visible escape hatch:
<div class="preview">{!trusted_fragment}</div>
The exclamation mark is a useful code-review signal. It says that the value is trusted markup and that sanitizing it is the caller's responsibility. Automatic escaping reduces common mistakes; it does not replace validation of URLs, authorization, or other security boundaries.
Layouts without a second inheritance language
Reusable pieces are called components: small templates that can be rendered from other templates. A layout can accept nested content through @children, giving you the practical benefit of template inheritance without another hierarchy system:
pub templ Card(title: []const u8) {
<section class="card">
<h2>{title}</h2>
<div class="card-body">
@children
</div>
</section>
}
pub templ SettingsPage {
@Card("Settings") {
<p>Notification preferences live here.</p>
}
}
zt also exposes a component type—a common wrapper for a renderable template—and bind, which packages template arguments for later use. That is useful for menus, alerts, modal bodies, and other pieces selected by application logic. The result is composition rather than a maze of copied strings.
A small rough edge
The project is young, and one syntax decision is still unsettled. In the current form, a block-level line beginning with if, for, or switch is treated as Zig control flow. A sentence such as “if you forgot your password…” can therefore be read as an incomplete statement.
Wrapping the prose in an element or writing it as a string expression avoids the ambiguity. An @if-style syntax is one possible direction for the future. This friction comes from an otherwise appealing choice: control flow looks like Zig instead of belonging to a miniature language with unrelated rules.
The shape of a Zig web stack
zt does not try to turn Zig into a JavaScript framework. Its narrower idea is more interesting: keep server-rendered HTML pleasant to write, let the compiler check the data crossing into the page, and stream the result through the same I/O primitives used elsewhere in the application.
For a Zig service using htmx, that can support interactive pages without a client-side build pipeline. For a simpler site, it creates a direct path from a typed model to a response body. Type-safe HTML templating in Zig works because the template is not treated as a mysterious string; it becomes ordinary, inspectable Zig code before the server ever runs.
Comments (0)
No comments yet. Be the first to respond!
Leave a Comment
Your comment will be visible after review.