zero-native: Desktop Apps Without the Chromium Tax
If you have ever shipped an Electron app, you know the quiet embarrassment of a "Hello World" that weighs more than 100 MB and parks a few hundred megabytes in RAM. That is the Chromium tax: every Electron app bundles a full copy of Chromium and Node, whether it needs them or not. zero-native is a brand-new framework from Vercel Labs that takes the opposite bet. You write your UI in whatever web framework you already love, and zero-native wraps it in a tiny native window driven by a Zig core that talks directly to the operating system's built-in WebView.
The result is the part of Electron you actually wanted — web UI in a real desktop window — without dragging an entire browser along for the ride. Binaries stay small, memory stays low, cold starts feel instant, and when you genuinely need pixel-identical rendering everywhere, you can flip a single config field to bundle Chromium instead. This article is a tour of the zero-native model: how an app is described, how JavaScript talks to Zig, and what the headline feature of version 0.2.0 (layered WebViews) unlocks.
One honest caveat up front: zero-native is experimental and only weeks old at the time of writing. The first release landed in early May 2026 and 0.2.0 followed days later. It is a fantastic thing to kick the tires on, but treat the APIs as unstable and keep production apps on something mature like Tauri or Electron for now.
Why Skip the Bundled Browser
The classic problem is "I want web UI inside a desktop app." Electron solves it by shipping Chromium with every app, which guarantees identical rendering but pays for it in size and memory. zero-native answers the same problem from a different angle:
- System WebView by default. It renders through WKWebView on macOS, WebKitGTK on Linux, and the WebView2/Edge path on Windows. No bundled browser means tiny binaries, low memory, and fast launch.
- A Zig native core. Zig compiles fast and calls C directly, so the native layer gets clean interop with platform SDKs, codecs, and system libraries without a heavy runtime.
- Bring your own web stack. Next.js, React, Svelte, Vue — the native layer stays thin and framework-agnostic.
- An escape hatch to Chromium. When "smallest possible" loses to "renders identically everywhere," you opt into bundling Chromium via CEF (the Chromium Embedded Framework) per target. Tauri does not offer this out of the box.
- An explicit security model. The WebView is treated as untrusted by default. Native commands, navigation, external links, and window APIs are all policy-controlled, opt-in, and size-limited.
It sits squarely in the same neighborhood as Tauri and Electrobun — lightweight, system-WebView-first alternatives to Electron — but with the native layer written in Zig rather than Rust.
Getting Set Up
The npm package is a thin CLI shim; the real build runs through Zig, so you will need the Zig toolchain installed alongside Node. Install the CLI globally:
npm install -g zero-native
Or with yarn:
yarn global add zero-native
Then scaffold and run an app. The --frontend flag picks one of the starter templates (next, react, svelte, or vue):
zero-native init my_app --frontend next
cd my_app
zig build run
That first zig build run does a lot: it installs the frontend dependencies, builds the generated native shell, and opens a desktop window rendering your web UI. Note that zig build is the actual run command, not an npm script — this is a Zig project with a web frontend living inside it, not the other way around.
The Shape of an App
Three concepts carry most of the mental model.
The App is the small Zig object describing your application: its name, where the WebView loads from, lifecycle hooks, and any optional native services. The Runtime owns the event loop, the windows, the bridge dispatch, and platform services. And the WebViewSource tells the runtime what to load — inline HTML, a remote URL, or packaged frontend assets served from a local app origin (zero://app).
Most project-level behavior, though, lives declaratively in app.zon, the manifest written in Zig Object Notation. This single file declares metadata, windows, the web engine choice, security policy, and bridge permissions:
.{
.id = "com.example.my-app",
.name = "my-app",
.display_name = "My App",
.version = "0.1.0",
.web_engine = "system",
.permissions = .{ "window" },
.capabilities = .{ "webview", "js_bridge" },
.security = .{
.navigation = .{
.allowed_origins = .{ "zero://app", "http://127.0.0.1:5173" },
},
},
.windows = .{
.{ .label = "main", .title = "My App", .width = 960, .height = 640 },
},
}
The .web_engine field is the lever that decides everything about your bundle. Leave it as "system" for the platform WebView and the smallest possible app. Switch it to "chromium" (with an accompanying .cef config) to bundle Chromium on supported targets when you need rendering to be identical everywhere. The .security.navigation.allowed_origins list is your allowlist of URLs the WebView is permitted to navigate to — in development that typically includes your local dev server, while production loads from zero://app.
Talking to Zig From the Browser
A web UI is only half a desktop app; the other half is reaching into native capabilities. zero-native does this through a single, deliberately narrow bridge: window.zero.invoke(). From JavaScript it looks like an ordinary async call:
interface PingResult {
message: string;
count: number;
}
const result = (await window.zero.invoke("native.ping", {
source: "webview",
})) as PingResult;
console.log(result); // { message: "pong", count: 1 }
try {
await window.zero.invoke("native.ping", {});
} catch (error) {
const e = error as { code: string; message: string };
console.error(e.code, e.message);
}
Every call is size-limited, origin-checked, permission-checked, and routed only to handlers you have explicitly registered. On the Zig side, a handler receives the invocation and writes a JSON response into a provided buffer:
fn ping(context: *anyopaque, invocation: zero_native.bridge.Invocation,
output: []u8) anyerror![]const u8 {
const self: *App = @ptrCast(@alignCast(context));
self.ping_count += 1;
return std.fmt.bufPrint(output, "{{\"message\":\"pong\",\"count\":{d}}}",
.{self.ping_count});
}
Handlers are wired into a dispatcher that pairs an explicit policy with a registry, so nothing is reachable from the WebView unless you have both registered the handler and allowed the command:
fn bridge(self: *App) zero_native.BridgeDispatcher {
self.handlers = .{.{ .name = "native.ping", .context = self, .invoke_fn = ping }};
return .{
.policy = .{ .enabled = true, .commands = &policies },
.registry = .{ .handlers = &self.handlers },
};
}
Two things are worth calling out. First, this is real Zig — manual buffer handling, manual JSON formatting, explicit pointer casts. If you do not already know Zig, any non-trivial native logic carries a learning curve, and that is the honest cost of the model. Second, the bridge is intentionally small: messages are capped at roughly 16 KiB inbound and 12 KiB outbound. That is plenty for commands and results, but large payloads (file contents, image blobs) need chunking or a different transport. These limits may shift before 1.0.
Stacking WebViews With 0.2.0
The headline feature of version 0.2.0 is the layered WebView runtime. Instead of one WebView per window, each native window is now modeled as a stack of named WebViews: a reserved startup main WebView plus any number of child WebViews, each with its own frame, layer, zoom, transparency, navigation policy, and lifecycle. This is what you need to build browser-style chrome, or to embed untrusted third-party content beside your trusted UI.
The whole surface is exposed to JavaScript under window.zero.webviews.*:
const child = await window.zero.webviews.create({
url: "https://example.com",
bridge: false,
});
await window.zero.webviews.setFrame(child.id, {
x: 0,
y: 48,
width: 800,
height: 600,
});
await window.zero.webviews.navigate(child.id, "https://example.org");
await window.zero.webviews.close(child.id);
The security defaults here are the interesting part. Child WebViews are bridge-isolated by default — they cannot reach your native handlers at all unless your trusted chrome explicitly opts a child in with bridge: true. Navigation policy is enforced on every child URL, so an embedded page cannot wander off to wherever it likes. The pattern this enables is exactly a minimal browser: your main WebView draws the toolbar and tabs (trusted, with bridge access), while child WebViews render the actual pages (untrusted, isolated, sandboxed by navigation rules). The repo ships a browser example demonstrating the whole thing, runnable with zig build run-browser.
Choosing Your Renderer Per Target
Because the engine choice lives in app.zon, you can make rendering decisions deliberately rather than inheriting a browser by default. For an internal tool where you control the install base, .web_engine = "system" gives you the smallest, leanest binary that uses whatever WebView ships with the OS. For a consumer app where a CSS feature renders differently on WebKitGTK than on the Windows path, you reach for Chromium:
.{
.web_engine = "chromium",
.cef = .{
.runtime = "cef-144.0.6",
},
// ...rest of the manifest
}
zero-native distributes Chromium as a tagged CEF runtime (a recent build pairs cef-144.0.6 with chromium-144.0.7559.59) alongside the source. The tradeoff is the familiar one: you trade a tiny system-WebView footprint for a larger, pinned, consistent Chromium. The win is that you make this choice with a single field, per target, without rewriting your app — and you can mix strategies as CEF support matures across platforms, with macOS currently leading.
The native core's Zig foundation is what makes the rest of this comfortable. Direct C interop means reaching for platform SDKs, codecs, and native libraries is a normal C call rather than a foreign-function gymnastics routine, and Zig's fast compiles keep the rebuild loop tight even though you are building a native binary on every change.
Should You Reach For It
zero-native is a genuinely exciting take on the desktop-web problem. Reach for it when you want Electron-style web UIs without the Chromium tax, when you like or want to learn Zig and value its fast compiles and clean C interop, when you want a single escape hatch to bundled Chromium for the screens where consistent rendering matters, or when you need multiple layered and isolated WebViews per window. The Vercel Labs association gives it real credibility for kicking the tires.
That said, be clear-eyed about maturity. This is a pre-release project that is only weeks old, with a small contributor base, a fast-moving API, and a mobile story that is still just C-ABI embedding examples rather than a turnkey framework. Anything shipping to real users today is better served by Tauri or Electron, and teams unwilling to touch Zig will feel friction the moment they leave the happy path. As a "Labs" project, its long-term trajectory is genuinely uncertain — it could graduate, stagnate, or be archived.
But if you are the sort of developer who enjoys being early, who likes small fast binaries, and who is curious what desktop apps look like when the browser is an option rather than a mandate, zero-native is one of the most interesting things to land in this space in a long time. Install the CLI, scaffold a starter, and watch a real desktop window open in a fraction of the size you are used to. That moment alone is worth the trip.