Learn ZX
Quick Start
Welcome to the ZX documentation! This page will give you an introduction to the core concepts you'll use daily when building web applications with ZX.
Installation
Install the ZX CLI to get started:
If you prefer not to install the CLI, you can create a project manually. See the Create a Project section for the manual setup option, or check the CLI documentation for all available commands.
Install Zig
ZX 0.1.0-dev requires Zig 0.16.0:
Create a Project
CLI: Use the CLI to quickly scaffold a new ZX project. It automatically sets up the project structure, build configuration, and template files for you.
Manual: If you prefer not to install the CLI, you can simply add the ZX dependency to your build.zig file and initialize the project manually, or use the zx build step available after configuring zx.init.
Create a new ZX project using the CLI:
Start the ZX app in development mode with hot reloading:
Open http://localhost:3000 in your browser!
First initialize a Zig project if you haven't already:
Add ZX as a dependency:
Update your build.zig to the following:
pub fn buildManual(b: *std.Build) !void {
const target = b.standardTargetOptions(.{});
const optimize = b.standardOptimizeOption(.{});
const app_exe = b.addExecutable(.{
.name = "ziex_app",
.root_module = b.createModule(.{
.root_source_file = b.path("app/main.zig"),
.target = target,
.optimize = optimize,
}),
});
_ = try ziex.init(b, app_exe, .{});
}Then initialize the project with the default template:
Start the ZX app in development mode with hot reloading:
Open http://localhost:3000 in your browser!
Project Structure
app/pages/ directory uses file-based routing. Each page.zx file becomes a route.Editor Setup
Set up your editor for the best ZX development experience.
Install the official ZX extension for the best development experience.
Install from:
- VS Code Marketplace
- Open VSX Registry (for Cursor and other forks)
Or from command palette: ext install ziex.ziex
Features: Syntax highlighting, IntelliSense, error diagnostics, bracket matching, and code folding.
With lazy.nvim, add ziex-dev/ziex as a plugin with nvim-treesitter as a dependency.
See the Neovim setup guide for the full configuration.
Features: Tree-sitter syntax highlighting, LSP support, and file icons.
See the Helix setup guide for the full configuration.
Features: Tree-sitter syntax highlighting, and LSP support.
Manual installation (pending marketplace approval):
- Clone:
git clone https://github.com/ziex-dev/ziex - Open Extensions panel (Cmd/Ctrl + Shift + P)
- Select "Install Dev Extension"
- Navigate to
ide/zedin the cloned repo
See the Zed setup guide for details.
Creating and nesting components
ZX apps are made out of components. A component is a function that returns a zx.Component type. Components can be as small as a button or as large as an entire page.
ZX components are Zig functions that return markup. Notice how <Greeting /> starts with a capital letter - that's how ZX distinguishes between custom components and HTML elements:
pub fn HelloWorld(allocator: zx.Allocator) zx.Component {
return (
<main @allocator={allocator}>
<h1>Welcome to my app</h1>
<Greeting />
</main>
);
}
fn Greeting(allocator: zx.Allocator) zx.Component {
return (<p @allocator={allocator}>Hello, World!</p>);
}<main><h1>Welcome to my app</h1><p>Hello, World!</p></main>@allocator attribute must be set on the root element of each component. Child components inherit the allocator from their parent. See the @allocator documentation for details.Writing markup with ZX
The markup syntax is called ZX - it's similar to JSX but designed for Zig. ZX files use the .zx extension and are transpiled to efficient Zig code.
ZX is stricter than HTML. You have to close all tags, including self-closing ones like <br />:
pub fn AboutSection(allocator: zx.Allocator) zx.Component {
return (
<main @allocator={allocator}>
<h1>About</h1>
<p>Hello there.<br />How are you?</p>
<AboutCard />
</main>
);
}
fn AboutCard(allocator: zx.Allocator) zx.Component {
return (
<div @allocator={allocator} class="card">
<h2>User Profile</h2>
<p>Welcome to the card component!</p>
</div>
);
}<main><h1>About</h1><p>Hello there.<br />How are you?</p><div class="card"><h2>User Profile</h2><p>Welcome to the card component!</p></div></main>CSS classes are specified with the class attribute, same as HTML. Write your CSS in separate files and include them in your layout.
ZX automatically escapes HTML content for security. If you need to render raw HTML, see the @escaping documentation.
Fragments
Sometimes you want to return multiple elements from a component without adding an extra wrapper. Use the empty tag syntax <>...</>:
pub fn FragmentDemo(allocator: zx.Allocator) zx.Component {
return (
<main @allocator={allocator}>
<>
<h1>Welcome</h1>
<p>Multiple elements without a wrapper</p>
</>
</main>
);
}<main><h1>Welcome</h1><p>Multiple elements without a wrapper</p></main>Fragments are useful when you need to group elements but don't want to add extra DOM nodes. The children are rendered directly without any wrapper element. Learn more in the Fragments documentation.
Displaying data
ZX lets you embed dynamic content using curly braces {expression}. Expressions are automatically formatted based on their type:
- Strings - displayed as text (HTML-escaped for safety)
- Numbers - formatted as decimal
- Booleans - displayed as "true" or "false"
- Enums - displayed as the tag name
- Optionals - unwrapped if present, otherwise renders nothing
pub fn UserGreeting(allocator: zx.Allocator) zx.Component {
const user_name = "Alice";
const greeting = "Welcome back";
return (
<main @allocator={allocator}>
<h1>{greeting}, {user_name}!</h1>
<p>Your profile is ready.</p>
</main>
);
}<main><h1>Welcome back, Alice!</h1><p>Your profile is ready.</p></main>Different types are automatically handled:
pub fn ProductInfo(allocator: zx.Allocator) zx.Component {
const price: f32 = 19.99;
const quantity: u32 = 3;
const is_available = true;
return (
<main @allocator={allocator}>
<p>Price: ${price}</p>
<p>Quantity: {quantity}</p>
<p>Available: {is_available}</p>
</main>
);
}<main><p>Price: $19.99</p><p>Quantity: 3</p><p>Available: true</p></main>See the Expressions documentation for detailed information on all supported types including components and component arrays.
Dynamic attributes
Use curly braces to pass dynamic values to HTML attributes:
pub fn DynamicAttrs(allocator: zx.Allocator) zx.Component {
const class_name = "primary-btn";
const user_id = "user-123";
const is_active = true;
return (
<main @allocator={allocator}>
<button class={class_name} id={user_id}>Submit</button>
<div class={if (is_active) "active" else "inactive"}>Dynamic class</div>
</main>
);
}<main><button class="primary-btn" id="user-123">Submit</button><div class="active">Dynamic class</div></main>You can use any expression, including conditionals, to compute attribute values dynamically.
Template strings
Use backticks for string interpolation in attributes:
const id = 42;
...
(<a href=`/users/{id}/profile`>Profile</a>)Spread and shorthand
Use {..struct} to spread struct fields as attributes, or {variable} as shorthand when the attribute name matches the variable:
const attrs = .{ .class = "btn", .disabled = true };
...
(<button {..attrs}>Click</button>)See the Attribute Syntax documentation for more details. ZX also provides special builtin attributes like @allocator, @escaping, and @rendering.
Adding interactivity
Interactivity can be added using native HTML attributes, you will provide a function as an handler to the event and take action upon that event.
Event handlers
Attach event handlers to elements using the on<event_name> syntax:
pub fn Interactivity(allocator: zx.Allocator) zx.Component {
return (<EventHandling @{allocator} @rendering={.client} />);
}
pub fn EventHandling(allocator: zx.Allocator) zx.Component {
return (
<main @allocator={allocator}>
<button onclick={handleClick}>Click me</button>
<input oninput={handleInput} />
</main>
);
}
fn handleClick(_: zx.client.Event) void {
zx.log.info("Clicked!", .{});
}
fn handleInput(event: zx.client.Event) void {
const v = event.value() orelse "";
zx.log.info("Input: {s}", .{v});
}<!--$c98e427--><main><button>Click me</button><input></main><!--/$c98e427-->Common event handlers include onclick, onsubmit, onchange, onblur, and more. Handler functions receive a zx.client.Event parameter with access to the DOM event object.
Binding state to events
To update component state in response to events, use ctx.bind(handler) and access state through the event handler:
pub fn Client(allocator: zx.Allocator) zx.Component {
return (<Counter @{allocator} @rendering={.client} />);
}
pub fn Counter(ctx: *zx.ComponentCtx(void)) zx.Component {
const count = ctx.state(i32, 0);
return (
<div @allocator={ctx.allocator} class="counter">
<button onclick={ctx.bind(increment)}>
Click Me: {count}
</button>
</div>
);
}
fn increment(e: *zx.client.Event.Stateful) void {
const count = e.state(i32);
count.set(count.get() + 1);
}<!--$ce7e505--><div class="counter"><button> Click Me: 0</button></div><!--/$ce7e505-->When you use ctx.bind(), the handler receives a zx.client.Event.Stateful which provides access to component state through event.state(T). Call state(T) in the same order as the ctx.state() calls in the component; the types must match.
@rendering={.client} must be set on the interactive component or a parent that wraps it. Without it, the component renders server-side only and event handlers are ignored.Conditional rendering
Use Zig's if expressions to conditionally render content:
pub fn UserStatus(allocator: zx.Allocator) zx.Component {
const is_logged_in = true;
const is_admin = false;
return (
<main @allocator={allocator}>
{if (is_logged_in) (<p>Welcome back!</p>) else (<p>Please log in.</p>)}
{if (is_admin) (<button>Admin Panel</button>)}
</main>
);
}<main><p>Welcome back!</p></main>Switch expressions
Use switch expressions to match against enum values. Each case can return text or a component:
const Role = enum { admin, member, guest };
pub fn RoleBadge(allocator: zx.Allocator) zx.Component {
const role: Role = .admin;
return (
<main @allocator={allocator}>
<span class="badge">
{switch (role) {
.admin => (<strong>Admin</strong>),
.member => ("Member"),
.guest => ("Guest"),
}}
</span>
</main>
);
}<main><span class="badge"><strong>Admin</strong></span></main>ZX also supports while loops for conditional iteration. See the Control Flow documentation for all patterns.
Rendering lists
Use for loops to iterate over arrays and render a component for each item:
pub fn ProductList(allocator: zx.Allocator) zx.Component {
const products = [_][]const u8{ "Apple", "Banana", "Orange" };
return (
<main @allocator={allocator}>
<h2>Products</h2>
<ul>{for (products) |product| (<li>{product}</li>)}</ul>
</main>
);
}<main><h2>Products</h2><ul><li>Apple</li><li>Banana</li><li>Orange</li></ul></main>The loop variable can be used within the component body to display item-specific content. See the For Loops documentation for more examples.
Passing props to components
Components can accept props (properties) to customize their behavior. Define a props struct as the second parameter:
const ButtonProps = struct {
title: []const u8 = "Click Me",
class: []const u8 = "btn",
};
pub fn ButtonDemo(allocator: zx.Allocator) zx.Component {
return (
<main @allocator={allocator}>
<Button title="Submit" class="primary" />
<Button title="Cancel" />
<Button />
</main>
);
}
fn Button(allocator: zx.Allocator, props: ButtonProps) zx.Component {
return (<button @allocator={allocator} class={props.class}>{props.title}</button>);
}<main><button class="primary">Submit</button><button class="btn">Cancel</button><button class="btn">Click Me</button></main>ZX automatically coerces attributes to the props struct. Fields with default values are optional. See the Components documentation for more on props coercion.
Component signatures
- Allocator only:
fn Component(allocator: zx.Allocator) zx.Component - With props:
fn Component(allocator: zx.Allocator, props: Props) zx.Component
Passing children to components
Components can accept children - content passed between opening and closing tags. Add a children: zx.Component field to your props:
const CardProps = struct { title: []const u8, children: zx.Component };
pub fn CardDemo(allocator: zx.Allocator) zx.Component {
return (
<main @allocator={allocator}>
<Card title="Welcome">
<p>This content is passed as children.</p>
<button>Click me</button>
</Card>
</main>
);
}
fn Card(allocator: zx.Allocator, props: CardProps) zx.Component {
return (
<div @allocator={allocator} class="card">
<h2>{props.title}</h2>
<div class="card-body">{props.children}</div>
</div>
);
}<main><div class="card"><h2>Welcome</h2><div class="card-body"><p>This content is passed as children.</p><button>Click me</button></div></div></main>This pattern is useful for creating wrapper components like cards, modals, or layout containers. See the Children Props documentation for more details.
Pages and Layouts
ZX uses file-based routing. Create page.zx files in the app/pages/ directory:
pub fn Page(ctx: zx.PageContext) zx.Component {
return (
<main @allocator={ctx.arena}>
<h1>About Us</h1>
<p>Welcome to our website!</p>
<p>Path: {ctx.request.pathname}</p>
</main>
);
}<main><h1>About Us</h1><p>Welcome to our website!</p><p>Path: /learn</p></main>The file path determines the URL: app/pages/about/page.zx → /about
Dynamic routes
Use brackets [param] in folder names to create dynamic segments:
app/pages/user/[id]/page.zx→/user/:idapp/pages/blog/[slug]/page.zx→/blog/:slug
Access the parameter value using ctx.request.params.get("name"):
pub fn UserProfile(ctx: zx.PageContext) zx.Component {
const user_id = ctx.request.params.get("id") orelse "unknown";
return (
<main @allocator={ctx.arena}>
<h1>User Profile</h1>
<p>User ID: {user_id}</p>
</main>
);
}<main><h1>User Profile</h1><p>User ID: unknown</p></main>Creating layouts
Layouts wrap pages with common UI. Create a layout.zx file:
pub fn Layout(ctx: zx.LayoutContext, children: zx.Component) zx.Component {
return (
<html @allocator={ctx.arena}>
<head><title>My App</title></head>
<body>
<nav><a href="/">Home</a> <a href="/about">About</a></nav>
<main>{children}</main>
<footer>© 2025 My App</footer>
</body>
</html>
);
}pub fn Page(ctx: zx.PageContext) zx.Component {
return (
<main @allocator={ctx.arena}>
<h1>About Us</h1>
<p>Welcome to our website!</p>
<p>Path: {ctx.request.pathname}</p>
</main>
);
}<html><head><title>My App</title></head><body><nav><a href="/">Home</a> <a href="/about">About</a></nav><main><h1>About Us</h1><p>Welcome!</p></main><footer>© 2025 My App</footer></body></html>PageContext and LayoutContext
Both contexts provide:
request- HTTP request with headers, query params, bodyresponse- HTTP response for setting headersarena- Request-scoped allocator (recommended)allocator- Global allocator for persistent allocations
ctx.arena for allocations - it's automatically freed after the request.See the Routing documentation for detailed information on pages, layouts, and dynamic routes.
API Routes
ZX makes it easy to create APIs by adding route.zig files to your app/pages/ directory. These files handle HTTP methods like GET, POST, and more.
Create a route.zig file to handle API requests:
pub fn GET(ctx: zx.RouteContext) !void {
try ctx.response.json(.{ .message = "Hello World!" }, .{});
}API routes have access to zx.RouteContext, which provides the request, response, and even WebSocket support. Learn more in the API documentation.
Key-Value Storage
ZX includes a key-value store for persisting small pieces of data across requests and restarts. Access it through zx.kv.
Store and retrieve raw bytes:
pub fn KvBasic(ctx: zx.PageContext) !zx.Component {
try zx.kv.put("greeting", "hello from kv", .{});
const value = try zx.kv.get(ctx.arena, "greeting");
try zx.kv.delete("greeting");
return (
<main @allocator={ctx.arena}>
<p>Stored value: {value orelse "not found"}</p>
</main>
);
}zx.kv.get returns owned bytes (or null), so free them when you're done — or use the request arena as shown. List keys with a prefix using zx.kv.list.
Typed values
Use zx.kv.putAs and zx.kv.as to store and read typed Zig structs directly:
pub fn KvTyped(ctx: zx.PageContext) !zx.Component {
const User = struct {
id: u32,
name: []const u8,
active: bool,
};
try zx.kv.putAs("user:123", User{
.id = 123,
.name = "alice",
.active = true,
}, .{});
const user = try zx.kv.as(ctx.arena, "user:123", User);
return (
<main @allocator={ctx.arena}>
{if (user) |u| (
<p>User: {u.name} (#{u.id})</p>
) else (
<p>Not found</p>
)}
</main>
);
}If the stored value's type doesn't match, zx.kv.as returns error.InvalidType.
Scoped namespaces
Group keys into separate namespaces with zx.kv.scoped, passing an enum literal for the namespace name:
pub fn KvScoped(ctx: zx.PageContext) !zx.Component {
const sessions = zx.kv.scoped(.sessions);
try sessions.put("abc123", "token-xyz", .{});
const token = try sessions.get(ctx.arena, "abc123");
return (
<main @allocator={ctx.arena}>
<p>Session token: {token orelse "none"}</p>
</main>
);
}A scoped store exposes the same methods as the default one.
datadir/kv/. On edge runtimes like Cloudflare Workers, ZX uses the platform's KV binding instead.Database
ZX ships with a SQL database layer accessed through zx.db.
Create tables and insert rows with zx.db.run:
pub fn DbBasic(ctx: zx.PageContext) !zx.Component {
_ = try zx.db.run(
\\CREATE TABLE IF NOT EXISTS posts (
\\ id INTEGER PRIMARY KEY,
\\ title TEXT NOT NULL
\\)
, .empty);
_ = try zx.db.run("INSERT INTO posts (title) VALUES (?1)", .{"Hello World"});
const row = try zx.db.get(ctx.arena, "SELECT title FROM posts ORDER BY id DESC LIMIT 1", .{});
return (
<main @allocator={ctx.arena}>
<p>Latest post: {if (row) |r| r.text("title") else "none"}</p>
</main>
);
}Pass bindings as an inline literal: .empty for none, a tuple for positional ?1 placeholders, or a struct for named $name placeholders. Read a single row with zx.db.get, then pull columns with row.text, row.int, row.float, or row.boolean.
Reading typed rows
Map query results straight into Zig structs with zx.db.rows (or zx.db.row for a single result). Column names are matched to struct fields:
pub fn DbTyped(ctx: zx.PageContext) !zx.Component {
const Post = struct {
id: i64,
title: []const u8,
};
_ = try zx.db.run(
\\CREATE TABLE IF NOT EXISTS blog_posts (
\\ id INTEGER PRIMARY KEY,
\\ title TEXT NOT NULL
\\)
, .empty);
_ = try zx.db.run("INSERT INTO blog_posts (title) VALUES (?1)", .{"ZX Guide"});
const posts = try zx.db.rows(ctx.arena, Post, "SELECT id, title FROM blog_posts", .{});
return (
<main @allocator={ctx.arena}>
{for (posts) |post| (
<p>{post.title} (#{post.id})</p>
)}
</main>
);
}Transactions
Run multiple statements atomically with zx.db.transaction. It takes a context value and a callback that receives that value plus a zx.Db handle:
pub fn DbTransaction(ctx: zx.PageContext) !zx.Component {
const Account = struct { id: i64, balance: i64 };
_ = try zx.db.run(
\\CREATE TABLE IF NOT EXISTS accounts (
\\ id INTEGER PRIMARY KEY,
\\ balance INTEGER NOT NULL
\\)
, .empty);
_ = try zx.db.run("INSERT OR REPLACE INTO accounts (id, balance) VALUES (1, 100), (2, 100)", .empty);
try zx.db.transaction({}, transfer);
const accounts = try zx.db.rows(ctx.arena, Account, "SELECT id, balance FROM accounts ORDER BY id", .{});
return (
<main @allocator={ctx.arena}>
{for (accounts) |account| (
<p>Account #{account.id}: {account.balance}</p>
)}
</main>
);
}
fn transfer(_: void, db: zx.Db) !void {
_ = try db.run("UPDATE accounts SET balance = balance - 10 WHERE id = ?1", .{1});
_ = try db.run("UPDATE accounts SET balance = balance + 10 WHERE id = ?1", .{2});
}If the callback returns an error, the transaction rolls back. Use zx.db.transactionWith to pick an explicit mode (.deferred, .immediate, or .exclusive).
zx.db API works across both.Deployment
ZX apps can be deployed in three ways: as a standalone binary on your own server, as a static site on a CDN, or to an edge runtime like Cloudflare Workers or Vercel.
Standalone
Compile your app to a single, self-contained binary with zero runtime dependencies. Build for production, then bundle the binary with its static assets:
zig build -Doptimize=ReleaseSafezig build zx -- bundleThis creates a bundle/ directory containing your binary and static/ assets. Copy the whole directory to your server and run the binary.
Docker
Scaffold a project with a ready-to-use Dockerfile using the docker template:
zx init --template dockerIt uses a two-stage Alpine build that compiles your app and ships only the final binary and assets. Build and run the image:
docker build -t myapp .Static
For sites without per-request server logic, export every route to static HTML:
zig build zx -- exportThis crawls all routes and writes the output to a dist/ directory, ready to upload to any static host like Netlify, GitHub Pages, or a CDN.
Edge
ZX compiles to WebAssembly (WASI), so it runs on edge runtimes close to your users. Edge targets use the KV binding for storage instead of the filesystem.
Cloudflare
Scaffold a Cloudflare Workers project with the cloudflare template:
zx init --template cloudflareThe template includes a wrangler.jsonc that builds your app to WASM and wires up KV and static assets. Deploy with Wrangler:
npx wrangler deployVercel
Scaffold a Vercel project with the vercel template:
zx init --template vercelIt ships a vercel.json configured for Edge Functions (with an optional Node.js runtime). Deploy with the Vercel CLI:
vercel deployNext Steps
You now know the basics of ZX! Here's what to explore next: