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.
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:
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:
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!
New Project
For a new project, first initialize a Zig project:
Existing Project
For an existing Zig project, add the following to your 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:
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 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:
- VS Code Marketplace
- Open VSX Registry (for Cursor and other forks)
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):
- Clone:
git clone https://github.com/nurulhudaapon/zx.git - Open Extensions panel (Cmd/Ctrl + Shift + P)
- Select "Install Dev Extension"
- Navigate to
editors/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>);
}
const zx = @import("zx");
<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>
<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/:idsite/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, 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.
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 styleszx.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:
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" />npm install tailwindcss @tailwindcss/cliesbuild Plugin
The esbuild builtin plugin bundles TypeScript or JavaScript for client-side interactivity:
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>npm install esbuildSee 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: