Overview

This reference documents the ZX templating syntax and APIs. For a hands-on introduction, see the Learn guide.

Expressions

Use curly braces {expression} to embed any Zig expression. Values are automatically formatted based on their type and HTML-escaped for safety.

pub fn Expressions(allocator: zx.Allocator) !zx.Component {
    const string_val = "hello";
    const int_val: i32 = 42;
    const float_val: f32 = 3.14;
    const bool_true = true;
    const bool_false = false;
    const optional_val: ?[]const u8 = "present";
    const optional_null: ?[]const u8 = null;

    const InputType = enum { text, number, checkbox };
    const enum_val = InputType.text;

    const component_val = (<p>hi</p>);

    const component_array_val = try allocator.alloc(zx.Component, 2);
    component_array_val[0] = (<li>hi</li>);
    component_array_val[1] = (<li>hello</li>);

    return (
        <form @allocator={allocator}>
            <p>String: {string_val}</p>
            <p>Integer: {int_val}</p>
            <p>Float: {float_val}</p>
            <p>Boolean: {bool_true}</p>
            <p>Boolean: {bool_false}</p>
            <p>Optional: {optional_val}</p>
            <p>Optional: {optional_null}</p>
            <p>Enum: {enum_val}</p>
            <p>Component: {component_val}</p>
            <ol>Component Array: {component_array_val}</ol>
        </form>
    );
}

const zx = @import("zx");
<form><p>String: hello</p><p>Integer: 42</p><p>Float: 3.14</p><p>Boolean: true</p><p>Boolean: false</p><p>Optional: present</p><p>Optional: </p><p>Enum: text</p><p>Component: <p>hi</p></p><ol>Component Array: <li>hi</li><li>hello</li></ol></form>

Strings

String values ([]const u8) are displayed as text and automatically HTML-escaped to prevent XSS attacks. This is the safe default for displaying user input or any dynamic text content.

const name = "Alice";
...
(<p>{name}</p>) // <p>Alice</p>

Integers

Integer types (i32, u64, comptime_int, etc.) are formatted as decimal numbers. The special case u8 is printed as a character if it's a printable ASCII character.

const count: u32 = 42;
...
(<p>{count}</p>) // <p>42</p>

Floats

Floating-point types (f32, f64, comptime_float) are formatted as decimal numbers.

const price: f32 = 19.99;
...
(<p>${price}</p>) // <p>$19.99</p>

Booleans

Boolean values are displayed as the literal strings "true" or "false".

const active = true;
...
(<p>{active}</p>) // <p>true</p>

Enums

Enum values are displayed as their tag name. This is useful for displaying status values, categories, or any enumerated type.

const Status = enum { pending, approved, rejected };
const status = Status.approved;
...
(<p>{status}</p>) // <p>approved</p>

Optionals

Optional values (?T) are automatically unwrapped and rendered if present. If the value is null, nothing is rendered. This makes it easy to conditionally display content.

const maybe_name: ?[]const u8 = "Bob";
...
(<p>{maybe_name}</p>) // <p>Bob</p>


const no_name: ?[]const u8 = null;
...
(<p>{no_name}</p>) // <p></p> (empty)

Null

The null value renders nothing, producing no output in the final HTML.

Components

A zx.Component value is rendered directly. This allows you to embed components returned from functions or stored in variables.

const header = Header(allocator);
...
(<div>{header}</div>) // Renders the Header component

Component Arrays

Arrays or slices of components ([]zx.Component) are rendered as a fragment containing all the components in sequence.

const items: []zx.Component = &.{ Item("A"), Item("B") };
...
(<ul>{items}</ul>) // Renders both Item components

Control Flow

ZX supports conditional rendering and iteration using familiar Zig control flow constructs. These expressions allow you to conditionally render components or iterate over collections to build dynamic UIs.

If Statements

Use if expressions to conditionally render components based on boolean conditions. Theelse branch is optional and can render alternative content when the condition is false.

pub fn Conditional(allocator: zx.Allocator) zx.Component {
    const is_admin = true;
    const is_logged_in = false;

    return (
        <main @allocator={allocator}>
            <section>
                {if (is_admin) (<p>Admin</p>) else (<p>User</p>)}
            </section>
            <section>
                {if (is_admin) ("Powerful") else ("Powerless")}
            </section>
            <section>
                {if (is_logged_in) (
                    <p>Welcome, User!</p>
                ) else (
                    <p>Please log in to continue.</p>
                )}
            </section>
        </main>
    );
}

const zx = @import("zx");
<main><section><p>Admin</p></section><section>Powerful</section><section><p>Please log in to continue.</p></section></main>

Switch Statements

Use switch expressions to match against enum values or other types. Each case can return either a string literal or a component. Switch expressions are particularly useful for rendering different UI based on state or user roles.

pub fn RoleSwitch(allocator: zx.Allocator) zx.Component {
    const user_swtc = users[0];

    return (
        <main @allocator={allocator}>
            <section>
                {switch (user_swtc.user_type) {
                    .admin => ("Admin"),
                    .member => ("Member"),
                }}
            </section>
            <section>
                {switch (user_swtc.user_type) {
                    .admin => (<p>Powerful</p>),
                    .member => (<p>Powerless</p>),
                }}
            </section>
        </main>
    );
}

const zx = @import("zx");

const UserType = enum { admin, member };

const User = struct { name: []const u8, age: u32, user_type: UserType };

const users = [_]User{
    .{ .name = "John", .age = 20, .user_type = .admin },
    .{ .name = "Jane", .age = 21, .user_type = .member },
};
<main><section>Admin</section><section><p>Powerful</p></section></main>

For Loops

Use for loops to iterate over arrays, slices, or strings and render a component for each item. The loop variable can be used within the component body to display item-specific content. This is ideal for rendering lists, tables, or any repeating UI patterns.

pub fn UserList(arena: zx.Allocator) zx.Component {
    const users = [_]struct { name: []const u8, role: UserRole }{
        .{ .name = "John", .role = .admin },
        .{ .name = "Jane", .role = .member },
        .{ .name = "Jim", .role = .guest },
    };
    return (
        <main @allocator={arena}>
            {for (users) |user| (
                <div>
                    <p>{user.name}</p>
                    {switch (user.role) {
                        .admin => (<span>Admin</span>),
                        .member => (<span>Member</span>),
                        .guest => (<span>Guest</span>),
                    }}
                </div>
            )}
        </main>
    );
}

const zx = @import("zx");

const UserRole = enum { admin, member, guest };
<main><div><p>John</p><span>Admin</span></div><div><p>Jane</p><span>Member</span></div><div><p>Jim</p><span>Guest</span></div></main>

While Loops

Use while loops for conditional iteration with a continuation expression. The syntax follows Zig's while loop pattern: while (condition) : (continue_expr) (body). This is useful for generating numbered sequences or iterating until a condition is met.

pub fn Counter(allocator: zx.Allocator) zx.Component {
    var i: usize = 0;

    return (
        <ul @allocator={allocator}>
            {while (i < 3) : (i += 1) (
                <li>Item {i}</li>
            )}
        </ul>
    );
}

const zx = @import("zx");
<ul><li>Item 0</li><li>Item 1</li><li>Item 2</li></ul>

Optional Capture

Use payload capture syntax to unwrap optional values in if and while expressions:

const user_name: ?[]const u8 = "Alice";
...
{if (user_name) |name| (<p>Welcome, {name}!</p>)}

// With else branch:
{if (user_name) |name| (<p>Hello, {name}</p>) else (<p>Guest</p>)}

// While with capture (iterator pattern):
{while (iter.next()) |item| (<li>{item}</li>)}

If the optional is null, the body is skipped (or the else branch runs if provided).

Error Capture

Capture errors from error unions using else |err| syntax:

// If with error capture:
{if (fetchUser()) |user| (
    <p>Welcome, {user.name}!</p>
) else |err| (
    <p>Error: {@errorName(err)}</p>
)}

// While with error capture:
{while (iter.next()) |item| (
    <li>{item}</li>
) else |err| (
    <p>Iteration ended: {@errorName(err)}</p>
)}

