Spider's template engine renders .html and .md files server-side via c.view(). Templates are embedded at compile time with @embedFile — no JavaScript build step, no dependencies.
At build time, generate-templates scans src/ and embeds all .html and .md files into a single Zig struct:
// src/embedded_templates.zig — auto-generated, do not edit
pub const EmbeddedTemplates = struct {
index: []const u8 = @embedFile("index.html"),
layout_docs: []const u8 = @embedFile("layout_docs.html"),
};
Spider loads this struct at startup. Templates are resolved by name at request time via c.view().
1. Create src/index.html:
<!DOCTYPE html>
<html>
<head><title>Spider App</title></head>
<body>
<h1>Hello, { name }!</h1>
</body>
</html>
2. Create the handler in src/main.zig:
const spider = @import("spider");
pub fn index(c: *spider.Ctx) !spider.Response {
return c.view("index", .{ .name = "Seven" }, .{});
}
3. Register the route:
var server = spider.app();
server.get("/", index);
server.listen(.{ .port = 8080 }) catch {};
Visit http://localhost:8080/ — you'll see Hello, Seven!.
Use { variable } to render values from your handler context:
<h1>{ title }</h1>
<p>Welcome, { user_name }!</p>
<p>Items in cart: { count }</p>
pub fn page(c: *spider.Ctx) !spider.Response {
return c.view("page", .{
.title = "Dashboard",
.user_name = "Alice",
.count = "42",
}, .{});
}
Missing variables render as empty string — no errors, no crashes.
Use if (condition) { } to conditionally render content:
Basic condition:
if (is_admin) {
<a href="/admin">Admin Panel</a>
}
Negation:
if (!is_guest) {
<p>Welcome back!</p>
}
Else branch:
if (logged_in) {
<a href="/logout">Logout</a>
} else {
<a href="/login">Login</a>
}
Else if chain:
if (role == "admin") {
<li>Admin Panel</li>
} else if (role == "moderator") {
<li>Moderator Tools</li>
} else {
<li>Standard User</li>
}
Comparison operators: ==, !=, <, <=, >, >=
if (age >= 18) {
<p>You can vote!</p>
}
List length:
if (users.len > 0) {
<p>{ users.len } users found</p>
}
Logical AND / OR:
if (is_admin or is_moderator) {
<button>Moderate</button>
}
if (is_logged_in and is_premium) {
<p>Premium features unlocked!</p>
}
Boolean strings "true", "1", "yes" evaluate to true. "false", "0", "no" evaluate to false. Empty string and missing variables evaluate to false.
Use for (items) |item| { } to iterate over a slice:
Template:
<ul>
for (items) |item| {
<li>{ item.value }</li>
}
</ul>
Handler:
const Item = struct { value: []const u8 };
pub fn data(c: *spider.Ctx) !spider.Response {
const items = &[_]Item{
.{ .value = "Lunas" },
.{ .value = "Maylla" },
};
return c.view("data", .{ .items = items }, .{});
}
Result:
<ul>
<li>Lunas</li>
<li>Maylla</li>
</ul>
Use { expr ?? "default" } to provide a fallback value when a variable is empty or missing:
<h1>{ title ?? "Default Title" }</h1>
<p>{ description ?? "No description available" }</p>
Quoted strings as body content (e.g., if (x) { "my-class" } else { "other" }) are emitted as plain text without quotes.
Use layouts to share structure across pages. The first line of a template must be extends "layout_name".
src/shared/templates/layout.html:
<!DOCTYPE html>
<html>
<head><title>{ title ?? "Spider App" }</title></head>
<body>
<nav>...</nav>
<main>
{ slot }
</main>
</body>
</html>
src/features/home/views/index.html:
extends "layout"
<h1>Welcome!</h1>
<p>This page uses the layout above.</p>
The { slot } placeholder in the layout is replaced with the child template's content.
Spider supports multiple layouts for different routes:
1. Create a custom layout: src/shared/templates/layout_docs.html → normalizes to layout_docs
2. Use extends in templates:
extends "layout_docs"
<h1>Docs Page</h1>
<p>This uses the docs-specific layout.</p>
Create reusable components in shared/templates/. They are automatically available as PascalCase in any template.
src/shared/templates/site-nav.html:
<nav class="site-nav">
<a href="/">Home</a>
<a href="/docs">Docs</a>
</nav>
Normalizes to site_nav. Use in any template as:
<SiteNav />
The template engine converts <SiteNav /> to site_nav automatically (PascalCase → snake_case).
Components can have named slots for injecting content:
Component with slot:
<div class="card">
{ slot_header }
<div class="card-body">
{ slot }
</div>
</div>
Using the component:
<Card>
<h2 slot="header">Title</h2>
Body content goes here
</Card>
Templates ending with .md (or starting with <!-- md -->) are automatically converted from Markdown to HTML.
src/features/docs/views/quickstart.md:
<!-- md -->
# Quick Start
This is **markdown** and it gets converted to HTML automatically.
- Item 1
- Item 2
Use c.view("docs/quickstart", .{}, .{}) — Spider detects the <!-- md --> signature and converts markdown to HTML before rendering.
Spider has two template modes. Both produce byte-identical output — the only difference is when and where templates are loaded.
Templates are compiled into the binary — no files needed at runtime, ideal for Docker and production deployments.
Declare in main.zig or root.zig (must be in the root file of the executable):
pub const spider_templates = @import("embedded_templates.zig").EmbeddedTemplates;
Spider detects this via @hasDecl(@import("root"), "spider_templates") — same pattern as std_options in the Zig stdlib.
Reads templates from disk at request time. Useful in development. Just don't declare spider_templates — Spider will scan views_dir automatically.
Runtime mode requires spider.config.zig.
Without it, Spider uses views_dir = "./views" as default, which rarely matches the actual project structure and causes TemplateNotFound errors.
Create spider.config.zig in your project root:
const spider = @import("spider");
pub const config = spider.Config{
.views_dir = "./src", // point to where your .html/.md files live
.layout = "layout",
.env = .development,
.port = 3000,
.host = "0.0.0.0",
};
Spider prints warnings to help diagnose configuration issues:
[spider] WARNING: views_dir "./views" not found.
[spider] Templates will not load in runtime mode.
[spider] Check your spider.config.zig -> views_dir setting.
[spider] WARNING: No templates found in "./views".
[spider] Make sure your .html/.md files are inside views_dir.
[spider] Check your spider.config.zig -> views_dir setting.
[spider] runtime templates: 5 loaded from "./src"
Both modes apply the same normalization rules. The name passed to c.view() is normalized the same way in both modes:
| File path (relative to views_dir) | Normalized name | Call with |
|---|---|---|
| views/bills/index.html | bills_index | c.view("bills/index", ...) |
| views/home/index.html | home_index | c.view("home/index", ...) |
| shared/templates/layout.html | layout | layout (auto, via config) |
| shared/templates/Card.html | Card | c.view("Card", ...) |
| shared/templates/site-nav.html | site_nav | <SiteNav /> in templates |
Rules: strip extension → use segment after views/ or templates/ → replace / and - with _.
Common mistake: calling c.view("index", ...) when the file is at views/bills/index.html.
The correct call is c.view("bills/index", ...) which normalizes to bills_index.
In embed mode, templates are inside the binary — no extra files needed:
FROM <zig-image>:master AS builder
WORKDIR /app
COPY . .
RUN zig build -Doptimize=ReleaseSmall
FROM debian:bookworm-slim
WORKDIR /app
COPY --from=builder /app/zig-out/bin/<app> /app/<app>
COPY --from=builder /app/public /app/public
EXPOSE 3000
CMD ["./<app>"]
In runtime mode, templates must be copied into the container alongside the binary:
FROM <zig-image>:master AS builder
WORKDIR /app
COPY . .
RUN zig build -Doptimize=ReleaseSmall
FROM debian:bookworm-slim
WORKDIR /app
COPY --from=builder /app/zig-out/bin/<app> /app/<app>
COPY --from=builder /app/public /app/public
COPY --from=builder /app/src /app/src
COPY --from=builder /app/spider.config.zig /app/spider.config.zig
EXPOSE 3000
CMD ["./<app>"]
Add to build.zig to automatically embed all .html and .md files at build time:
const gen = b.addRunArtifact(spider_dep.artifact("generate-templates"));
gen.addArg("src/");
gen.addArg("src/embedded_templates.zig");
exe.step.dependOn(&gen.step);
Files are discovered automatically — add a new .html or .md file, rebuild, and it's available by name via c.view().
For Spider to read your spider.config.zig, register it as an anonymous import on spider_mod. Spider's build.zig provides a default config fallback — your project overrides it:
const spider_dep = b.dependency("spider", .{ .target = target });
const spider_mod = spider_dep.module("spider");
// Register spider.config.zig if it exists — overrides Spider's default config
const config_exists = blk: {
std.Io.Dir.cwd().access(b.graph.io, "spider.config.zig", .{}) catch break :blk false;
break :blk true;
};
if (config_exists) {
spider_mod.addAnonymousImport("spider_config", .{
.root_source_file = b.path("spider.config.zig"),
});
}
> Note: Use b.graph.io to check file existence in build.zig — this is the correct Zig 0.17 API. b.pathExists() does not exist and std.fs.cwd().access() is the old API.
| Tag | Syntax | Description |
|-----|--------|-------------|
| Interpolation | { variable } | Render variable value |
| Coalescing | { var ?? "default" } | Fallback for empty/missing values |
| Conditional | if (condition) { ... } | Conditional block |
| Else if | } else if (condition) { | Else-if branch |
| Else | } else { | Else branch |
| For loop | for (items) \|item\| { ... } | Iterate over slice with capture |
| Extends | extends "layout" | Use a layout (first line only) |
| Slot | { slot } | Placeholder in layout/component |
| Named slot | { slot_header } | Named placeholder |
| Component | <ComponentName /> | Self-closing PascalCase component |
| Component with slot | <ComponentName>...</ComponentName> | PascalCase component with slot content |