A Zig library for constructing DOM trees as pure values (no internal allocations) and rendering them to a writer or to a string.
Build type-safe, reusable HTML components using functions and enums — no template files.
- 🔧 No template files — Build HTML programmatically with full type safety
- 🛡️ Automatic HTML escaping — Text content is escaped to prevent XSS
- 🌳 Recursive rendering — Nested components render seamlessly
- 📦 Memory efficient — Direct-to-writer rendering avoids intermediate strings
- 🎯 Simple, value-based API — Nodes are plain structs, constructed from slices
Add ZigDOM to your build.zig.zon dependencies (using Zig's package manager):
.dependencies = .{
.zigdom = .{
// Use `zig fetch --save https://github.com/exastencil/zigdom/archive/refs/heads/main.tar.gz`
// then copy the generated .hash here
.url = "https://github.com/exastencil/zigdom/archive/refs/heads/main.tar.gz",
.hash = "...",
},
};Then in your build.zig:
const zigdom_dep = b.dependency("zigdom", .{ .target = target, .optimize = optimize });
exe.root_module.addImport("zigdom", zigdom_dep.module("zigdom"));Minimum Zig version: see build.zig.zon (currently 0.15.0).
const std = @import("std");
const zigdom = @import("zigdom");
const dom = zigdom.dom;
const tags = zigdom.tags;
const attr = dom.attr;
const text = dom.text;
pub fn main() !void {
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
defer _ = gpa.deinit();
const allocator = gpa.allocator();
// Build a simple HTML structure as pure values
const page = tags.div(&.{ attr("class", "container") }, &.{
tags.p(&.{}, &.{ text("Hello, World!") }),
});
// Render to stdout (no allocation in ZigDOM)
const stdout = std.io.getStdOut().writer();
try page.render(stdout);
try stdout.writeAll("\n");
// Or render to a string (allocates; you free it)
const html = try page.renderToString(allocator);
defer allocator.free(html);
}ZigDOM supports the following node types via dom.Tag:
- Elements (e.g. div, p, img, svg, ...)
- Text
- Document (root container)
- Fragment (invisible container)
- Custom elements (by tag name)
Note: There is currently no separate Comment node.
Use helpers from dom and tags:
const dom = zigdom.dom;
const tags = zigdom.tags;
const attr = dom.attr;
const text = dom.text;
// Element via tag enum
const el = dom.tag(.div, &.{ attr("class", "box") }, &.{});
// Nicer syntax via generated tag functions
const div = tags.div(&.{ attr("class", "box") }, &.{});
// Text node
const t = text("Hello!");
// Fragment
const frag = dom.tag(.fragment, &.{}, &.{ div, t });
// Custom element
const custom = dom.custom("my-widget", &.{ attr("data-id", "123") }, &.{ text("Content") });Nodes are plain values you construct with attributes and children slices:
const card = tags.div(
&.{ attr("class", "card") },
&.{
tags.h2(&.{}, &.{ text("Title") }),
tags.p(&.{}, &.{ text("Body text") }),
},
);Void elements (like img, br, hr, ...) are handled automatically; they render without a closing tag. There is no self_closing flag to set.
// Render to any writer (no allocation inside ZigDOM)
try node.render(writer);
// Render to a string (allocates; you must free)
const html = try node.renderToString(allocator);
defer allocator.free(html);- Construction:
- Nodes are pure values; creating nodes does not allocate.
- Attributes (
dom.Attribute) and children are provided as slices you own. - Text nodes keep a slice of your provided bytes.
- Lifetimes:
- Any slices you pass (attribute names/values, text content, custom tag names, children arrays) must remain valid until rendering is complete.
- String literals are fine. If you build strings dynamically, keep their backing memory alive until after
render/renderToStringfinishes.
- Rendering:
render(writer)does not allocate within ZigDOM.renderToString(allocator)allocates a buffer and returns an owned slice that you must free with the same allocator.
- Destruction:
- There is no
deinitforNode; simply let values go out of scope. Only free what you allocated (e.g. strings you created and the result ofrenderToString).
- There is no
You can create reusable component functions that return dom.Node values:
const std = @import("std");
const zigdom = @import("zigdom");
const dom = zigdom.dom;
const tags = zigdom.tags;
const attr = dom.attr;
const text = dom.text;
const Node = dom.Node;
fn Card(title: []const u8, content: []const u8) Node {
return tags.div(&.{ attr("class", "card") }, &.{
tags.h2(&.{}, &.{ text(title) }),
tags.p(&.{}, &.{ text(content) }),
});
}zig build testzig build runContributions are welcome! Please feel free to submit a Pull Request.