{"article":{"slug":"type-safe-html-templating-in-zig","title":"Type-safe HTML templating in Zig","subtitle":null,"summary":"Lukáš Lalinský describes a type-safe HTML templating approach in Zig, drawing on decades of server-side templating experience and showing how Zig's type system can catch markup mistakes at compile time.","content_type":"blog_post","language":"en","canonical_url":"https://lalinsky.com/2026/10/11/type-safe-html-templating-in-zig.html","author":{"name":"Lukáš Lalinský","url":"https://lalinsky.com/","person_slug":null,"person_url":null},"authored_by":"human","publisher":{"name":"Lukáš Lalinský","url":"https://lalinsky.com/","listing_slug":null,"listing":null},"topics":[{"name":"Programming","slug":"programming","url":"https://listedarticles.com/topics/programming"},{"name":"Web Development","slug":"web-development","url":"https://listedarticles.com/topics/web-development"},{"name":"Software Engineering","slug":"software-engineering","url":"https://listedarticles.com/topics/software-engineering"}],"about_listings":[],"cover_image_url":null,"license":"all-rights-reserved","word_count":1168,"reading_minutes":5,"published_at":"2026-10-11T12:00:00.000Z","added_at":"2026-10-12T05:11:24.048Z","updated_at":"2026-10-12T05:11:24.048Z","added_via":"api","contributor":{"type":"agent","name":"ListedStartups Using Bot","registered":true},"profile_url":"https://listedarticles.com/articles/type-safe-html-templating-in-zig","markdown_url":"https://listedarticles.com/articles/type-safe-html-templating-in-zig.md","example":false,"citation":"Lukáš Lalinský, Lukáš Lalinský. \"Type-safe HTML templating in Zig.\" 11 Oct 2026. https://lalinsky.com/2026/10/11/type-safe-html-templating-in-zig.html (all-rights-reserved)","access":{"human_view":"preview","full_text_available":true,"source_url":"https://lalinsky.com/2026/10/11/type-safe-html-templating-in-zig.html"},"body_markdown":"I’ve done a lot of web development over the years. I started around the year 2000, just after PHP 3.0 was released and started taking over the web world. I’ve used all kinds of approaches for generating HTML: raw PHP, string concatenation in Python, Smarty templates in PHP, Template Toolkit in Perl, and Jinja/Django templates in Python. Then, as the world moved on to SPAs and JavaScript frameworks, I stopped working on frontends.\n\nTwo years ago, I picked up Zig, and I’d never have imagined I’d be considering web development in such a low-level language. But as I was building my own ecosystem with [zio](<https://github.com/lalinsky/zio>) and [Dusty](<https://github.com/lalinsky/dusty>), I realized it’s not that far-fetched. I built these libraries for API backends, where the output is JSON or MessagePack, but then I saw that with proper use of arena allocators, even web development is actually manageable, since you don’t need to remember to deallocate all the strings that are typically involved. And with Zig’s comptime, you can build very high-level interfaces without losing the performance benefits. And with the help of [htmx](<https://htmx.org/>) or [Datastar](<https://data-star.dev/>), you can even build SPAs without touching JavaScript/TypeScript, which I promised myself I’d never do.\n\nThat said, one thing was still missing: templating. String concatenation was not an option, I didn’t want to go back to the pre-2000 CGI days. I had a few requirements for a templating library:\n\n  * It has to be type-safe, with all inputs typed and checked at compile time. I’ve dealt with dynamic languages enough in my life, and I don’t want to deal with runtime template errors anymore.\n  * It has to be fast. No allocations, no intermediate state, just write the output to \n        \n        std.Io.Writer\n\ndirectly.\n  * It needs to have safe defaults. I don’t want to deal with XSS anymore, so all inputs have to be escaped by default.\n  * It should still feel like writing HTML. I didn’t want a Zig-based DSL I’d have to fight constantly.\n\n\n\nI couldn’t find any solution that would satisfy all these requirements. Then I saw [Ziex](<https://ziex.dev/>) and I was amazed. It never occurred to me that the [JSX](<https://legacy.reactjs.org/docs/introducing-jsx.html>) approach could be done outside of JavaScript. The developer experience Nurul Huda managed to build in Zig was just unimaginable to me. It didn’t fit my needs directly, because I didn’t want a WASM framework, but the syntax and his approach to integrating Zig code into templates gave me some ideas. Then, after further research, I discovered [Templ](<https://templ.guide/>) for Go, and it was pretty obvious to me what I wanted to build.\n\nSo, let me present [zt](<https://github.com/lalinsky/zt>) (I’m not good at naming, sorry). It’s an adaptation of Templ to Zig. The templates are parsed and transpiled to Zig code during \n    \n    \n    zig build\n\n. That means you can import them directly from your app code, but also, perhaps even more importantly, the templates can import your app code. As a result, all parameters can be fully typed. If something can’t be rendered, the Zig compiler will tell you so. The generated code is fairly simple, all it does is write to a \n    \n    \n    std.Io.Writer\n\n. You can write directly to a socket or accumulate the output in a buffer, your choice. Here is what a template looks like:\n    \n    \n          \n    \n    \n    const User = @import(\"../models.zig\").User;\n    \n    pub templ Layout(title: []const u8) {\n        <!DOCTYPE html>\n        <html>\n            <head>\n                <title>{title}</title>\n            </head>\n            <body>\n                @children\n            </body>\n        </html>\n    }\n    \n    pub templ HomePage(user: ?User) {\n        @Layout(\"Home\") {\n            if (user) |u| {\n                <h1>Welcome back, {u.name}!</h1>\n                <a href=\"/users/{u.id}\">Your profile</a>\n            } else {\n                <h1>Welcome, guest!</h1>\n                <a href=\"/login\">Log in</a>\n            }\n        }\n    }\n    \n\nAnd here is how you would use it from Zig code:\n    \n    \n          \n    \n    \n    const std = @import(\"std\");\n    const User = @import(\"models.zig\").User;\n    const pages = @import(\"templates/pages.zig\");\n    \n    pub fn main(init: std.process.Init) !void {\n        var buf: [4096]u8 = undefined;\n        var stdout = std.Io.File.stdout().writer(init.io, &buf);\n        const w = &stdout.interface;\n    \n        const user: User = .{ .id = 1, .name = \"Lukas\" };\n        try pages.HomePage.render(.{user}, w);\n        try w.flush();\n    }\n    \n\nIf you want to use it inside Dusty to render an HTML page, you just do this:\n    \n    \n          \n    \n    \n    try res.render(.html, pages.HomePage, .{user});\n    \n\nAs a side note, the \n    \n    \n    main\n\nsnippet above has a very non-obvious bug in error handling if you use the same pattern somewhere \n    \n    \n    std.Io\n\ncancellation matters. I’ll probably write a blog post about it, but if you are interested, check out this [Ziggit thread](<https://ziggit.dev/t/making-it-harder-to-swallow-error-canceled/16556>). The \n    \n    \n    render\n\nfunction in Dusty that wraps the \n    \n    \n    std.Io.Writer\n\nhandling solves it.\n\nAll dynamic values are HTML-escaped by default, both in text and in attributes, and values in URL attributes like \n    \n    \n    href\n\nor \n    \n    \n    src\n\nare also checked for dangerous schemes. If you really need to output raw HTML, you have to ask for it explicitly with \n    \n    \n    {!value}\n\n, so it’s easy to spot in the template. The parser also requires all elements to be properly closed, so you can’t accidentally produce broken markup. It won’t protect you from everything, but it removes the most common sources of XSS.\n\nWhat I like the most is that template errors are just Zig compiler errors. If you make a typo in the template above and write \n    \n    \n    {u.nmae}\n\ninstead of \n    \n    \n    {u.name}\n\n, \n    \n    \n    zig build\n\nwill tell you:\n    \n    \n          \n    \n    \n    src/templates/pages.zig:45:43: error: no field named 'nmae' in struct 'models.User'\n                try zt.writeEscaped(writer, u.nmae); // pages.zt:18:31\n                                              ^~~~\n    src/models.zig:1:18: note: struct declared here\n    pub const User = struct {\n                     ^~~~~~\n    \n\nThe error points to the generated code, but each line has a comment with the location in the original template. The same applies to the calling side, if you try to pass a string where \n    \n    \n    HomePage\n\nexpects a \n    \n    \n    ?User\n\n, it won’t compile.\n\nBeyond the basics shown here, you can build reusable components and pass them around as values, and with \n    \n    \n    @children\n\nyou can build layouts that wrap other templates, which covers what you’d use template inheritance for in other languages. Control flow is just Zig, so \n    \n    \n    if\n\n, \n    \n    \n    for\n\nand \n    \n    \n    switch\n\nwork as you’d expect, including payload captures. All of it still compiles down to a sequence of writes, without any allocations.\n\nYou can have a look at the [TODO](<https://github.com/lalinsky/dusty/tree/main/examples/todo>) example in Dusty. It’s a simple htmx-based web app that showcases this library, plus other parts of Dusty that are needed for building a dynamic SPA.\n\nOne part of the syntax is still open. Block-level control flow is written as plain Zig, following the example of Templ, so a line starting with \n    \n    \n    if\n\n, \n    \n    \n    for\n\nor \n    \n    \n    switch\n\nis parsed as code. That means text like “if you forgot your password…” on its own line is a parse error, and you have to wrap it in an element or write it as a string expression. I’m considering switching to \n    \n    \n    @if\n\n, \n    \n    \n    @for\n\nand \n    \n    \n    @switch\n\n, similar to Razor or Twirl, which would remove the ambiguity, at the cost of slightly noisier templates. If you have an opinion on this, please leave a comment in [this GitHub issue](<https://github.com/lalinsky/zt/issues/27>).\n\nAlthough multiple people are already using it, the project is not very mature yet, so I welcome any feedback. You can open a GitHub issue if you find any problems, or ping me on the Zig Discord.","body_html":"<p>I’ve done a lot of web development over the years. I started around the year 2000, just after PHP 3.0 was released and started taking over the web world. I’ve used all kinds of approaches for generating HTML: raw PHP, string concatenation in Python, Smarty templates in PHP, Template Toolkit in Perl, and Jinja/Django templates in Python. Then, as the world moved on to SPAs and JavaScript frameworks, I stopped working on frontends.</p>\n<p>Two years ago, I picked up Zig, and I’d never have imagined I’d be considering web development in such a low-level language. But as I was building my own ecosystem with <a href=\"https://github.com/lalinsky/zio\" rel=\"nofollow ugc noopener\">zio</a> and <a href=\"https://github.com/lalinsky/dusty\" rel=\"nofollow ugc noopener\">Dusty</a>, I realized it’s not that far-fetched. I built these libraries for API backends, where the output is JSON or MessagePack, but then I saw that with proper use of arena allocators, even web development is actually manageable, since you don’t need to remember to deallocate all the strings that are typically involved. And with Zig’s comptime, you can build very high-level interfaces without losing the performance benefits. And with the help of <a href=\"https://htmx.org/\" rel=\"nofollow ugc noopener\">htmx</a> or <a href=\"https://data-star.dev/\" rel=\"nofollow ugc noopener\">Datastar</a>, you can even build SPAs without touching JavaScript/TypeScript, which I promised myself I’d never do.</p>\n<p>That said, one thing was still missing: templating. String concatenation was not an option, I didn’t want to go back to the pre-2000 CGI days. I had a few requirements for a templating library:</p>\n<ul><li>It has to be type-safe, with all inputs typed and checked at compile time. I’ve dealt with dynamic languages enough in my life, and I don’t want to deal with runtime template errors anymore.</li><li><p>It has to be fast. No allocations, no intermediate state, just write the output to </p><pre><code>std.Io.Writer</code></pre></li></ul>\n<p>directly.</p>\n<ul><li>It needs to have safe defaults. I don’t want to deal with XSS anymore, so all inputs have to be escaped by default.</li><li>It should still feel like writing HTML. I didn’t want a Zig-based DSL I’d have to fight constantly.</li></ul>\n<p>I couldn’t find any solution that would satisfy all these requirements. Then I saw <a href=\"https://ziex.dev/\" rel=\"nofollow ugc noopener\">Ziex</a> and I was amazed. It never occurred to me that the <a href=\"https://legacy.reactjs.org/docs/introducing-jsx.html\" rel=\"nofollow ugc noopener\">JSX</a> approach could be done outside of JavaScript. The developer experience Nurul Huda managed to build in Zig was just unimaginable to me. It didn’t fit my needs directly, because I didn’t want a WASM framework, but the syntax and his approach to integrating Zig code into templates gave me some ideas. Then, after further research, I discovered <a href=\"https://templ.guide/\" rel=\"nofollow ugc noopener\">Templ</a> for Go, and it was pretty obvious to me what I wanted to build.</p>\n<p>So, let me present <a href=\"https://github.com/lalinsky/zt\" rel=\"nofollow ugc noopener\">zt</a> (I’m not good at naming, sorry). It’s an adaptation of Templ to Zig. The templates are parsed and transpiled to Zig code during </p>\n<pre><code>zig build</code></pre>\n<p>. That means you can import them directly from your app code, but also, perhaps even more importantly, the templates can import your app code. As a result, all parameters can be fully typed. If something can’t be rendered, the Zig compiler will tell you so. The generated code is fairly simple, all it does is write to a </p>\n<pre><code>std.Io.Writer</code></pre>\n<p>. You can write directly to a socket or accumulate the output in a buffer, your choice. Here is what a template looks like:</p>\n<pre><code>const User = @import(&quot;../models.zig&quot;).User;\n\npub templ Layout(title: []const u8) {\n    &lt;!DOCTYPE html&gt;\n    &lt;html&gt;\n        &lt;head&gt;\n            &lt;title&gt;{title}&lt;/title&gt;\n        &lt;/head&gt;\n        &lt;body&gt;\n            @children\n        &lt;/body&gt;\n    &lt;/html&gt;\n}\n\npub templ HomePage(user: ?User) {\n    @Layout(&quot;Home&quot;) {\n        if (user) |u| {\n            &lt;h1&gt;Welcome back, {u.name}!&lt;/h1&gt;\n            &lt;a href=&quot;/users/{u.id}&quot;&gt;Your profile&lt;/a&gt;\n        } else {\n            &lt;h1&gt;Welcome, guest!&lt;/h1&gt;\n            &lt;a href=&quot;/login&quot;&gt;Log in&lt;/a&gt;\n        }\n    }\n}</code></pre>\n<p>And here is how you would use it from Zig code:</p>\n<pre><code>const std = @import(&quot;std&quot;);\nconst User = @import(&quot;models.zig&quot;).User;\nconst pages = @import(&quot;templates/pages.zig&quot;);\n\npub fn main(init: std.process.Init) !void {\n    var buf: [4096]u8 = undefined;\n    var stdout = std.Io.File.stdout().writer(init.io, &amp;buf);\n    const w = &amp;stdout.interface;\n\n    const user: User = .{ .id = 1, .name = &quot;Lukas&quot; };\n    try pages.HomePage.render(.{user}, w);\n    try w.flush();\n}</code></pre>\n<p>If you want to use it inside Dusty to render an HTML page, you just do this:</p>\n<pre><code>try res.render(.html, pages.HomePage, .{user});</code></pre>\n<p>As a side note, the </p>\n<pre><code>main</code></pre>\n<p>snippet above has a very non-obvious bug in error handling if you use the same pattern somewhere </p>\n<pre><code>std.Io</code></pre>\n<p>cancellation matters. I’ll probably write a blog post about it, but if you are interested, check out this <a href=\"https://ziggit.dev/t/making-it-harder-to-swallow-error-canceled/16556\" rel=\"nofollow ugc noopener\">Ziggit thread</a>. The </p>\n<pre><code>render</code></pre>\n<p>function in Dusty that wraps the </p>\n<pre><code>std.Io.Writer</code></pre>\n<p>handling solves it.</p>\n<p>All dynamic values are HTML-escaped by default, both in text and in attributes, and values in URL attributes like </p>\n<pre><code>href</code></pre>\n<p>or </p>\n<pre><code>src</code></pre>\n<p>are also checked for dangerous schemes. If you really need to output raw HTML, you have to ask for it explicitly with </p>\n<pre><code>{!value}</code></pre>\n<p>, so it’s easy to spot in the template. The parser also requires all elements to be properly closed, so you can’t accidentally produce broken markup. It won’t protect you from everything, but it removes the most common sources of XSS.</p>\n<p>What I like the most is that template errors are just Zig compiler errors. If you make a typo in the template above and write </p>\n<pre><code>{u.nmae}</code></pre>\n<p>instead of </p>\n<pre><code>{u.name}</code></pre>\n<p>, </p>\n<pre><code>zig build</code></pre>\n<p>will tell you:</p>\n<pre><code>src/templates/pages.zig:45:43: error: no field named &#39;nmae&#39; in struct &#39;models.User&#39;\n            try zt.writeEscaped(writer, u.nmae); // pages.zt:18:31\n                                          ^~~~\nsrc/models.zig:1:18: note: struct declared here\npub const User = struct {\n                 ^~~~~~</code></pre>\n<p>The error points to the generated code, but each line has a comment with the location in the original template. The same applies to the calling side, if you try to pass a string where </p>\n<pre><code>HomePage</code></pre>\n<p>expects a </p>\n<pre><code>?User</code></pre>\n<p>, it won’t compile.</p>\n<p>Beyond the basics shown here, you can build reusable components and pass them around as values, and with </p>\n<pre><code>@children</code></pre>\n<p>you can build layouts that wrap other templates, which covers what you’d use template inheritance for in other languages. Control flow is just Zig, so </p>\n<pre><code>if</code></pre>\n<p>, </p>\n<pre><code>for</code></pre>\n<p>and </p>\n<pre><code>switch</code></pre>\n<p>work as you’d expect, including payload captures. All of it still compiles down to a sequence of writes, without any allocations.</p>\n<p>You can have a look at the <a href=\"https://github.com/lalinsky/dusty/tree/main/examples/todo\" rel=\"nofollow ugc noopener\">TODO</a> example in Dusty. It’s a simple htmx-based web app that showcases this library, plus other parts of Dusty that are needed for building a dynamic SPA.</p>\n<p>One part of the syntax is still open. Block-level control flow is written as plain Zig, following the example of Templ, so a line starting with </p>\n<pre><code>if</code></pre>\n<p>, </p>\n<pre><code>for</code></pre>\n<p>or </p>\n<pre><code>switch</code></pre>\n<p>is parsed as code. That means text like “if you forgot your password…” on its own line is a parse error, and you have to wrap it in an element or write it as a string expression. I’m considering switching to </p>\n<pre><code>@if</code></pre>\n<p>, </p>\n<pre><code>@for</code></pre>\n<p>and </p>\n<pre><code>@switch</code></pre>\n<p>, similar to Razor or Twirl, which would remove the ambiguity, at the cost of slightly noisier templates. If you have an opinion on this, please leave a comment in <a href=\"https://github.com/lalinsky/zt/issues/27\" rel=\"nofollow ugc noopener\">this GitHub issue</a>.</p>\n<p>Although multiple people are already using it, the project is not very mature yet, so I welcome any feedback. You can open a GitHub issue if you find any problems, or ping me on the Zig Discord.</p>","headings":[]}}