Skip to content

Latest commit

 

History

222 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

merjs

Latest Release License Zig 0.17.0-dev Zero node_modules Experimental

merjs

Next.js-style web framework. Written in Zig. Zero Node.js.

File-based routing · SSR · Type-safe APIs · Hot reload · WASM client logic · Cloudflare Workers · Fastly Compute

Quick Start · Features · Demo · How It Works · Deploy · Changelog


The Problem

Every Node.js web framework drags in 300 MB of node_modules, a 1-3s cold start, and a JavaScript runtime you never asked for. The reason JS won the server was simple: it was already in the browser.

WebAssembly changes that. Zig compiles to wasm32-freestanding with a single flag. You can write client-side logic in Zig, compile it to .wasm, and ship it directly to the browser — no transpiler, no bundler, no runtime.

app/page.zig    →  native binary  (SSR, Zig HTTP server, < 5ms cold start)
wasm/logic.zig  →  logic.wasm     (client interactivity, runs in browser)

merjs is exploring whether you can get the full Next.js developer experience — file-based routing, SSR, type-safe APIs, hot reload — without any of its runtime weight.


Quick Start

Requirements: Zig 0.17.0-dev (master snapshot 0.17.0-dev.1862+40ebd8162)

Option A: One-line install (recommended)

curl -fsSL https://merjs.trilok.ai/install.sh | bash

Then:

mer init my-app
cd my-app
mer dev            # dev server on :3000 with hot reload

Option B: mer CLI from releases

Install the latest mer binary from releases:

curl -fsSL https://raw.githubusercontent.com/justrach/merjs/main/scripts/install-mer.sh | sh

Or download manually, then:

mer init my-app
cd my-app
mer dev

Option C: Clone the repo

git clone https://github.com/justrach/merjs.git
cd merjs

zig build codegen   # scan app/ and api/, generate routes
zig build wasm      # compile wasm/ → public/*.wasm
zig build serve     # dev server on :3000 with hot reload

Optional: zig build css compiles Tailwind v4 (no npm). The standalone CLI is auto-downloaded on first run, or you can install it manually via mer add css.

Visit http://localhost:3000.


Performance

Local benchmarks (Apple M-series, wrk -t4 -c50 -d10s, --release=small):

merjs Next.js
Throughput 115,093 req/s ~2,060 req/s
Avg latency 0.39 ms ~77 ms
Cold start < 5 ms ~1-3 s
Binary size 260 KB N/A (interpreted)
node_modules 0 files ~300 MB / ~85k files
Build time ~3.2 s ~38 s

CI benchmarks (GitHub Actions, auto-updated on each push to main):

merjs Next.js

| Requests/sec (wrk) | 2438.89 req/s | 4169.84 req/s | | Avg latency | 40.90ms 1.65ms | 66.77ms 200.03ms | | RAM usage (under load) | 10.3 MB | 72.9 MB | | Build time | 69921 ms | 30004 ms |

merjs is an early experiment — Next.js is mature and production-grade. Local and CI numbers differ due to hardware (Apple Silicon vs shared GitHub Actions VM).


Features

File-based routing — like Next.js

app/index.zig       →  /
app/dashboard.zig   →  /dashboard
app/users/[id].zig  →  /users/:id
api/users.zig       →  /api/users

Drop a .zig file, export render(), get a route. The codegen tool writes src/generated/routes.zig — a static dispatch table with zero runtime cost.

Type-safe APIs via dhi

const mer = @import("mer");

const UserModel = mer.dhi.Model("User", .{
    .name  = mer.dhi.Str(.{ .min_length = 1, .max_length = 100 }),
    .email = mer.dhi.EmailStr,
    .age   = mer.dhi.Int(i32, .{ .gt = 0, .le = 150 }),
});

pub fn render(req: mer.Request) mer.Response {
    const user = try UserModel.parse(req.body);
    return mer.typedJson(req.allocator, UserResponse{ .name = user.name });
}

Constraints are checked comptime. Validation runs at parse time. No hand-rolled JSON.

HTML builder — comptime, type-safe

const h = mer.h;

fn page() h.Node {
    return h.div(.{ .class = "container" }, .{
        h.h1(.{}, "Hello from Zig"),
        h.p(.{}, "No virtual DOM. No hydration. Just HTML."),
        h.a(.{ .href = "/about" }, "Learn more"),
    });
}

comptime { mer.lint.check(page_node); } // catches missing alts, empty titles, etc.

WASM client logic — no bundler

// wasm/counter.zig
export fn increment(n: i32) i32 { return n + 1; }
zig build wasm   # → public/counter.wasm

Load in the browser with WebAssembly.instantiateStreaming. That's it.

Hot reload — no daemon

The watcher polls app/ every 300ms, detects mtime changes, and fires an SSE event. Browser reloads. No webpack, no esbuild, no separate process.

Tailwind v4 — zero Node.js

Download the standalone Tailwind v4 CLI and place it at tools/tailwindcss. Then zig build css runs it — no npm install.


mer CLI

mer init <name>      scaffold a new project (131 KB binary, all templates embedded)
mer dev [--port N]   codegen + dev server with hot reload
mer build            production build (ReleaseSmall + prerender)
mer add <feature>    add optional features (css, wasm, worker)
mer update           update merjs dependency to latest
mer --version        print version

Download from releases — available for macOS (ARM/Intel) and Linux (x86_64/ARM64).

Or build from source:

zig build cli --release=small   # → zig-out/bin/mer

Quick install from source checkout:

zig build cli --release=small
install -m 755 zig-out/bin/mer /usr/local/bin/mer

If you use merjs as a Zig dependency, prefer its exported API instead of reaching into package paths directly:

const merjs_dep = b.dependency("merjs", .{});
const mer_mod = merjs_dep.module("mer");         // framework public API
const runtime_mod = merjs_dep.module("runtime"); // std.Io runtime instance
const server_mod = merjs_dep.module("server");   // HTTP server entry
const codegen_mod = merjs_dep.module("codegen"); // route generator
const worker_mod = merjs_dep.module("worker");   // Cloudflare Workers entry

Every entry point is a named module, so consumer build.zig files never reach into internal paths like src/main.zig or tools/codegen.zig. Fresh mer init apps still vendor their own tools/codegen.zig by default, so route generation works even without the module.


Troubleshooting

Server crashes or "Connection refused"

Problem: Server stops when terminal closes or shows "ERR_CONNECTION_REFUSED"

Solutions:

1. Run in foreground (development):

mer dev
# or
zig build serve

Server runs in terminal. Press Ctrl+C to stop.

2. Run in background with nohup (keeps running):

# Build first
zig build -Doptimize=ReleaseFast

# Run with nohup (won't stop when terminal closes)
nohup ./zig-out/bin/merjs --port 3000 --no-dev > merjs.log 2>&1 &

# Check it's running
curl http://localhost:3000

# View logs
tail -f merjs.log

# Stop server
pkill -f "merjs"

3. Common fixes:

# Port already in use?
lsof -i :3000
kill -9 <PID>

# Or use different port
./zig-out/bin/merjs --port 3001 --no-dev

# Check binary exists
ls -la zig-out/bin/merjs

# Clean build
rm -rf .zig-cache zig-out
zig build -Doptimize=ReleaseFast

Demo

Live demo: merlionjs.com — the framework's own site, built with merjs.

Singapore data dashboard: sgdata.merlionjs.com — real-time government data, SSR pages, JSON APIs, WASM, RAG-powered AI chat. Deployed on Cloudflare Workers. Zero Node.js.


Deploy

merjs ships ready-to-go configs for every major host. The same Docker image works everywhere; PaaS providers inject PORT and main.zig reads it. Health checks hit /_mer/health (always available, no extra setup).

Docker (any host, any machine)

docker build -t merjs .
docker run --rm -p 3000:3000 merjs
# or:  docker compose up --build

The image:

  • Pins Zig 0.17.0-dev (matches build.zig.zon)
  • Runs as a non-root user (uid 10001)
  • Uses tini as PID 1 so Ctrl-C and orchestrator stop signals work
  • Has a HEALTHCHECK against /_mer/health
  • Multi-arch: linux/amd64 + linux/arm64

A multi-arch image is published to GHCR on every tag:

docker pull ghcr.io/justrach/merjs:latest
docker run --rm -p 3000:3000 ghcr.io/justrach/merjs:latest

Fly.io

flyctl launch --copy-config --no-deploy
flyctl deploy

fly.toml is included. Fly injects PORT and the health check hits /_mer/health.

Render.com

render.yaml is included — push the repo and click New Blueprint Instance in the Render dashboard.

Railway

railway.json is included — point Railway at the repo and it picks up the Dockerfile + healthcheck automatically.

DigitalOcean App Platform

doctl apps create --spec .do/app.yaml

Heroku-style PaaS (Procfile)

A Procfile is included for any platform that respects the Heroku 12-factor process model — it runs the binary directly with --no-dev, and the platform's PORT env var binds correctly.

Cloudflare Workers (zero cold start, edge)

  1. Edit worker/wrangler.toml — set your project name, route/domain, and any R2 bindings.
  2. Build and deploy:
zig build worker        # compile to WASM
cd worker
wrangler deploy

If your routes use secrets (API keys, etc.), set them first: wrangler secret put MY_API_KEY

The worker/worker.js shim handles the fetch event and passes requests to the WASM binary.

Fastly Compute

  1. Edit examples/site/fastly/fastly.toml — set your service name and backend origins.
  2. Build and deploy:
zig build fastly        # compile to WASI WASM
cd examples/site/fastly
fastly compute deploy

