Type-Safe HTML in Zig: How zt Turns Templates Into Checked Code
HTML templates occupy an awkward place in many web applications. They look like source code, accept application data, and contain control flow, but errors in them may not appear until a request reaches the right page. A renamed field can become a production-only failure. A careless interpolation can become an injection bug. And a general-purpose template engine often introduces a second language beside the application language.
zt takes a different route for Zig applications. Its .zt files look mostly like HTML with Zig expressions. During zig build, a template compiler turns those files into Zig source. The normal Zig compiler then checks the generated program against the application’s real types. At runtime, rendering is a sequence of writes to std.Io.Writer.
The approach is young, but it makes an interesting design choice concrete: move template interpretation into the build, then let the host language do the checking.
The four requirements behind the design
Lukáš Lalinský built zt while working on zio, a Zig I/O library, and Dusty, a web framework. His server work already produced JSON and MessagePack; HTML pages exposed another problem. String concatenation would be tedious, while a conventional dynamic template engine would lose some of Zig’s compile-time guarantees.
The desired template system had four properties: typed inputs, direct writer output, safe interpolation by default, and markup that still feels like HTML. Inspiration came from JSX-style markup in Ziex and the generated-code approach in Go’s Templ. zt adapts those ideas to Zig’s build system and type checker.
The key distinction is when a template becomes executable. A runtime engine parses or interprets template instructions while serving a page. zt’s build step parses .zt, emits .zig, and hands the result to Zig’s compiler. The rendered page still depends on runtime data; the shape of the rendering program has already been checked.
A page that knows its data type
A template can import application types and declare typed parameters. This compact example shows the main idea:
const User = @import("../models.zig").User;
pub templ Greeting(user: ?User) {
if (user) |person| {
<h1>Welcome back, {person.name}!</h1>
} else {
<h1>Welcome, guest!</h1>
}
}
The file is markup, but user is a Zig optional value, the if is Zig control flow, and person.name must be a real field of User. The template does not receive an untyped dictionary and hope that a key named name exists later.
Suppose a model refactor renames name to display_name. The generated Zig still refers to person.name, so the build fails. The caller is checked too: passing a string to a template that expects ?User is a type error. The error points into generated code, with a comment that maps the line back to its .zt source. That is less polished than a compiler diagnostic pointing directly into the template, but it keeps the mistake in the build rather than a rarely visited page.
There is a useful boundary here. Types can verify that a field exists and that an expression can be rendered. They cannot verify that the welcome message is correct, that the page is accessible, or that a link points to a real destination. The browser still needs testing.
From source file to HTTP response
The project’s build integration registers .zt files with zt.addTemplates. It emits corresponding Zig files, then makes the application’s executable depend on that generation step. Application code imports the generated file and calls its render function with an argument tuple and a writer.
At the application boundary, the shape is straightforward:
const pages = @import("templates/pages.zig");
try pages.Greeting.render(.{user}, writer);
The output target can be a response writer, a socket-backed writer, or a buffer when a complete string is actually needed. zt does not first build a tree of HTML nodes or allocate a page-sized intermediate string. Its generated rendering code writes chunks directly to std.Io.Writer. That is a useful fit for server-rendered pages and fragments.
“No allocations” describes the template rendering path, not a magical guarantee for the entire application. Fetching records, formatting a domain value, buffering I/O, or constructing a response may allocate elsewhere. Writer errors also still need correct handling. Lalinský specifically notes a cancellation and error-handling trap in a minimal std.Io example; Dusty’s response helper wraps that pattern.
HTML stays visible while Zig handles decisions
Templates can use ordinary elements, attributes, expressions, and Zig control flow. An optional value can control whether a section is present. A for loop can render a list. A switch can render different status states. Inline expressions handle small choices, such as a label that changes between “Pro” and “Free.”
Composition uses template calls. A layout can declare @children; a page invokes that layout with a nested block. A button or navigation template can be reused like a function. Components can also be passed as zt.Component values when the caller needs to choose a body dynamically.
This replaces some features that other engines call partials, inheritance, or slots. The important part is that layout composition remains visible in the call structure:
pub templ Layout(title: []const u8) {
<html>
<head><title>{title}</title></head>
<body>
<main>
@children
</main>
</body>
</html>
}
pub templ HomePage(user: ?User) {
@Layout("Home") {
<h1>Home</h1>
<p>Welcome to the site.</p>
}
}
For an htmx-style application, that can mean rendering full pages for navigation and small HTML fragments for interactions, using the same typed components. It does not require a client-side SPA to make the server output feel responsive.
Escaping is a default, with explicit escape hatches
The most consequential template default is that dynamic values are escaped in text and attribute positions. A comment containing <script> should display as text instead of becoming a new element. Dynamic href and src values receive checks for dangerous URL schemes as well. The parser also requires properly closed elements.
Raw markup remains possible with {!value}. That is necessary for some applications, but it shifts responsibility back to the developer. zt also supports a formatHtml method for values that deliberately emit HTML; that method must produce safe markup itself. These are review points, not ordinary interpolation.
The default rules reduce common cross-site scripting mistakes. They do not prove the whole page safe. HTML can be used in many contexts, including scripts, styles, URLs, and event handlers, and each context has different rules. The OWASP XSS guidance is a useful reminder that escaping at one boundary is part of a broader defense. Applications still need sensible content policies, trusted data handling, and tests for their specific output paths.
zt also catches a subtle HTML attribute error: a plain dynamic boolean is rejected where a string value would be misleading. In HTML, checked="false" still means checked because the attribute is present. For aria-expanded or similar attributes that expect text, the caller can explicitly choose the strings "true" and "false".
The cost of compiling templates
The compiler-first design changes where work and complexity live. Every template must participate in zig build, so generated sources and the build graph become part of the development workflow. An error in .zt may surface through generated .zig, and the source comment helps trace it back. A dynamic runtime engine might allow editing templates without rebuilding; zt chooses build-time feedback and direct runtime execution instead.
It also has a syntax choice still under discussion. At block level, a line beginning with if, for, or switch is parsed as Zig control flow. That can make ordinary text such as “if you forgot your password” ambiguous when it begins a line. Wrapping the text in an element or writing it as an expression works today. The project is considering @if, @for, and @switch markers to remove the ambiguity, at the price of more visible syntax. The open design issue is a sign that the language is still settling.
zt currently documents support for Zig 0.16 and 0.17. That is a useful starting point, but a young template compiler should still be evaluated against the specific Zig version, build setup, HTML patterns, and security requirements of a real application. The project’s examples are the shortest path to testing the actual integration.
When this approach is a good fit
zt is especially attractive when a Zig service already owns the HTTP response and wants server-rendered HTML or htmx fragments. Typed parameters catch model drift, direct writer output avoids a separate page assembly layer, and familiar markup keeps the template readable. Layouts and components cover common reuse without introducing an unrelated scripting language.
The broader idea extends beyond Zig: if templates are code generation inputs, the application’s compiler can become the template validator. That does not remove the need to inspect rendered pages. It makes one class of broken pages and unsafe interpolations harder to ship, while keeping the runtime path simple enough to understand.