<p align="center">
<img src="merlion.png" alt="merjs" width="200" />
</p>
<p align="center">
<a href="https://github.com/justrach/merjs/releases/latest"><img src="https://img.shields.io/github/v/release/justrach/merjs?style=flat-square&label=version" alt="Latest Release" /></a>
<a href="https://github.com/justrach/merjs/blob/main/LICENSE"><img src="https://img.shields.io/github/license/justrach/merjs?style=flat-square" alt="License" /></a>
<img src="https://img.shields.io/badge/zig-0.17.0--dev-f7a41d?style=flat-square" alt="Zig 0.17.0-dev" />
<img src="https://img.shields.io/badge/node__modules-0_files-brightgreen?style=flat-square" alt="Zero node_modules" />
<img src="https://img.shields.io/badge/status-experimental-orange?style=flat-square" alt="Experimental" />
</p>
<h1 align="center">merjs</h1>
<h3 align="center">Next.js-style web framework. Written in Zig. Zero Node.js.</h3>
<p align="center">
File-based routing · SSR · Type-safe APIs · Hot reload · WASM client logic · Cloudflare Workers · Fastly Compute
</p>
<p align="center">
<a href="#-quick-start">Quick Start</a> ·
<a href="#-features">Features</a> ·
<a href="#-demo">Demo</a> ·
<a href="#-how-it-works">How It Works</a> ·
<a href="#-deploy">Deploy</a> ·
<a href="CHANGELOG.md">Changelog</a>
</p>
---
## 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](https://ziglang.org/download/) (master snapshot `0.17.0-dev.1862+40ebd8162`)
### Option A: One-line install (recommended)
```bash
curl -fsSL https://merjs.trilok.ai/install.sh | bash
```
Then:
```bash
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](https://github.com/justrach/merjs/releases/latest):
```bash
curl -fsSL https://raw.githubusercontent.com/justrach/merjs/main/scripts/install-mer.sh | sh
```
Or download manually, then:
```bash
mer init my-app
cd my-app
mer dev
```
### Option C: Clone the repo
```bash
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** |
| ---------------------- | -------------------------- | ------------------------------ |
<!-- BENCH:START -->
| 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** |
<!-- BENCH:END -->
> 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](https://github.com/justrach/dhi)
```zig
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
```zig
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
```zig
// wasm/counter.zig
export fn increment(n: i32) i32 { return n + 1; }
```
```bash
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](https://github.com/tailwindlabs/tailwindcss/releases) 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](https://github.com/justrach/merjs/releases/latest) — available for macOS (ARM/Intel) and Linux (x86_64/ARM64).
Or build from source:
```bash
zig build cli --release=small # → zig-out/bin/mer
```
Quick install from source checkout:
```bash
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:
```zig
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):**
```bash
mer dev
# or
zig build serve
```
Server runs in terminal. Press `Ctrl+C` to stop.
**2. Run in background with `nohup` (keeps running):**
```bash
# 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:**
```bash
# 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](https://merlionjs.com)** — the framework's own site, built with merjs.
Singapore data dashboard: **[sgdata.merlionjs.com](https://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)
```bash
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:
```bash
docker pull ghcr.io/justrach/merjs:latest
docker run --rm -p 3000:3000 ghcr.io/justrach/merjs:latest
```
### Fly.io
```bash
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
```bash
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](https://12factor.net/) — 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:
```bash
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:
```bash
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](https://github.com/fastly/Viceroy):
```bash
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](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:
```bash
zig build desktop
open zig-out/MerApp.app
```
See [examples/desktop/README.md](examples/desktop/README.md) for details.
---
## Contributing
See [CONTRIBUTING.md](CONTRIBUTING.md) for setup instructions, build commands, and branch conventions.
Quick start:
```bash
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](https://github.com/justrach/dhi)** — Pydantic-style validation for Zig
- **[Tailwind CSS v4](https://tailwindcss.com)** — standalone CLI, no npm
- **[kuri](https://github.com/justrach/kuri)** — E2E testing via headless Chrome
- **Zig 0.17.0-dev** — the whole stack
## License
MIT