Skip to content

htmx Integration

zzz provides first-class support for htmx through a middleware that detects htmx requests, context helpers that set htmx response headers, and partial rendering for returning HTML fragments without a layout wrapper.

The htmx middleware auto-detects htmx requests and sets template assigns that you can use in your templates.

Add the htmx middleware to your router:

const zzz = @import("zzz");
const App = zzz.Router.define(.{
.middleware = &.{zzz.htmx(.{})},
.routes = &.{
zzz.Router.get("/", index),
},
});

The htmx middleware accepts a HtmxConfig struct:

OptionTypeDefaultDescription
set_assignsbooltrueAutomatically set assigns for htmx request headers
htmx_cdn_version[]const u8"2.0.4"htmx version for the auto-generated CDN script tag
// Use a specific htmx version
zzz.htmx(.{ .htmx_cdn_version = "1.9.0" })

When set_assigns is true (the default), the middleware sets these assigns on every request:

AssignValueDescription
is_htmx"true" or "false"Whether the request has an HX-Request header
htmx_targetHeader value or absentThe HX-Target header value, if present
htmx_triggerHeader value or absentThe HX-Trigger request header value, if present
htmx_script<script> tagA CDN script tag for the configured htmx version

You can access these assigns in your handlers:

fn handler(ctx: *zzz.Context) !void {
const is_htmx = ctx.getAssign("is_htmx") orelse "false";
if (std.mem.eql(u8, is_htmx, "true")) {
// htmx request -- return a fragment
} else {
// Full page request -- return with layout
}
}

The Context object provides a convenience method to check if the current request was made by htmx:

fn handler(ctx: *zzz.Context) !void {
if (ctx.isHtmx()) {
// Return a partial HTML fragment
try ctx.renderPartial(CounterPartial, .ok, .{ .count = "5" });
} else {
// Return a full page with layout
try ctx.renderWithLayout(AppLayout, PageContent, .ok, .{
.title = "My Page",
});
}
}

ctx.isHtmx() returns true when the HX-Request header is present and equals "true".

zzz provides helper methods on Context for setting htmx response headers:

MethodHeader setPurpose
ctx.htmxTrigger(event)HX-TriggerTrigger a client-side event after the response is processed
ctx.htmxTriggerAfterSwap(event)HX-Trigger-After-SwapTrigger an event after the swap completes
ctx.htmxTriggerAfterSettle(event)HX-Trigger-After-SettleTrigger an event after the settle phase
ctx.htmxRedirect(url)HX-RedirectClient-side redirect (full page navigation)
ctx.htmxPushUrl(url)HX-Push-UrlPush a URL into the browser history
ctx.htmxReswap(strategy)HX-ReswapOverride the swap strategy for this response
ctx.htmxRetarget(selector)HX-RetargetOverride the target element for this response
ctx.htmxScriptTag()Returns the htmx CDN <script> tag string
fn increment(ctx: *zzz.Context) !void {
const raw = ctx.param("count") orelse "0";
const current = std.fmt.parseInt(u32, raw, 10) catch 0;
var buf: [16]u8 = undefined;
const next_str = std.fmt.bufPrint(&buf, "{d}", .{current + 1}) catch "1";
try ctx.renderPartial(CounterPartial, .ok, .{ .count = next_str });
ctx.htmxTrigger("counterUpdated");
}

Use ctx.renderPartial to render a template without wrapping it in a layout. This is the standard approach for htmx fragment responses:

const CounterPartial = zzz.template(
@embedFile("../templates/partials/counter.html.zzz"),
);
fn increment(ctx: *zzz.Context) !void {
try ctx.renderPartial(CounterPartial, .ok, .{
.count = "42",
});
}

For templates that include partials, use templateWithPartials:

const TodoListPartial = zzz.templateWithPartials(
@embedFile("../templates/partials/todo_list.html.zzz"),
.{ .todo_item = @embedFile("../templates/partials/todo_item.html.zzz") },
);
fn addTodo(ctx: *zzz.Context) !void {
// ... add the todo ...
try ctx.renderPartial(TodoListPartial, .ok, .{
.has_items = true,
.items = items,
});
}

The htmx middleware generates a CDN script tag assign. Use it in your layout via a named yield to include htmx only on pages that need it:

Layout (layout.html.zzz):

<head>
<title>{{title}}</title>
{{{yield_head}}}
</head>

Handler:

fn htmxPage(ctx: *zzz.Context) !void {
try ctx.renderWithLayoutAndYields(AppLayout, PageContent, .ok, .{
.title = "Interactive Page",
}, .{
.head = ctx.htmxScriptTag(),
});
}

The htmxScriptTag() method returns a string like:

<script src="https://unpkg.com/htmx.org@2.0.4" crossorigin="anonymous"></script>

A counter that increments on each button click, replacing the counter fragment in place.

Template (partials/counter.html.zzz):

<p>Count: {{count}}</p>
<input type="hidden" name="count" value="{{count}}">
<button hx-post="/htmx/increment"
hx-target="#counter"
hx-swap="innerHTML"
hx-include="closest div">
Increment
</button>

Page template (htmx_demo.html.zzz):

<h1>{{title}}</h1>
<div id="counter" hx-headers='{"X-CSRF-Token": "{{csrf_token}}"}'>
{{> counter}}
</div>

Controller:

const zzz = @import("zzz");
const home = @import("home.zig");
const AppLayout = home.AppLayout;
const HtmxDemoContent = zzz.templateWithPartials(
@embedFile("../templates/htmx_demo.html.zzz"),
.{ .counter = @embedFile("../templates/partials/counter.html.zzz") },
);
const CounterPartial = zzz.template(
@embedFile("../templates/partials/counter.html.zzz"),
);
fn htmxDemo(ctx: *zzz.Context) !void {
const csrf_token = ctx.getAssign("csrf_token") orelse "";
try ctx.renderWithLayoutAndYields(AppLayout, HtmxDemoContent, .ok, .{
.title = "htmx Demo",
.count = "0",
.csrf_token = csrf_token,
}, .{
.head = ctx.htmxScriptTag(),
});
}
fn htmxIncrement(ctx: *zzz.Context) !void {
const raw = ctx.param("count") orelse "0";
const current = std.fmt.parseInt(u32, raw, 10) catch 0;
var buf: [16]u8 = undefined;
const next_str = std.fmt.bufPrint(&buf, "{d}", .{current + 1}) catch "1";
try ctx.renderPartial(CounterPartial, .ok, .{ .count = next_str });
ctx.htmxTrigger("counterUpdated");
}

Use hx-trigger with a debounce to send a request as the user types:

<input type="text" name="name" placeholder="Enter your name"
hx-get="/htmx/greeting"
hx-target="#greeting"
hx-trigger="keyup changed delay:300ms"
hx-include="this">
<div id="greeting"><p>Type your name above...</p></div>

The handler reads the query parameter and returns a fragment:

fn htmxGreeting(ctx: *zzz.Context) !void {
const raw_name = ctx.param("name") orelse "stranger";
const name = zzz.urlDecode(ctx.allocator, raw_name) catch raw_name;
var buf: [256]u8 = undefined;
const body = std.fmt.bufPrint(&buf, "<p>Hello, <strong>{s}</strong>!</p>", .{name}) catch "<p>Hello!</p>";
ctx.html(.ok, body);
}

A todo list with add and delete operations that update in place.

Page template (htmx_todos.html.zzz):

<h1>{{title}}</h1>
<div hx-headers='{"X-CSRF-Token": "{{csrf_token}}"}'>
<form hx-post="/todos" hx-target="#todo-list" hx-swap="innerHTML"
hx-on::after-request="this.reset()">
<input type="text" name="text" placeholder="Add a todo..." required>
<button type="submit">Add</button>
</form>
<div id="todo-list">
{{#if has_items}}<ul>
{{#each items}}{{> todo_item}}{{/each}}
</ul>{{else}}<p>No todos yet. Add one above!</p>{{/if}}
</div>
</div>

Item partial (partials/todo_item.html.zzz):

<li id="todo-{{id}}">
<span>{{text}}</span>
<button hx-delete="/todos/{{id}}" hx-target="#todo-list" hx-swap="innerHTML">
Delete
</button>
</li>

List partial (partials/todo_list.html.zzz):

{{#if has_items}}<ul>
{{#each items}}{{> todo_item}}{{/each}}
</ul>{{else}}<p>No todos yet. Add one above!</p>{{/if}}

Both the add and delete handlers return the updated list fragment using renderPartial:

fn htmxTodoAdd(ctx: *zzz.Context) !void {
// ... add the item to storage ...
const items = getItems();
try ctx.renderPartial(TodoListPartial, .ok, .{
.has_items = items.len > 0,
.items = items,
});
}
fn htmxTodoDelete(ctx: *zzz.Context) !void {
// ... remove the item from storage ...
const items = getItems();
try ctx.renderPartial(TodoListPartial, .ok, .{
.has_items = items.len > 0,
.items = items,
});
}

A common pattern is to serve the full page on initial load and only the fragment on htmx requests:

fn dashboard(ctx: *zzz.Context) !void {
if (ctx.isHtmx()) {
try ctx.renderPartial(DashboardContent, .ok, .{
.stats = stats,
});
} else {
try ctx.renderWithLayout(AppLayout, DashboardContent, .ok, .{
.title = "Dashboard",
.stats = stats,
});
}
}

Register both the page routes and the htmx action routes together using a controller:

pub const ctrl = zzz.Controller.define(.{}, &.{
zzz.Router.get("/htmx", htmxDemo),
zzz.Router.post("/htmx/increment", htmxIncrement),
zzz.Router.get("/htmx/greeting", htmxGreeting),
zzz.Router.get("/todos", htmxTodos),
zzz.Router.post("/todos", htmxTodoAdd),
zzz.Router.delete("/todos/:id", htmxTodoDelete),
});