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:

$ curl -fsSL https://ziex.dev/install | bash
> powershell -c "irm ziex.dev/install.ps1 | iex"

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:

$ brew install zig
> winget install -e --id zig.zig

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:

$ zx init my-app

Start the ZX app in development mode with hot reloading:

$ cd my-app
$ zig build dev

Open http://localhost:3000 in your browser!

First initialize a Zig project if you haven't already:

$ zig init

Add ZX as a dependency:

$ zig fetch --save git+https://github.com/ziex-dev/ziex

Update your build.zig to the following:

build.zig
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:

$ zig build zx -- init --existing

Start the ZX app in development mode with hot reloading:

$ zig build dev

Open http://localhost:3000 in your browser!

Project Structure

my-app/Project root
build.zigZig build entrypoint and ZX build steps
build.zig.zonZig package manifest and dependencies
README.mdProject overview and quick start
app/Application source root (pages, assets, public, entrypoints)
main.zigServer entrypoint and app bootstrap
assets/Static assets bundled and served at /assets/*
style.cssStarter global styles
pages/File-based routes and layouts
layout.zxRoot layout shared by all routes
page.zxExample page client component
client.zxZX client component (counter logic)
about/
page.zxAbout page (/about)
public/Static files served to the site root (/*)
favicon.icoSite favicon
Tip: The 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:

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):

  1. Clone: git clone https://github.com/ziex-dev/ziex
  2. Open Extensions panel (Cmd/Ctrl + Shift + P)
  3. Select "Install Dev Extension"
  4. Navigate to ide/zed in 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>
Note: Every component needs an allocator for memory management. The @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.

Note: For event handlers to run in the browser,@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/:id
  • app/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, body
  • response - HTTP response for setting headers
  • arena - Request-scoped allocator (recommended)
  • allocator - Global allocator for persistent allocations
Best practice: Use 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:

route.zig
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.

Storage backend: On native platforms, data is stored on the filesystem under 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).

Backends: ZX uses SQLite on native platforms and Cloudflare D1 on Workers. The same 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=ReleaseSafe
zig build zx -- bundle

This 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 docker

It 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 .
Others: More standalone deployment guides (systemd, bare VPS) are coming soon.

Static

For sites without per-request server logic, export every route to static HTML:

zig build zx -- export

This 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 cloudflare

The template includes a wrangler.jsonc that builds your app to WASM and wires up KV and static assets. Deploy with Wrangler:

npx wrangler deploy

Vercel

Scaffold a Vercel project with the vercel template:

zx init --template vercel

It ships a vercel.json configured for Edge Functions (with an optional Node.js runtime). Deploy with the Vercel CLI:

vercel deploy
Others: Deployment to other edge platforms is possible with custom build scripts. Guides for Fly.io, AWS Lambda, and more are coming soon.

Next Steps

You now know the basics of ZX! Here's what to explore next: