justrach/merjs

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

Zig

354

222 commits

updated Aug 26, 2026

See the code

README

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)

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):

merjsNext.js
Throughput115,093 req/s~2,060 req/s
Avg latency0.39 ms~77 ms
Cold start< 5 ms~1-3 s
Binary size260 KBN/A (interpreted)
node_modules0 files~300 MB / ~85k files
Build time~3.2 s~38 s

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

merjsNext.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

cloudflare-workers
file-based-routing
no-node-modules
server-side-rendering
ssr
type-safe
wasm
webassembly
web-framework
zig

Contributors

justrach/merjs

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

Zig

354

222 commits

updated Aug 26, 2026

See the code

README

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)

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):

merjsNext.js
Throughput115,093 req/s~2,060 req/s
Avg latency0.39 ms~77 ms
Cold start< 5 ms~1-3 s
Binary size260 KBN/A (interpreted)
node_modules0 files~300 MB / ~85k files
Build time~3.2 s~38 s

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

merjsNext.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

cloudflare-workers
file-based-routing
no-node-modules
server-side-rendering
ssr
type-safe
wasm
webassembly
web-framework
zig

Contributors

Languages

Zig

95.1%

Python

2.2%

Shell

1.2%