Zen is a tiny Go-first SSR framework that glues together:
- Fiber for backend routing and request handling
- Vite for frontend dev/build behavior
- Preact for server-rendered and hydrated frontend pages
- Tailwind for styling
- Air for Go hot reload during development
Zen is not trying to replace these tools. It should make them work together with as little magic as possible.
The core philosophy is:
Developers should feel like they are using Fiber, Vite, Preact, Tailwind, and Air directly. Zen should only remove the repetitive bridge slop between them.
Do not add abstractions that make developers learn “the Zen way” for things Fiber, Vite, Preact, Tailwind, or Air already do well.
A Zen app works like this:
Browser
-> Fiber app
-> normal Fiber route handler
-> renderer.Render(c, "Page", props)
-> HTTP call to Node renderer
-> Vite/Preact SSR
-> Zen injects SSR output into frontend/index.html
-> browser hydrates Preact page
Development mode:
zen dev
-> starts dev renderer
-> starts Go app through Air
Production mode:
zen build
-> builds frontend client assets
-> builds frontend SSR bundle
-> builds Go binary
zen start
-> starts production renderer
-> starts compiled Go binary with ZEN_ENV=prod
The renderer bridge uses HTTP. Do not reintroduce stdin/stdout process communication.
Run all Go tests:
go test ./...Run Node renderer tests:
node --test js/renderers/*.test.mjs
node --test scripts/*.test.mjsBuild the Zen CLI:
go build -o ./bin/zen ./zencli/cmd/zenRun the example app in development:
cd examples/basic
../../bin/zen devBuild the example app:
cd examples/basic
../../bin/zen buildStart the example app from production artifacts:
cd examples/basic
../../bin/zen startInitialize a new project:
zen init
zen devzen init runs go mod tidy, pnpm --dir frontend install, and pnpm --dir frontend approve-builds --all after writing files.
Expected high-level structure:
.
config.go
render.go
document.go
escape.go
manifest.go
static.go
ssr_client.go
ssr_http_client.go
zencli/
go.mod
cmd/
zen/
main.go
internal/
zencli/
js/
entries/
entry-client.tsx
entry-server.tsx
renderers/
renderer-shared.mjs
dev-renderer.mjs
prod-renderer.mjs
examples/
basic/
main.go
zen.config.json
.air.toml
frontend/
package.json
vite.config.ts
src/
The root Go module is:
github.com/zenith-hosting/zen
The CLI is an in-repo Go module:
github.com/zenith-hosting/zen/zencli
Do not merge CLI implementation code into the root Zen library package. Keeping zencli as a separate subdirectory module lets the root module stay usable as a library dependency without CLI code.
- Routes
- Middleware
- Request parsing
- Forms
- Redirects
- Cookies
- Sessions
- Errors
- Static asset route registration
Zen should not wrap Fiber into a second routing framework.
Good:
app.Get("/", func(c fiber.Ctx) error {
return renderer.Render(c, "Home", props)
})Bad:
zen.Page("/", HomePage)
zen.Loader(...)
zen.Action(...)Do not add this kind of API unless explicitly requested and carefully justified.
- TypeScript/TSX transforms
- Preact plugin behavior
- Tailwind integration
- Frontend dev server behavior
- Frontend production builds
- SSR bundle generation
- HMR
Zen should not reimplement Vite behavior in Go.
- Page components
- SSR rendering
- Hydration
- Frontend component model
Zen should not invent component wrappers, custom page definitions, or a Zen-specific frontend API.
- Styling
- CSS pipeline
- Utility classes
Zen should not add its own styling abstraction.
- Go hot reload
- Rebuilding/restarting the Go app during development
Zen should only start Air as part of zen dev.
The renderer files must be available to initialized projects.
Renderer runtime files should live under the frontend package:
frontend/.zen/renderers/renderer-shared.mjs
frontend/.zen/renderers/dev-renderer.mjs
frontend/.zen/renderers/prod-renderer.mjs
The renderer process must run with:
Dir: cfg.FrontendDir
This matters because Vite is installed in:
frontend/node_modules
If the renderer is started from the project root, imports like this will fail:
import { createServer as createViteServer } from "vite";Do not move renderer runtime files back to root .zen/renderers unless dependency resolution is redesigned.
Canonical renderer and entry sources live in:
js/entries/
js/renderers/
The starter-template copies live in the zencli module:
zencli/internal/zencli/init_template/frontend/.zen/
After changing renderer or entry source files, run:
node scripts/sync-renderers.mjsFull-page responses are assembled from the developer-owned frontend document template:
frontend/index.html
Config.DocumentPath defaults to:
<ProjectRoot>/<frontendDir>/index.html
where frontendDir comes from zen.config.json and defaults to frontend.
The Go renderer reads this template for RenderPage, replaces Zen slots, and sends the final HTML response. Keep this as simple string slot replacement. Do not introduce an HTML parser, metadata DSL, layout system, Vite transformIndexHtml, or a Next-style document API unless explicitly requested and carefully justified.
Required template slots:
<!--zen:title-->
<!--zen:head-->
<!--zen:base-->
<!--zen:meta-->
<!--zen:link-->
<!--zen:style-->
<!--zen:styles-->
<!--zen:script-->
<!--zen:app-->
<!--zen:data-->
<!--zen:scripts-->Slot meanings:
<!--zen:title-->: escaped page title fromWithTitleorConfig.DefaultTitle<!--zen:head-->: rawheadreturned by the Node renderer<!--zen:base-->: Go-generated<base>tags fromWithBase/Base<!--zen:meta-->: Go-generated<meta>tags fromWithMeta/Meta<!--zen:link-->: Go-generated<link>tags fromWithLink/Link<!--zen:style-->: Go-generated<style>tag fromWithStyle/Style<!--zen:styles-->: production CSS links from the Vite manifest<!--zen:script-->: Go-generated<script>tags fromWithScript/Script<!--zen:app-->: raw Preact SSR HTML<!--zen:data-->: Go-generated safe hydration JSON script usingConfig.DataElementID<!--zen:scripts-->: Vite dev scripts or production client entry scripts
Go-owned head elements should use the structured render options:
renderer.RenderPage(c, "Home", props,
zen.WithTitle("Home"),
zen.WithBase(zen.Href("/")),
zen.WithMeta(zen.Name("description"), zen.Content("Page description")),
zen.WithLink(zen.Rel("canonical"), zen.Href("https://example.com/")),
zen.WithStyle(`body { color: red; }`),
zen.WithScript(zen.Type("application/ld+json"), zen.Text(`{"name":"Home"}`)),
)Attribute values are escaped by Zen. Use Attr(name, value) for less-common attributes; unsafe attribute names are skipped. Text is raw element text for scripts, and WithStyle takes raw CSS directly as a string.
The starter and examples should keep this shape:
<div id="app"><!--zen:app--></div>
<!--zen:data-->
<!--zen:scripts-->The current frontend hydration entry looks for:
document.getElementById("app")
document.getElementById("__ZEN_DATA__")
Do not change the template IDs without also changing the hydration entry and tests.
RenderIsland is intentionally separate. It returns a hydratable fragment from renderIslandFragment; it should not read or inject frontend/index.html.
When changing document template behavior, update:
document.go
document_test.go
head.go
render.go
render_test.go
zencli/internal/zencli/init_template/frontend/index.html
zencli/internal/zencli/init_templates_test.go
examples/basic/frontend/index.html
examples/todo/frontend/index.html
The Go app calls the renderer over HTTP.
Render endpoint:
POST /__zen/render
Health endpoint:
GET /__zen/health
Render request:
{
"url": "/users/42",
"page": "User",
"props": {
"id": "42"
}
}Successful response:
{
"html": "<main>...</main>",
"head": ""
}The HTTP protocol stops here. The Node renderer returns fragments and optional head HTML; Go owns template slot injection and the final full document response.
Error response:
{
"error": {
"message": "Unknown page: User",
"stack": "..."
}
}Keep the protocol boring. Do not introduce WebSocket, JSON-RPC, gRPC, or a custom multiplexed protocol for the render path unless there is a benchmark-backed reason.
Should create a complete runnable starter project, not just config files.
It should write:
zen.config.json
.air.toml
go.mod
main.go
package.json
frontend/package.json
frontend/tsconfig.json
frontend/vite.config.ts
frontend/index.html
frontend/src/app.css
frontend/src/pages.ts
frontend/src/pages/Home.tsx
frontend/src/pages/User.tsx
frontend/.zen/entries/entry-client.tsx
frontend/.zen/entries/entry-server.tsx
frontend/.zen/renderers/renderer-shared.mjs
frontend/.zen/renderers/dev-renderer.mjs
frontend/.zen/renderers/prod-renderer.mjs
frontend/index.html must include the required Zen document slots listed in "HTML Document Template Rules". It should not hard-code a direct .zen/entries/entry-client.tsx script tag; Zen injects the dev or production client scripts into <!--zen:scripts-->.
Then it should install dependencies, leaving the user with a working project:
go mod tidy
pnpm --dir frontend install
pnpm --dir frontend approve-builds --allzen init should refuse to overwrite existing developer-owned files.
The one exception is the Zen-managed runtime directory {frontendDir}/.zen (renderer and entry files). zen init always replaces it wholesale, removing any existing copy first, so an existing project checked out on a new machine gets runtime files matching the installed CLI. For existing projects the directory is resolved against the frontendDir from zen.config.json (defaulting to frontend); new projects always use the starter frontend/.zen.
Should only create a development environment.
It should start:
renderer: node .zen/renderers/dev-renderer.mjs
app: go tool air -c .air.toml
It should not build production artifacts.
It should not inspect ZEN_ENV=prod.
It should not run production mode.
Should build production artifacts.
It should run:
pnpm --dir frontend build
go build -o ./bin/app .
Should start production artifacts.
It should run:
renderer: node .zen/renderers/prod-renderer.mjs
app: ZEN_ENV=prod ./bin/app
zen start should not build. If artifacts are missing, it should clearly tell the user to run:
zen buildPrefer actionable errors.
Bad:
Cannot find package vite
Good:
zen: missing frontend dependencies.
Run:
pnpm --dir frontend install
Bad:
open frontend/dist/server/entry-server.js: no such file or directory
Good:
zen: missing production artifact frontend/dist/server/entry-server.js.
Run:
zen build
Bad:
renderer failed
Good:
zen renderer: Unknown page: Admin
Use test-first development for behavior changes.
For Go:
go test ./...For Node renderer behavior:
node --test js/renderers/*.test.mjs
node --test scripts/*.test.mjsFor CLI behavior, prefer small tests around pure planning functions:
devPlanstartPlanbuildPlanstarterFilesensureFrontendDependenciesensureProductionArtifacts
CLI tests live in the zencli module. Run them from that module or through the workspace:
cd zencli
go test ./...Avoid tests that require starting long-running processes unless the behavior cannot be tested another way.
Keep files focused.
Prefer small units with boring names.
Do not add clever abstractions around simple tool invocations.
Do not introduce a plugin system.
Do not introduce a route system.
Do not introduce a form system.
Do not introduce generated frontend types.
Do not turn Zen into Next.js with a Go accent.
Adding a dependency is allowed when it removes meaningful maintenance burden.
Adding a dependency is not allowed when it mostly creates a new abstraction developers have to understand.
Acceptable dependencies so far:
- Fiber
- Air
- Vite
- Preact
- Tailwind
Be cautious with protocol libraries, RPC frameworks, process managers, and anything that makes debugging less obvious.
When in doubt, choose:
boring over clever
explicit over magical
tool-native over Zen-specific
debuggable over elegant
small over complete
Zen should make this workflow pleasant:
app.Get("/", func(c fiber.Ctx) error {
return renderer.Render(c, "Home", props)
})Not replace it with a shiny proprietary lifecycle.
If a feature requires users to learn a new Zen concept before they can understand what Fiber, Vite, or Preact is doing, the feature is probably too big or pointed in the wrong direction.
Run:
go test ./...
node --test js/renderers/*.test.mjs
node --test scripts/*.test.mjsWhen changing the CLI, also run:
go build -o ./bin/zen ./zencli/cmd/zenWhen changing starter templates, validate:
tmpdir="$(mktemp -d)"
cd "$tmpdir"
/path/to/zen/bin/zen init
/path/to/zen/bin/zen devWhen changing production behavior, validate:
zen build
zen startThen request:
curl -i http://127.0.0.1:3000/In dev output, the HTML should include Vite client scripts.
The response should preserve the surrounding frontend/index.html template shell and include SSR HTML in <!--zen:app-->.
In production output, the HTML should not include:
/@vite/client
Zen is not:
- a full-stack application framework
- a frontend router
- a backend router
- an auth framework
- a database framework
- a form framework
- a deployment platform
- a replacement for Vite
- a replacement for Fiber
- a replacement for Air
Zen is glue.
Keep it that way.