This works with both if and while expressions for handling fallible operations.

Components

Components are reusable functions that return zx.Component. They allow you to encapsulate UI logic and create modular, maintainable code. Components can accept props (properties) to customize their behavior and appearance.

Component Function Signatures

Component functions can use one of several signatures depending on their needs:

  • Allocator only:fn Component(allocator: zx.Allocator) zx.Component
  • With props:fn Component(allocator: zx.Allocator, props: Props) zx.Component
  • Context (children only):fn Component(ctx: *zx.ComponentContext) zx.Component
  • Context with props:fn Component(ctx: *zx.ComponentCtx(Props)) zx.Component

ComponentContext

Use zx.ComponentContext when your component needs access to children but has no custom props:

// ComponentContext: children only, no custom props
fn Wrapper(ctx: *zx.ComponentContext) zx.Component {
    return (
        <div @allocator={ctx.allocator} class="wrapper">
            {ctx.children}
        </div>
    );
}

ComponentCtx with Props

Use zx.ComponentCtx(Props) when you need both custom props and children access:

// ComponentCtx(Props): props and children access
const CardProps = struct { title: []const u8 };
fn Card(ctx: *zx.ComponentCtx(CardProps)) zx.Component {
    return (
        <article @allocator={ctx.allocator} class="card">
            <h2>{ctx.props.title}</h2>
            {ctx.children}
        </article>
    );
}

The context provides ctx.allocator, ctx.props, and ctx.children.

Defining Components

To create a component, define a function that returns a zx.Component. The function must have an allocator as its first parameter. If your component needs configuration, add a props struct as the second parameter:

pub fn ButtonDemo(ctx: zx.PageContext) zx.Component {
    const allocator = ctx.arena;

    return (
        <main @allocator={allocator}>
            <Button title="Submit" class="primary-btn" />
            <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-btn">Submit</button><button class="btn">Cancel</button><button class="btn">Click Me</button></main>

Props Coercion

ZX automatically coerces the attributes you pass to a component into the expected props struct. Missing required fields will result in a compile-time error, while fields with default values are optional. This provides type safety while keeping component usage flexible.

Routing

ZX provides a file-based routing system that maps URL paths to page components. Routes can be nested, and layouts can be applied hierarchically to wrap pages with common UI elements.

Pages

Pages are the main content components for each route. A page function must:

  • Accept a zx.PageContext as its only parameter
  • Return a zx.Component
  • Be exported as pub fn Page
// This example shows a page component
pub fn Page(ctx: zx.PageContext) zx.Component {
    const allocator = ctx.arena;

    // Access request data (e.g., query params, headers, etc.)
    // const query = ctx.request.query() catch unreachable;

    return (
        <main @allocator={allocator}>
            <h1>Hello, World!</h1>
            <p>Welcome to the page</p>
        </main>
    );
}

const zx = @import("zx");
<main><h1>Hello, World!</h1><p>Welcome to the page</p></main>

Layouts

Layouts wrap pages with common UI elements like headers, footers, or navigation. A layout function must:

  • Accept a zx.LayoutContext as the first parameter
  • Accept a zx.Component (the page content) as the second parameter
  • Return a zx.Component
  • Be exported as pub fn Layout
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");
// This example shows a page component
pub fn Page(ctx: zx.PageContext) zx.Component {
    const allocator = ctx.arena;

    // Access request data (e.g., query params, headers, etc.)
    // const query = ctx.request.query() catch unreachable;

    return (
        <main @allocator={allocator}>
            <h1>Hello, World!</h1>
            <p>Welcome to the page</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>Hello, World!</main><footer>© 2025 My App</footer></body></html>

PageContext and LayoutContext

Both PageContext and LayoutContext provide access to:

  • request: The HTTP request object with headers, query params, body, etc.
  • response: The HTTP response object for setting headers and writing the response
  • allocator: Global allocator for persistent allocations (freed manually)
  • arena: Request-scoped allocator that's automatically freed after the request (recommended for most use cases)

Use ctx.arena for temporary allocations that only need to persist during request processing. This is the recommended allocator for most page and component code.

Builtin Attributes

ZX provides special builtin attributes that control component behavior and memory management. These attributes are prefixed with @ to distinguish them from regular HTML attributes.

@allocator

The @allocator attribute is required to be passed to the topmost component. All child components will inherit the allocator from the parent component. It is available to all child components and expressions within that component tree.

Why is @allocator needed?

ZX components can allocate memory for various purposes:

  • Storing text content (which is HTML-escaped and allocated)
  • Copying child components arrays
  • Copying attribute arrays
  • Formatting expressions that allocate formatted strings

Usage:

pub fn AllocatorDemo(ctx: zx.PageContext) zx.Component {
    return (
        <html @allocator={ctx.arena}>
            <head>
                <title>My Page</title>
            </head>
            <body>
                <MyComponent />
            </body>
        </html>
    );
}

fn MyComponent(allocator: zx.Allocator) zx.Component {
    const someText = "This text will be allocated and escaped";

    return (
        <div @allocator={allocator}>
            {someText}
        </div>
    );
}

const zx = @import("zx");
<html><head><title>My Page</title></head><body><div>This text will be allocated and escaped</div></body></html>

Best Practices:

  • Always use ctx.arena in page components (it's automatically freed after the request)
  • Pass the allocator parameter to custom components and use it in the @allocator attribute
  • Set @allocator on the root element of each component that needs memory allocation
  • Child components inherit the allocator from their parent's component, so you don't need to pass it explicitly to every nested component

@escaping

The @escaping attribute controls how text content is escaped within an element. By default, all text is HTML-escaped to prevent XSS attacks.

Values:

  • .html - Standard HTML escaping (default behavior)
  • .none - No escaping; outputs raw HTML. Use with caution for trusted content only.
pub fn RawHtml(allocator: zx.Allocator) zx.Component {
    return (
        <main @allocator={allocator}>
            <div @escaping={.none}>
                <strong>This HTML is rendered directly</strong>
            </div>
        </main>
    );
}

const zx = @import("zx");
<main><div><strong>This HTML is rendered directly</strong></div></main>

Warning: Only use @escaping={.none} for content you trust completely. User-provided content should always use the default escaping.

@rendering

The @rendering attribute enables client-side rendering for interactive components.

Values:

  • .react - Client-side render with React (for imported React/TSX components)
  • .client - Client-side render with Zig (compile component to WebAssembly)
pub fn ReactDemo(allocator: zx.Allocator) zx.Component {
    const max_count = 10;

    return (
        <main @allocator={allocator}>
            <CounterComponent @rendering={.react} max_count={max_count} />
        </main>
    );
}

const zx = @import("zx");
const CounterComponent = @jsImport("react.tsx");
<main><!--$c40f580 CounterComponent {"max_count":10}--><!--/$c40f580--></main>

Attribute Syntax

ZX provides convenient syntax for working with attributes beyond simple static values.

Spread Attributes

Use {..struct} to spread all fields of a struct as attributes on an element. This is useful for passing groups of attributes or forwarding props:

const form_attrs = .{
    .class = "form-control",
    .@"data-validate" = "true",
};
const input_props = .{ .name = "email", .value = "test@example.com" };
...
(<form {..form_attrs}>
    <input type="text" {..input_props} />
</form>)

Spread attributes can be combined with explicit attributes. Explicit attributes take precedence when there are conflicts.

Shorthand Attributes

When the attribute name matches the variable name, use the shorthand syntax {variable}:

const class = "btn-primary";
const disabled = true;
const @"data-id" = "123";
...
(<button {class} {disabled} {@"data-id"}>Click</button>)
// Renders: <button class="btn-primary" disabled data-id="123">Click</button>

This is equivalent to writing class={class} but more concise.

Template Attributes

Use backticks (`) for string interpolation in attribute values. Embed expressions with {expr} inside the template:

const count = 42;
const name = "John";
...
(<div class=`item-{count}` data-user=`user-{name}`>
    <a href=`/users/{name}/profile`>Profile</a>
</div>)
// Renders: <div class="item-42" data-user="user-John">
//            <a href="/users/John/profile">Profile</a>
//          </div>

Template attributes are useful for building dynamic URLs, class names, or data attributes that combine static text with dynamic values.

Importing

ZX supports importing components and modules from various sources. You can import ZX components from other files, React/TSX components for client-side interactivity, and standard Zig modules.

pub fn ImportDemo(allocator: zx.Allocator) zx.Component {
    return (
        <main @allocator={allocator}>
            <Button title="Click me" />
            <Card title="Welcome">
                <p>Card content here</p>
            </Card>
        </main>
    );
}

const zx = @import("zx");

// Define components in the same file
const ButtonProps = struct { title: []const u8 };
fn Button(a: zx.Allocator, props: ButtonProps) zx.Component {
    return (<button @allocator={a} class="btn">{props.title}</button>);
}

const CardProps = struct { title: []const u8, children: zx.Component };
fn Card(a: zx.Allocator, props: CardProps) zx.Component {
    return (
        <div @allocator={a} class="card">
            <h2>{props.title}</h2>
            {props.children}
        </div>
    );
}
<main><button class="btn">Click me</button><div class="card"><h2>Welcome</h2><p>Card content here</p></div></main>

Use @jsImport for React/TSX components and the standard @import for ZX and Zig files.

Dynamic Routes

ZX supports dynamic route segments using bracket notation in folder names.

Creating Dynamic Routes

Create a folder with brackets around the parameter name:

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

Accessing Route and Query Parameters

Use ctx.request.getParam("name") to access dynamic route parameters and ctx.request.query("name") for query strings:

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>

Both methods return ?[]const u8, so use orelse to provide a default value.

Fragments

Fragments allow you to return multiple elements from a component without adding an extra wrapper element to the DOM. Use the empty tag syntax <>...</> to create a fragment:

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>

The fragment's children are rendered directly without any containing element.

Children Props

Components can accept child elements through a children prop, enabling wrapper and container patterns. Any elements between the opening and closing tags are passed as the children prop:

pub fn CardDemo(allocator: zx.Allocator) zx.Component {
    return (
        <main @allocator={allocator}>
            <Card title="Welcome">
                <p>This is the card content.</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 is the card content.</p><button>Click me</button></div></div></main>

Syntax Features

ZX supports additional Zig syntax features within templates.

Multiline Strings

Use Zig's multiline string syntax (\\) to embed formatted text blocks:

// Multiline strings use Zig's \\ syntax:
(<pre>{
    \\const x = 1;
    \\const y = 2;
    \\const sum = x + y;
}</pre>)

Each line prefixed with \\ is concatenated. This is useful for code snippets, preformatted text, or ASCII art.

Comments

Single-line comments are supported within ZX templates:

// Single-line comments are supported in ZX:
(<div>
    // This is a comment
    <p>Visible content</p>
    // Another comment
</div>)

Comments are stripped from the output and do not appear in the rendered HTML.

Caching Alpha

ZX provides built-in caching for components, pages, and layouts to improve performance. Cached content is stored in memory and served directly without re-rendering.

Overview

Caching can be applied at three levels:

  • Component caching — Cache individual components using the @caching attribute
  • Page caching — Cache entire pages using PageOptions.caching
  • Layout caching — Cache layouts using LayoutOptions.caching

Component Caching

Use the @caching attribute on any component to cache its rendered HTML output. The syntax is <duration> or <duration>:key.

Time Units

UnitDescriptionExample
sSeconds"10s" = 10 seconds
mMinutes"5m" = 300 seconds
hHours"2h" = 7200 seconds
dDays"1d" = 86400 seconds

Basic Usage

// Cache for 10 seconds
(<Header @caching="10s" />)

// Cache for 5 minutes
(<Sidebar @caching="5m" />)

// Cache for 1 hour
(<Footer @caching="1h" />)

// Cache for 1 day
(<StaticContent @caching="1d" />)

Custom Cache Keys

Add a custom key after the duration with a colon (:) separator. Keys are useful for manual cache invalidation:

// Cache with a custom key for manual invalidation
(<UserProfile @caching="10m:user-profile" user={user} />)

// Cache navigation with key
(<Navigation @caching="1h:main-nav" />)

The cache key is prefixed with cmp: internally, so "10m:user-profile" creates a key cmp:user-profile.

Page Caching

Enable page-level caching by exporting a options constant with caching settings:

page.zx
pub fn Page(ctx: zx.PageContext) zx.Component {
    return (
        <div @allocator={ctx.arena}>
            <h1>Cached Page</h1>
            <p>This page is cached for 5 minutes.</p>
        </div>
    );
}

// Enable page-level caching
pub const options = zx.PageOptions{
    .caching = .{ .seconds = 300 }, // 5 minutes
};

Page caching stores the entire rendered response including headers. The server adds ETag and Cache-Control headers automatically.

Layout Caching

Layouts can also be cached using LayoutOptions:

layout.zx
pub fn Layout(ctx: zx.LayoutContext, children: zx.Component) zx.Component {
    return (
        <html @allocator={ctx.arena}>
            <body>
                <Header />
                {children}
                <Footer />
            </body>
        </html>
    );
}

// Cache the entire layout for 1 hour
pub const options = zx.LayoutOptions{
    .caching = .{ .seconds = 3600 },
};
Note: Layout caching caches the entire layout including children. Use with caution as it may cache dynamic page content unintentionally.

Cache Invalidation

Manually invalidate cache entries using zx.cache functions:

// Delete a specific cache entry by key
_ = zx.cache.del("cmp:user-profile");

// Delete all entries matching a prefix
// Useful for invalidating related content
_ = zx.cache.delPrefix("cmp:user");

Available functions:

  • zx.cache.del(key) — Delete a specific cache entry by exact key. Returns true if deleted.
  • zx.cache.delPrefix(prefix) — Delete all entries matching a prefix. Returns the count of deleted entries.
  • zx.cache.get(key) — Get cached HTML by key. Returns ?[]const u8.

Best Practices

  • Cache static content aggressively — Headers, footers, navigation, and other rarely-changing content benefit most from caching.
  • Use custom keys for related content — Group related cache entries with a common prefix for easy bulk invalidation.
  • Be careful with user-specific content — Don't cache components that display user-specific data without including user identity in the cache key.
  • Start with short TTLs — Begin with shorter cache durations and increase as you verify correctness.
  • Invalidate on data changes — Call zx.cache.del() or zx.cache.delPrefix() when underlying data changes.
Performance tip: Component caching is most effective for expensive-to-render components with stable output, such as markdown content, syntax-highlighted code blocks, or complex nested structures.

Plugins

ZX provides a plugin system to extend the build process with additional tools. Plugins run as build steps and integrate seamlessly with zig build.

Overview

Plugins are configured in your build.zig file and passed to zx.init(). Each plugin adds build steps that run before or after ZX transpilation, allowing you to process CSS, bundle JavaScript, or run other tools.

ZX provides builtin plugins for common tasks, and you can also create custom plugins for your specific needs.

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 CSS CLI. It automatically runs after ZX transpiles your pages.

Options

OptionTypeDefaultDescription
bin?LazyPathnode_modules/.bin/tailwindcssPath to the Tailwind CLI binary
input?LazyPathsite/assets/styles.cssInput CSS file with Tailwind directives
output?LazyPath{outdir}/assets/styles.cssOutput path for compiled CSS
minifyboolfalseOptimize and minify the output
optimizeboolfalseOptimize output without minifying
cwd?LazyPathnullWorking directory for Tailwind
mapboolfalseGenerate a source map

Example

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

pub fn build(b: *std.Build) !void {
    const target = b.standardTargetOptions(.{});
    const optimize = b.standardOptimizeOption(.{});
    
    const zx_dep = b.dependency("zx", .{ .target = target, .optimize = optimize });
    const exe = b.addExecutable(.{
        .name = "my-app",
        .root_module = b.createModule(.{
            .root_source_file = b.path("site/main.zig"),
            .target = target,
            .optimize = optimize,
        }),
    });

    try zx.init(b, exe, .{
        .plugins = &.{
            zx.plugins.tailwind(b, .{
                .bin = b.path("node_modules/.bin/tailwindcss"),
                .input = b.path("site/assets/styles.css"),
                .output = b.path("{outdir}/assets/styles.css"),
                .minify = optimize != .Debug,
            }),
        },
    });
}

esbuild Plugin

The esbuild builtin plugin bundles TypeScript or JavaScript files for client-side code. It supports modern JavaScript features and can bundle dependencies.

Options

OptionTypeDefaultDescription
bin?LazyPathnode_modules/.bin/esbuildPath to the esbuild binary
input?LazyPathsite/main.tsEntry point file
output?LazyPath{outdir}/assets/main.jsOutput file path
bundlebooltrueBundle all dependencies
minify?booltrue in releaseMinify the output
sourcemap?enuminline in debugSource map mode: none, inline, external, linked, both
log_level?enumsilentLog level: verbose, debug, info, warning, error, silent
format?enuminferredOutput format: iife, cjs, esm
platform?enumbrowserTarget platform: browser, node, neutral
target?[]const u8esnextEnvironment target (e.g., es2017, chrome58)
splittingboolfalseEnable code splitting (ESM only)
external[]const []const u8&.{}Modules to exclude from bundle
define[]const struct&.{}Custom substitutions (key-value pairs)

Automatic defines: The esbuild plugin automatically sets __DEV__ and process.env.NODE_ENV based on the build mode.

Example

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

pub fn build(b: *std.Build) !void {
    // ... setup code ...

    try zx.init(b, exe, .{
        .plugins = &.{
            zx.plugins.esbuild(b, .{
                .bin = b.path("node_modules/.bin/esbuild"),
                .input = b.path("site/main.ts"),
                .output = b.path("{outdir}/assets/main.js"),
                .bundle = true,
                .format = .esm,
                .platform = .browser,
                .target = "es2020",
                .external = &.{ "react", "react-dom" },
                .define = &.{
                    .{ .key = "API_URL", .value = "\"https://api.example.com\"" },
                },
            }),
        },
    });
}

Combining Plugins

You can use multiple plugins together. They run in order after ZX transpilation:

build.zig
    .plugins = &.{
        zx.plugins.esbuild(b, .{
            .input = b.path("site/main.ts"),
            .output = b.path("{outdir}/assets/main.js"),
        }),
        zx.plugins.tailwind(b, .{
            .input = b.path("site/assets/styles.css"),
            .output = b.path("{outdir}/assets/styles.css"),
        }),
    },
});

Creating Custom Plugins

You can create your own plugins by returning a zx.ZxInitOptions.PluginOptions struct. This allows you to integrate any command-line tool into the ZX build process.

Plugin Structure

A plugin consists of a name and a list of steps. Each step specifies when to run (before or after transpilation) and what command to execute:

const ExperimentalOptions = struct {
    /// Enable Client-Side Rendering (CSR) support.
    ///
    /// When enabled, ZX will compile a WebAssembly module for client-side
    /// interactivity and hydration. This generates additional build artifacts
    /// in the assets directory.
    ///
    /// Default: `false`
    enabled_csr: bool = false,
};

/// Configuration for build plugins that extend ZX functionality.
pub const PluginOptions = struct {
    /// Command-based plugin step configuration.
    pub const PluginStepCommand = struct {
        /// When to execute this plugin in the build lifecycle.
        type: enum {
            /// Run before ZX transpilation occurs
            before_transpile,
            /// Run after ZX transpilation completes
            after_transpile,
        },

        /// Command to execute.
        ///
        /// Use `{outdir}` in `LazyPath` arguments to reference the transpile output directory.
        run: *std.Build.Step.Run,
    };

Example: Custom Image Optimizer Plugin

Here's how to create a custom plugin that optimizes images during the build:

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

pub fn build(b: *std.Build) !void {
    // ... setup code ...

    try zx.init(b, exe, .{
        .plugins = &.{
            // Custom plugin using PluginOptions directly
            createImageOptimizer(b),
        },
    });
}

fn createImageOptimizer(b: *std.Build) zx.ZxInitOptions.PluginOptions {
    const cmd = std.Build.Step.Run.create(b, "optimize-images");
    
    // Add your custom command
    cmd.addArgs(&.{ "npx", "imagemin", "site/public/**/*", "--out-dir={outdir}/public" });
    
    // Allocate steps array
    const steps = b.allocator.alloc(zx.ZxInitOptions.PluginOptions.PluginStep, 1) catch @panic("OOM");
    steps[0] = .{
        .command = .{
            .type = .after_transpile,  // Run after ZX transpilation
            .run = cmd,
        },
    };
    
    return .{
        .name = "image-optimizer",
        .steps = steps,
    };
}

Plugin Lifecycle

  • before_transpile — Runs before ZX transpiles .zx files to Zig. Useful for preprocessing source files.
  • after_transpile — Runs after ZX transpilation. Useful for post-processing like CSS compilation, JS bundling, or asset optimization.

Using {outdir}

In output paths, use {outdir} as a placeholder for the ZX output directory.

Tip: Look at the builtin plugins (tailwind.zig and esbuild.zig in the ZX source) for examples of how to structure complex plugins with many options.

Command Line Interface

ZX comes with a built-in CLI tool to help you create, develop, and deploy your projects.

Installation

Install the ZX CLI using one of the following methods:

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

init

Initialize a new ZX project. Creates all the necessary files and directory structure to get started with ZX.

$ zx init [path] [flags]

Arguments

  • [path] — Path to initialize the project in (default: current directory)

Flags

  • --template, -t <name> — Template to use (default: "default"). Options: default, react, wasm, react_wasm
  • --force, -f — Force initialization even if the directory is not empty
  • --existing — Initialize ZX in an existing project (only adds files that don't exist)
Note:The command will warn if the directory is not empty. Use --force to override, or --existing to only add missing files.

dev

Start the app in development mode with hot reloading. Watches for file changes and automatically rebuilds.

$ zx dev [flags]

Flags

  • --binpath, -b <path> — Binpath of the app (default: auto-detected)
  • --build-args <args> — Additional arguments to pass to zig build

serve

Serve the project for production. Builds and runs the executable.

$ zx serve [flags]

Flags

  • --port, -p <number> — Port to run the server on (default: 3000)

transpile

Transpile .zx files to Zig source code. Useful for debugging or inspecting generated code.

$ zx transpile <path> [flags]

Arguments

  • <path> — Path to .zx file or directory (required)

Flags

  • --outdir, -o <directory> — Output directory (default: ".zx")

fmtAlpha

Format ZX source code files according to ZX's formatting rules.

$ zx fmt [path] [flags]

Arguments

  • [path] — Path to .zx file or directory (optional, formats current directory if not specified)

Flags

  • --stdio — Read from stdin and write to stdout
  • --stdout — Write formatted output to stdout instead of modifying files

export

Generate static site assets. Builds your project and exports it for static hosting.

$ zx export [flags]

Flags

  • --outdir, -o <directory> — Output directory (default: "dist")
  • --binpath, -b <path> — Binpath of the app executable (default: auto-detected)
Note:Run zig build first to build the ZX executable before exporting.

bundle

Bundle the site into a deployable directory with the executable and assets.

$ zx bundle [flags]

Flags

  • --outdir, -o <directory> — Output directory (default: "bundle")
  • --binpath, -b <path> — Binpath of the app executable (default: auto-detected)
Note:Run zig build first to build the ZX executable before bundling.

update

Update the ZX dependency version in your project's build.zig.zon file.

$ zx update [flags]

Flags

  • --version, -v <version> — Version to update to (default: "latest")

upgrade

Upgrade the ZX CLI tool itself. Downloads and installs a new version of the CLI binary.

$ zx upgrade [flags]

Flags

  • --version, -v <version> — Version to upgrade to (default: "latest")

version

Show the current version of the ZX CLI tool.

$ zx version