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.

You will learn

  • How to install ZX and set up your environment
  • How to create and nest components
  • How to write markup and use fragments
  • How to display data with expressions
  • How to use dynamic attributes
  • How to render conditionally and render lists
  • How to pass props and children to components
  • How to create pages, layouts, and dynamic routes

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.562 requires Zig 0.15.2:

$ 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!

New Project

For a new project, first initialize a Zig project:

$ zig init

Existing Project

For an existing Zig project, add the following to your build.zig:

build.zig
const std = @import("std");
const zx = @import("zx");

pub fn build(b: *std.Build) !void {
    // --- Target and Optimize from `zig build` arguments ---
    const target = b.standardTargetOptions(.{});
    const optimize = b.standardOptimizeOption(.{});

    // --- Root Module ---
    const mod = b.addModule("root_mod", .{
        .root_source_file = b.path("src/root.zig"),
        .target = target,
        .optimize = optimize,
    });

    // --- ZX Setup (sets up ZX, dependencies, executables and `serve` step) ---
    const site_exe = b.addExecutable(.{
        .name = "zx_site",
        .root_module = b.createModule(.{
            .root_source_file = b.path("site/main.zig"),
            .target = target,
            .optimize = optimize,
            .imports = &.{
                .{ .name = "root_mod", .module = mod },
            },
        }),
    });

    _ = try zx.init(b, site_exe, .{
        .experimental = .{ .enabled_csr = true },
    });
}

Then initialize the project with templates:

$ zig build zx -- init --existing

Project Structure

                
                    
my-app/
├── build.zig           # Zig build config
├── site/
│   ├── main.zig        # Entry point
│   ├── pages/
│   │   ├── page.zx     # Home page (/)
│   │   ├── about/
│   │   │   └── page.zx # About (/about)
│   │   └── layout.zx   # Root layout
│   └── public/         # Static assets
└── src/
    └── root.zig        # Shared code                            
Tip: The site/pages/ directory uses file-based routing. Each page.zx file becomes a route.

VS Code / Cursor

Install the official ZX extension for the best development experience.

Install from:

Or from command palette: ext install nurulhudaapon.zx

Features: Syntax highlighting, IntelliSense, error diagnostics, bracket matching, and code folding.

Neovim

With lazy.nvim, add nurulhudaapon/zx 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.

Zed

Manual installation (pending marketplace approval):

  1. Clone: git clone https://github.com/nurulhudaapon/zx.git
  2. Open Extensions panel (Cmd/Ctrl + Shift + P)
  3. Select "Install Dev Extension"
  4. Navigate to editors/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>);
}

const zx = @import("zx");
<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>
            <Card />
        </main>
    );
}

fn Card(allocator: zx.Allocator) zx.Component {
    return (
        <div @allocator={allocator} class="card">
            <h2>User Profile</h2>
            <p>Welcome to the card component!</p>
        </div>
    );
}

const zx = @import("zx");
<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}>
            <Header />
        </main>
    );
}

fn Header(allocator: zx.Allocator) zx.Component {
    return (
        <>
            <h1 @allocator={allocator}>Welcome</h1>
            <p>Multiple elements without a wrapper</p>
        </>
    );
}

const zx = @import("zx");
<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>
    );
}

const zx = @import("zx");
<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>
    );
}

const zx = @import("zx");
<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>
    );
}

const zx = @import("zx");
<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.

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>
    );
}

const zx = @import("zx");
<main><p>Welcome back!</p></main>

Switch expressions

Use switch expressions to match against enum values. Each case can return text or a component:

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>
    );
}

const Role = enum { admin, member, guest };
const zx = @import("zx");
<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>
    );
}

const zx = @import("zx");
<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:

pub fn ButtonDemo(allocator: zx.Allocator) zx.Component {
    return (
        <main @allocator={allocator}>
            <Button title="Submit" class="primary" />
            <Button title="Cancel" />
            <Button />
        </main>
    );
}

const ButtonProps = struct {
    title: []const u8 = "Click Me",
    class: []const u8 = "btn",
};

fn Button(allocator: zx.Allocator, props: ButtonProps) zx.Component {
    return (
        <button @allocator={allocator} class={props.class}>
            {props.title}
        </button>
    );
}

const zx = @import("zx");
<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:

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>
    );
}

const CardProps = struct { title: []const u8, children: zx.Component };
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>
    );
}

const zx = @import("zx");
<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 site/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>
    );
}

const zx = @import("zx");
<main><h1>About Us</h1><p>Welcome to our website!</p><p>Path: /learn</p></main>

The file path determines the URL: site/pages/about/page.zx/about

Dynamic routes

Use brackets [param] in folder names to create dynamic segments:

  • site/pages/user/[id]/page.zx/user/:id
  • site/pages/blog/[slug]/page.zx/blog/:slug

Access the parameter value using ctx.request.getParam("name"):

// site/pages/user/[id]/page.zx
pub fn UserProfile(ctx: zx.PageContext) zx.Component {
    const user_id = ctx.request.getParam("id") orelse "unknown";

    return (
        <main @allocator={ctx.arena}>
            <h1>User Profile</h1>
            <p>User ID: {user_id}</p>
        </main>
    );
}

const zx = @import("zx");
<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>
    );
}

const zx = @import("zx");
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>
    );
}

const zx = @import("zx");
<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.

Using Plugins

ZX provides a plugin system to extend your build process. Plugins run as additional build steps and integrate with tools like Tailwind CSS and esbuild.

Builtin Plugins

ZX ships with the following builtin plugins available via zx.plugins:

  • zx.plugins.tailwind — Compile Tailwind CSS styles
  • zx.plugins.esbuild — Bundle TypeScript/JavaScript for client-side code

Tailwind CSS Plugin

The Tailwind builtin plugin compiles your CSS using the Tailwind CLI. Add it to your build.zig:

build.zig
const std = @import("std");
const zx = @import("zx");

pub fn build(b: *std.Build) !void {
    const exe = b.addExecutable(.{ .name = "my-site" });

    try zx.init(b, exe, .{
        .plugins = &.{
            zx.plugins.tailwind(b, .{
                .input = b.path("site/assets/styles.css"),
                .output = b.path("{outdir}/assets/styles.css"),
            }),
        },
    });
}

Then link the compiled CSS in your layout:

<link rel="stylesheet" href="/assets/styles.css" />
Prerequisites: Install the required CLI tools before using builtin plugins:npm install tailwindcss @tailwindcss/cli

esbuild Plugin

The esbuild builtin plugin bundles TypeScript or JavaScript for client-side interactivity:

build.zig
const std = @import("std");
const zx = @import("zx");

pub fn build(b: *std.Build) !void {
    const exe = b.addExecutable(.{ .name = "my-site" });

    try zx.init(b, exe, .{
        .plugins = &.{
            zx.plugins.esbuild(b, .{
                .input = b.path("site/main.ts"),
                .output = b.path("{outdir}/assets/main.js"),
            }),
        },
    });
}

Then include the bundled script in your layout:

<script src="/assets/main.js"></script>
Prerequisites: Install the required CLI tools before using builtin plugins:npm install esbuild

See the Plugins documentation for all available options and how to create your own custom plugins.

Next Steps

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