Most Zig web content stops at a router. You get a way to match a path and write a response, then you are on your own for databases, queues, auth, and observability.
Zero takes the opposite stance. It is a framework that already wired those things in, and it turns them on through configuration instead of code.
This post walks through what zero is, how it is built, and what using it actually looks like. Everything below was run on Zig 0.16.0.
Why a framework and why Zig
Zig is a systems language with no garbage collector and no hidden runtime. You control memory by hand, the compiler helps you stay safe, and the result is a native binary with predictable latency. The tradeoff has been the ecosystem. For web work you usually assemble the pieces yourself.
Zero closes that gap.
It gives you one binary that includes the building blocks of a microservice: HTTP, SQL and NoSQL stores, caches, pub/sub, auth, cron, WebSockets, GraphQL, Protobuf, metrics, and tracing.
The framework is pinned to Zig 0.16.0 and ships with a real test suite, reproducible benchmarks, and soak and chaos runs.
The one-line summary: Go's ergonomics without its runtime, Node's speed without its footprint.
How the pieces fit together
A zero app has three main ideas.
First, the App. You create it with zero.App.new, register routes and middleware on it, and call app.run(). The app owns the server and the lifecycle.
Second, the Context. Every handler receives a Context that carries the request and the response and handles the stores and services you enabled: SQL connections, the Redis cache, the graph store, pub/sub clients, and file stores. You reach them through the context instead of constructing clients yourself.
Third, configuration. Almost everything is opt-in through a .env file. You do not import a Postgres client and write connection code to use Postgres. You set keys in .env, and the framework builds and injects the client. This is the core design decision, and it is why the examples stay short.
The smallest working app
The README's quickstart shows src/main.zig and configs/.env but leaves out one required file. You also need a build.zig that declares the dependency and exposes the zero import. The full minimal app is
build.zig:
const std = @import("std");
pub fn build(b: *std.Build) void {
const target = b.standardTargetOptions(.{});
const optimize = b.standardOptimizeOption(.{});
const zero = b.dependency("zero", .{});
const exe = b.addExecutable(.{
.name = "demo",
.root_module = b.createModule(.{
.root_source_file = b.path("src/main.zig"),
.target = target,
.optimize = optimize,
}),
});
exe.root_module.addImport("zero", zero.module("zero"));
b.installArtifact(exe);
const run_cmd = b.addRunArtifact(exe);
run_cmd.step.dependOn(b.getInstallStep());
const run_step = b.step("run", "Run the demo http server");
run_step.dependOn(&run_cmd.step);
}
src/main.zig:
const std = @import("std");
const zero = @import("zero");
pub const std_options: std.Options = .{ .logFn = zero.logger.custom };
pub fn main(init: std.process.Init) !void {
var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
defer arena.deinit();
const app = try zero.App.new(arena.allocator(), init.io, init.environ_map);
try app.get("/json", jsonResponse);
try app.run();
}
fn jsonResponse(ctx: *zero.Context) !void {
try ctx.json(.{ .msg = "hello zero!" });
}
configs/.env:
APP_NAME=hello
HTTP_PORT=8080
Then:
❯ zig fetch --save https://github.com/im-ng/zero/archive/refs/heads/main.zip
❯ zig build run
INFO [07:22:46] Loaded config from file: ./configs/.env
INFO [07:22:46] container is being created
INFO [07:22:46] no authentication mode found and disabled.
INFO [07:22:46] hello-zero app pid 238862
INFO [07:22:46] registered static files from directory ./static
INFO [07:22:46] Starting server on port: 8080
INFO [07:22:55] 05f702f28c2c44c39d1a1a65d738b8d4 200 0ms GET /json
Open another terminal and hit the service:
❯ curl localhost:8080/json
The response is:
{"data":{"msg":"hello zero!"}}
Notice the body is wrapped in a data field. That is the real output. Keep it in mind when you write tests or client code.
Adding a database without changing your code
This is where the config-driven design shows. Suppose you want Postgres. You add keys to .env:
DB_DIALECT=postgres
POSTGRES_HOST=127.0.0.1
POSTGRES_PORT=5432
POSTGRES_USER=app
POSTGRES_PASSWORD=secret
POSTGRES_DB=app
The framework builds the client and makes it available in the context. Your handler then calls it:
fn users(ctx: *zero.Context) !void {
const db = ctx.SQL orelse return error.NoDatabase;
const rows = try db.query("select id, name from users limit 10");
try ctx.json(rows);
}
No connection string parsing. No pool setup. The same pattern covers SQLite, DuckDB, Cassandra, Redis, InfluxDB, and Solr. You switch backends by changing .env, not by rewriting imports.
Auth, out of the box
Auth modes are Basic, API Key, and OAuth 2.0. You enable one in .env:
AUTH_MODE=Basic
BASIC_USER=admin
BASIC_PASS=change-me
Then protect routes with the auth middleware. The framework validates the request before your handler runs, so the handler only sees authorized calls.
If you add a service client for outbound calls, the same auth story applies: the client can attach Basic, API Key, or OAuth to every request, and OAuth tokens are cached and refreshed for you.
Observability from the first request
Zero is observable by default. It writes structured JSON logs with a trace ID per request. It exposes Prometheus metrics, and those metrics live on a separate port so they do not mix with your app traffic. On the default setup:
curl localhost:8080/.well-known/health
# => {"name":"hello","version":"dev","status":"UP","components":{}}
curl localhost:2121/metrics
# => app_info{app_name="hello",app_version="dev",zero_version="0.5.3"} 1
Two things to know. The health endpoint is /.well-known/health, not /health, which returns 404 by design. And the metrics port is 2121, not the app port. Both are easy to miss if you only read the short README.
Distributed tracing and request-level tracing are included as well, so you can follow a request across handlers and downstream calls without adding a library.
Resilience
Production services fail, and zero treats that as a first-class concern. Outbound service calls sit behind a circuit breaker that moves through closed, open, and half-open states. Requests have timeouts and bulkheads.
Pub/sub clients reconnect and support a dead-letter path. The point is that the framework gives you the defaults a real deployment needs instead of leaving them for later.
What it costs you
Zero is pre-1.0 and still moves fast. The public API changes between minor releases.
For production today, pin the current release tag, which is v0.5.3, or use the stable-* branch. Main tracks the next version. The API is expected to settle as it heads toward a stable release. That caveat is not a marketing line. It is the difference between a fun project and one you bet your service on.
Where to go next
- Docs and full feature list: zerofmk.in
- Source: github.com/im-ng/zero
- 30 runnable example apps live in the repo under
examples/ - Preview introduction video of zero
The fastest way to judge a framework is to run its smallest example and watch what it gives you for free. Do the 60-second path above, then turn on a database and auth with two lines of .env.
If something is missing for a backend you would ship, that is the feedback that shapes the next release.
Top comments (0)