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.
htmx middleware
Section titled “htmx middleware”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), },});Configuration
Section titled “Configuration”The htmx middleware accepts a HtmxConfig struct:
| Option | Type | Default | Description |
|---|---|---|---|
set_assigns | bool | true | Automatically 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 versionzzz.htmx(.{ .htmx_cdn_version = "1.9.0" })Assigns set by the middleware
Section titled “Assigns set by the middleware”When set_assigns is true (the default), the middleware sets these assigns on every request:
| Assign | Value | Description |
|---|---|---|
is_htmx | "true" or "false" | Whether the request has an HX-Request header |
htmx_target | Header value or absent | The HX-Target header value, if present |
htmx_trigger | Header value or absent | The HX-Trigger request header value, if present |
htmx_script | <script> tag | A 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 }}Detecting htmx requests
Section titled “Detecting htmx requests”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".
htmx response headers
Section titled “htmx response headers”zzz provides helper methods on Context for setting htmx response headers:
| Method | Header set | Purpose |
|---|---|---|
ctx.htmxTrigger(event) | HX-Trigger | Trigger a client-side event after the response is processed |
ctx.htmxTriggerAfterSwap(event) | HX-Trigger-After-Swap | Trigger an event after the swap completes |
ctx.htmxTriggerAfterSettle(event) | HX-Trigger-After-Settle | Trigger an event after the settle phase |
ctx.htmxRedirect(url) | HX-Redirect | Client-side redirect (full page navigation) |
ctx.htmxPushUrl(url) | HX-Push-Url | Push a URL into the browser history |
ctx.htmxReswap(strategy) | HX-Reswap | Override the swap strategy for this response |
ctx.htmxRetarget(selector) | HX-Retarget | Override the target element for this response |
ctx.htmxScriptTag() | — | Returns the htmx CDN <script> tag string |
Example: triggering events
Section titled “Example: triggering events”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");}Partial rendering
Section titled “Partial rendering”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, });}Including the htmx script
Section titled “Including the htmx script”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>Patterns
Section titled “Patterns”Click-to-load counter
Section titled “Click-to-load counter”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");}Search-as-you-type
Section titled “Search-as-you-type”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);}CRUD list with htmx
Section titled “CRUD list with htmx”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, });}Full page vs. fragment routing
Section titled “Full page vs. fragment routing”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, }); }}Defining routes for htmx
Section titled “Defining routes for htmx”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),});Next steps
Section titled “Next steps”- Templates Overview — introduction to the template engine
- Template Syntax — variable interpolation, conditionals, loops
- Layouts and Partials — layout wrapping and partial includes
- Pipes and Helpers — transform values with pipe filters