The Fastly target compiles to wasm32-wasi and runs natively on Fastly's Compute platform — no JS shim needed. Static assets from public/ are embedded directly into the WASM binary for zero-latency serving. Outbound HTTP requests (for SSR data fetching) are routed through Fastly backends configured in fastly.toml.

To test locally with Viceroy:

cd examples/site/fastly
viceroy ./merjs.wasm -C fastly.toml
# → http://127.0.0.1:7676

Runtime selection (io_uring)

src/runtime.zig exposes a single runtime.io instance and a Backend enum (evented / threaded). On startup, you'll see:

info(runtime): io backend: Threaded (blocking syscalls)

The framework is wired to opportunistically pick Evented (Linux io_uring) when the toolchain supports it, and gracefully fall back to Threaded if io_uring init fails (old kernel, restricted seccomp, sandboxed container, …) so the same binary boots on every host.

Zig 0.17.0-dev fixes the 0.16.0 Uring compile bug (error.ReadOnlyFileSystem mapped instead of forwarded into Dir.OpenError). Evented still cannot listen: std.Io.Uring wires netListenIp / netAccept to stubs that always return error.NetworkDown. stdlib_evented_works stays false so the server uses Threaded until those vtable slots are implemented. The init-time io_uring fallback remains in place for when they are.


How It Works

zig build codegen
  └── scans app/ + api/
  └── writes src/generated/routes.zig  (static dispatch table)

zig build serve
  └── compiles server binary
  └── binds :3000
  └── serves static files from public/ (in-memory cache)
  └── dispatches requests → hash-map route lookup (O(1) exact match)
  └── SSE watcher on app/ for hot reload

zig build worker
  └── compiles to wasm32-freestanding
  └── worker/worker.js wraps WASM in a CF Workers fetch handler

zig build fastly
  └── compiles to wasm32-wasi
  └── embeds public/ assets into the binary
  └── runs natively on Fastly Compute (no JS shim)

Thread model: std.Thread.Pool with CPU-count-based sizing, kernel backlog 512, 64 KB write buffers.

Layout convention: Pages returning HTML fragments are auto-wrapped by app/layout.zig. Pages returning full documents (starting with <!) bypass it.


Structure

merjs/
├── src/                    # framework runtime
│   ├── mer.zig             # public API: Request, Response, h, lint, dhi
│   ├── server.zig          # HTTP server (thread pool, hash-map router)
│   ├── ssr.zig             # SSR engine + router builder
│   ├── html.zig            # comptime HTML builder DSL
│   ├── html_lint.zig       # comptime HTML linter
│   ├── watcher.zig         # file watcher + SSE hot reload
│   ├── prerender.zig       # SSG: render pages at build time → dist/
│   └── generated/
│       └── routes.zig      # codegen output (zig build codegen) — do not edit
├── cli.zig                 # `mer` CLI entry point (init, dev, build)
├── packages/
│   └── merjs-auth/         # optional auth package
├── examples/
│   ├── desktop/            # native macOS app (experimental) — zig build desktop
│   ├── kanban/             # Kanban board demo (merboard.merlionjs.com)
│   ├── singapore-data-dashboard/
│   └── site/fastly/        # Fastly Compute deploy target — zig build fastly
├── tools/
│   ├── codegen.zig
│   └── tailwindcss         # Tailwind v4 standalone CLI (no npm)
│
│   ── merjs website (dogfooding the framework) ──
├── app/                    # website pages
├── api/                    # website API routes
├── wasm/                   # website client WASM modules
├── worker/                 # Cloudflare Workers deploy target
├── public/                 # static assets
│
├── docs/
│   └── architecture.md     # deep-dive on internals
├── .githooks/              # pre-commit (fmt+build) + pre-push (test)
└── CHANGELOG.md

See docs/architecture.md for a full breakdown of the request lifecycle, module system, streaming SSR, and desktop bridge.


Desktop (experimental)

merjs can run as a native macOS app — no Electron, no npm, one binary:

zig build desktop
open zig-out/MerApp.app

See examples/desktop/README.md for details.


Contributing

See CONTRIBUTING.md for setup instructions, build commands, and branch conventions.

Quick start:

git clone https://github.com/justrach/merjs.git
cd merjs
git config core.hooksPath .githooks   # enable pre-commit (fmt+build) and pre-push (test)
zig build test                        # run unit tests

Open an issue before submitting a large PR.


Credits

  • dhi — Pydantic-style validation for Zig
  • Tailwind CSS v4 — standalone CLI, no npm
  • kuri — E2E testing via headless Chrome
  • Zig 0.17.0-dev — the whole stack

License

MIT

About

A Zig-native web framework. File-based routing, SSR, type-safe APIs, WASM client interactivity. No Node. No npm. Just zig build serve.

Topics

Resources

Contributing

Security policy

Stars

354 stars

Watchers

1 watching

Forks

Releases

Packages

Contributors

Languages