DEV Community

Cover image for Meet zero: a batteries-included web and microservices framework for Zig.
Ng
Ng

Posted on AI-assisted

Meet zero: a batteries-included web and microservices framework for Zig.

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);
}
Enter fullscreen mode Exit fullscreen mode

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!" });
}
Enter fullscreen mode Exit fullscreen mode

configs/.env:

APP_NAME=hello
HTTP_PORT=8080
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Open another terminal and hit the service:

❯ curl localhost:8080/json
Enter fullscreen mode Exit fullscreen mode

The response is:

{"data":{"msg":"hello zero!"}}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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

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)