Command line
The zerolith binary is written in Rust and covers the parts of development that need a process outside the browser:
| Command | What it does |
|---|---|
zerolith dev |
Serves the project with live reload while you work |
zerolith serve |
Serves the project the same way, without watching it |
zerolith build |
Bundles the application for production into dist/ |
zerolith preview |
Serves a build as a static host would, to check it before deploying |
zerolith new |
Writes a starter application or the fullstack example |
zerolith skill |
Writes the zerolith skill for coding agents into a project |
zerolith importmap |
Vendors the packages and writes an import map with integrity hashes |
zerolith doctor |
Checks a project and reports coded findings |
Installing
| Where | How |
|---|---|
| Anywhere npm runs, on Linux (glibc), macOS, or Windows, x64 or arm64 | npm install --global @zerolith/cli@alpha puts zerolith on the PATH |
| Inside a project | @zerolith/cli as a development dependency, as every starter has it, runs as npx zerolith and through the npm scripts |
| Elsewhere, and on musl systems such as Alpine | cargo install zerolith --locked builds the same crate from crates.io, with the dependencies it was tested with, and ZEROLITH_BINARY_PATH tells the npm package where it is |
@zerolith/cli is a thin package whose optional dependencies carry the prebuilt binary for each platform, so npm installs only the one it needs.
Every command opens with the zerolith badge and the command's name, and colors what follows by meaning: addresses and finished steps in green, errors in red, warnings in yellow, and labels and details dimmed. The colors are dropped when NO_COLOR is set or the output goes to a file or another program, so logs and CI output stay plain text, and CLICOLOR_FORCE=1 keeps them.
dev, serve, and preview
zerolith dev
zerolith dev my-app --port 3000
zerolith dev --base /app/ --proxy /api=http://127.0.0.1:3000
zerolith previewdev serves the project with live reload. The page names its entry module, such as <script type="module" src="/src/main.ts"></script>, and nothing else; the server answers each request the way the build will see it:
| A request for | Is answered with |
|---|---|
A .ts, .tsx, or .jsx file |
JavaScript, with types stripped through oxc and JSX lowered to the same html calls the build produces; an import used only as types goes with them, as in build |
A package, as import { html } from "zerolith" |
The module from node_modules, found with the resolver and conditions build uses, the development build of zerolith included, so the page needs no import map and a new file needs nothing but an import |
A stylesheet imported from a module, as import "./card.css" |
The rules, applied through a <style> element |
A stylesheet imported with ?scoped, as import card from "./card.css?scoped" |
The styles a css literal gives, with local @imports inlined; an edit to the file or to one it imports replaces the rules in place |
| An image, font, or other file imported from code | Its URL; ?url gives any file's URL, ?raw its text, and a JSON import its value, all as in build. @zerolith/cli/assets, which the starter's src/env.d.ts references, types these imports |
A file in public/ |
The file, served at the root |
A client-side route, such as /notes/abc |
The application's index.html |
| A package outside the project folder, such as a workspace package in a monorepo | The module, served under /@fs/ from the folders of the packages that imports resolved to, and from nowhere else |
A CommonJS package, which a browser cannot load as a module, is reported by name; import the package's ES module build instead.
Live reload
The server injects a small module into HTML pages and announces file changes over server-sent events. The terminal shows a line for each change:
| A change to | Does |
|---|---|
| A stylesheet, linked or imported | update: the stylesheet is swapped in place |
| Any other file | reload: the page reloads |
A file inside node_modules, dist, or another tooling folder |
Nothing |
Before reloading, the client saves the values of every named signal, and the development build of zerolith restores them when signals with the same names are created again, so a form draft or a selected tab survives an edit with no component boundary:
const draft = signal("", { name: "draft" }); // restored across dev reloadsUnder Vite, the plugin in @zerolith/vite does the same restore without reloading the page; see Vite.
Serving and previewing
serve serves the project the way dev does, without watching it. preview serves a build's output folder, dist unless --out names another, as a static host would: every file as it is, and index.html for any path that names no file, so client-side routes load directly.
| Command | Default port |
|---|---|
dev and serve |
8080 |
preview |
4173, so it can run next to dev |
When the default port is taken, as 8080 often is on Windows, the command takes the next free one and says so in the banner. A port given with --port or in zerolith.json is used as it is, and a taken one fails with a message that says so.
| Option | What it does |
|---|---|
--port <number> |
Listens on that port |
--base /app/ |
Serves the application under a path, as a backend or a reverse proxy will: the site root redirects there, paths under it load the project with the same route fallback, and import.meta.env.BASE_URL carries it for the router |
--proxy /api=http://127.0.0.1:3000 |
Forwards every request under /api to a backend, so the page reaches its API on its own origin; repeat it for more paths |
--out <folder> |
For preview: the folder to serve |
Building and serving describes the proxy in full. On loopback, the server also answers /.well-known/appspecific/com.chrome.devtools.json, which lets Chrome DevTools edit the project's files in place.
zerolith.json
The settings the commands share live in zerolith.json next to index.html, so they are written once instead of on every command line. A flag overrides the file, and the starters that zerolith new writes on the command line include one that points at the schema @zerolith/cli ships, so an editor completes the keys:
{
"$schema": "./node_modules/@zerolith/cli/zerolith.schema.json",
"base": "/app/",
"proxy": {
"/api": "http://127.0.0.1:3000",
"/auth": { "target": "http://127.0.0.1:4000", "stripPrefix": true }
},
"dev": { "port": 5000 },
"preview": { "port": 5001 },
"build": { "out": "../server/public" }
}| Key | Used by | What it sets |
|---|---|---|
base |
dev, build, preview |
The path the application is served under |
proxy |
dev, serve, preview |
Path prefixes forwarded to backends: an http:// address, or an object with target and stripPrefix |
allowedHosts |
dev, serve, preview |
Host names answered besides localhost and IP addresses; one starting with a dot allows every name under it |
dev.port |
dev, serve |
The port, used as it is |
preview.port |
preview |
The port, used as it is |
build.out |
build, preview |
The output folder, relative to the project unless absolute |
An unknown key fails with the file's name, so a typo does not go unnoticed. build --unbundled does not apply base and says so.
Environment variables
import.meta.env carries these values in every module:
| Value | Holds |
|---|---|
MODE |
development under dev, and production under build unless --mode names another |
DEV and PROD |
Whether the mode is development or production |
BASE_URL |
The path the application is served under |
PUBLIC_* |
Every variable whose name starts with PUBLIC_ |
The variables are read from .env, .env.local, .env.<mode>, and .env.<mode>.local in the project folder, each overriding the one before, and from the environment over all of them:
# .env
PUBLIC_API_URL=https://api.example.com
DATABASE_PASSWORD=stays-on-the-serverconst response = await fetch(`${import.meta.env.PUBLIC_API_URL}/tasks`);build
zerolith build
zerolith build --base /app/ --out ../server/static
zerolith build --unbundledBundles the application for production with Rolldown, the bundler Vite is built on, compiled into the binary: no Node tooling runs and nothing else needs installing. The command prints every file it wrote, with the gzip size of each text file:
zerolith build production, minified
dist/assets/de-DWUa6-jV.js 1.69 kB gzip 0.79 kB
dist/assets/en-BR-GMg9l.js 1.62 kB gzip 0.76 kB
dist/assets/index-BcViqks6.js 45.39 kB gzip 14.90 kB
dist/assets/index-a01166d1.css 0.81 kB gzip 0.41 kB
dist/index.html 0.57 kB gzip 0.37 kB
built tasks into dist in 91 ms| Option | What it does |
|---|---|
--out <folder> |
The output folder, dist unless zerolith.json sets build.out |
--base <path> |
The public path every URL the build writes uses, such as /app/, or ./ for URLs relative to the page |
--mode <name> |
production by default; any other mode, such as staging, resolves the development build of zerolith with its diagnostic text |
--sourcemap |
Writes a source map next to each chunk |
--no-minify |
Keeps names and whitespace, while still removing dead code |
--no-prepare |
Keeps the template scanner and leaves every html literal as it is |
--empty-out-dir |
Empties an output folder outside the project before writing into it |
--unbundled |
Writes the buildless output, the modules behind an import map, instead of a bundle |
Scripts and chunks
The build starts from index.html. Every <script type="module" src="..."> with a local source is an entry, and the modules it imports, TypeScript, TSX, JSX, and JavaScript alike, are bundled into minified, tree-shaken chunks under assets/, each with a content hash in its name so a host can cache it forever. Packages resolve from node_modules, with the production build of zerolith.
The zerolith passes are the ones vite build runs through the Vite plugin, held to the same output by shared fixtures. JSX is lowered to html calls. Every module goes through the template prepare pass: each html literal is scanned ahead of time and registered next to the literal, a template with no bindings is created once at module scope, and the core's scanner is replaced by a stand-in that reports template_not_prepared, so the browser binds templates without scanning them and the scanner is not shipped. A template the scanner refuses fails the build at its line, and so does a module that uses html from zerolith other than as a template tag.
A view loaded with the router's lazy() and each locale of a message index compiled by zerolith-i18n get chunks of their own, fetched when they are needed, and the build fails with lazy_module_not_split when one of them is also imported statically, which would merge it into the importing chunk. A module that an entry and a lazy chunk share gets a chunk both import. The page preloads every chunk its entries import statically with <link rel="modulepreload">, so the browser fetches them in parallel instead of discovering them one import at a time.
Stylesheets
Stylesheets become one file. The ones the page links and the ones modules import, as in import "./card.css", are combined in the order the page and the modules load them, through lightningcss: local @imports are inlined in place under their layer(), supports(), and media conditions, again for a stylesheet imported again under other conditions, as the browser applies it again, a remote @import moves to the top where CSS requires it, and the result is lowered for the oldest supported browsers, with nesting flattened, fallbacks for newer color functions, and the vendor prefixes those browsers need, then minified. The page's first local stylesheet link loads the result, and the others are removed. A stylesheet imported with ?scoped goes through the same steps on its own and lands in the chunk of the module that imports it, as the rules of the styles that module binds, since it applies only where a component binds it. A stylesheet link to a file that is not in the project, such as one the host serves next to the app, is kept as it is, with a warning.
Files and URLs
Files are copied into assets/ under a content hash when a stylesheet references them with url(), when a module imports them, and when the page names them in an icon, manifest, or preload <link>, an <img>, <source>, <video>, <audio>, or <track>, each candidate of a srcset or a preload's imagesrcset, or a classic <script>. A dependency, a file installed in node_modules, may refer only to installed files: a stylesheet's url() or @import, or a ?url, ?raw, or ?scoped import, in a package that reaches outside node_modules, as url(../../.env) would, fails the build and names the file, so a package cannot publish a file of the project or of the disk. A package of the workspace that node_modules links to counts as the project's own. Importing an image, font, media file, or document gives its URL; ?url gives any file's URL and ?raw its text:
import logo from "./logo.svg";
import license from "../LICENSE?raw";A root-absolute URL such as /favicon.svg is looked up in public/ first. The files in public/ are copied into the output as they are and keep their URLs, so files that need a fixed name, such as robots.txt, a web manifest, and the favicon, go there.
--base /app/ suits an application a backend or a reverse proxy serves under /app/, and --base ./ a page opened from disk or embedded at a path not known in advance. Modules read the mode, the base, and the PUBLIC_ variables through import.meta.env, as Environment variables describes, and the build replaces each with its value, as it replaces process.env.NODE_ENV for packages written for Node bundlers.
Browsers and syntax
The output keeps ES2022 syntax as written. Lowering class fields for the oldest supported browsers would turn every field into a function call, slowing every class, so class static blocks need Chrome 94 and #field in checks Chrome 91; newer syntax, such as using declarations, is lowered.
The output folder
The output folder is emptied first when it is inside the project. One outside the project, such as the static folder of a backend next to it, is written over without being emptied unless --empty-out-dir is passed. A folder that holds the project, that is or lies inside the public folder, or that holds a source the page loads is refused, with .., links, and letter case resolved first, so --out .. cannot reach the folder above the project. The same rules hold for --unbundled. The page's import map and its module preload links to local files, such as the ones zerolith importmap writes, are dropped, since the bundle resolves packages itself and preloads its own chunks, so a project that runs from an import map while developing builds the same way.
Unbundled output
--unbundled writes the buildless output instead, for a project served with an import map in production. No bundler runs: the output is the project's modules, served as they are.
- Every file under the project is copied into the output folder, except
node_modules,vendor,dist,target,test, hidden folders and files, declaration files (*.d.ts), and the development files:package.json,tsconfig.json,zerolith.json,AGENTS.md,README.md, and*.config.*. Two sources that would write one output file, such asutils.tsbeside a staleutils.js, fail the build with both names. .ts,.tsx,.jsx, and.mtssources are transformed to JavaScript the same waydevserves them and renamed to.js, and the relative imports that named them, static and dynamic, are rewritten to.js, as are the module scripts in HTML pages.- An import of a file that is not a module works as it does under
dev: the build points it at a small module written next to the file, such ascard.css.js, which links the stylesheet and waits for it before the importer runs. A stylesheet imported with?scopedgetscard.css.scoped.js, which holds its rules with local imports inlined; a JSON file gives its value, parsed withJSON.parseso a__proto__key stays a key; an image, a font, or a file with?urlgives its URL; and?rawgives a file's text. An import that names a file outside the project fails the build, and so does one that names a file inpublic/, which is served as it is and is referenced by its URL instead. - Every module goes through the same template prepare pass.
- The installed zerolith packages, the ones
zerolith importmaplists, are vendored intovendor/inside the output, through the prepare pass as well, with an import map carrying integrity hashes written into every page, nested ones such asabout/index.htmlincluded. Entries the page's own import map holds for other specifiers are kept, so a third-party library mapped to a CDN or a local copy still loads; the build warns of a bare import, such aslit, that neither map covers, since the browser cannot load it.
new
zerolith new
zerolith new my-app --yes
zerolith new my-app --template ts --vite --no-install
zerolith new my-app --example fullstackWrites a starter application and installs its dependencies. On a terminal it asks for what the flags leave open, each question with a default that Enter takes, and outside a terminal the defaults apply without asking:
| Question | Default | Flag |
|---|---|---|
| The starting point | The starter | --example fullstack |
| The folder | zerolith-app |
The first argument |
| The language | TSX | --template tsx, ts with html templates, jsx, or js with html templates and JSDoc types |
| The dev server and build | The command line | --vite for Vite with @zerolith/vite, or --buildless for the command line deploying the modules behind an import map |
| The inspector | Included | --no-devtools |
| The zerolith skill for coding agents | Included | --no-skill |
| Installing the dependencies | Yes, with the package manager that ran the command | --no-install |
--yes takes the defaults for everything the flags leave open, the starter among them.
The starter is three pages in the brand style: a home page that shows the workflow, a counter built on a named signal, and a searchable list that a resource loads from public/data/packages.json. It has the layout the structure guide describes, a browser test with a vitest.config.ts that gives Vitest the zerolith plugin, src/env.d.ts typing import.meta.env and stylesheets imported with ?scoped, an AGENTS.md for coding agents, and a README that walks through developing, building, previewing, deploying, environment variables, and when Vite is worth adding. Its dev, build, and preview scripts run the command line, or Vite with --vite; --buildless keeps zerolith dev and builds with --unbundled. The tool versions it pins are the ones this repository tests with.
--example fullstack writes the fullstack example as a project of its own instead: Harbor, where a small team keeps its projects and tasks, under /app/, with its backend in server/, plain Node with no dependencies, and the types, the API contract, and the validation both sides share in shared/. It signs members in with a session cookie, streams the team's activity to the dashboard as server-sent events, and loads a project's board as a chunk of its own. zerolith.json sets the base and forwards /api to the backend on port 3001, so development runs two processes, npm run api for the backend in watch mode and npm run dev for zerolith dev, in either order, since the page asks for the session again until the backend answers; npm run build writes dist/ for the base, and npm start serves the API and the build from the backend. The project comes with tests of the backend in Node and of the pages in Chromium, an AGENTS.md, and a README on the two processes and on deploying, and pins the same versions as the starter. The example is TypeScript with html templates on the command line, with the inspector, so the language, dev server, and inspector questions are not asked for it, and --template, --vite, or --buildless given with --example stops the command with a message that names the flag.
skill
zerolith skill
zerolith skill my-appWrites the zerolith skill for coding agents into the project: its rules, one pattern for each concept, and every guide and diagnostic code as references, built into the command line so the copy describes this version. It goes into .agents/skills/zerolith/, which Codex, Cursor, GitHub Copilot, Gemini CLI, and most other agents read, and .claude/skills/zerolith/, which Claude Code reads, replacing an earlier copy in either; SKILL.md records the version under metadata. zerolith new runs it for every new project unless --no-skill is given.
Nothing more is needed: an agent started in the project finds the skill and loads it when a task involves zerolith. The copy does not change on its own, so run the command again after upgrading zerolith; zerolith doctor reports skill_outdated until then. The coding agents guide covers what an agent reads and when.
importmap
zerolith importmap --html index.htmlCopies the installed zerolith, @zerolith/router, @zerolith/forms, @zerolith/client, @zerolith/a11y, @zerolith/i18n, and @zerolith/devtools modules into vendor/, writes vendor/importmap.json, and replaces or inserts the import map and modulepreload links in the page, keeping the page's own entries for the specifiers it does not vendor. @zerolith/otel is not vendored, since it imports @opentelemetry/api; map both in the page to load it unbundled. The map carries an integrity entry with the SRI hash of every module reachable from the entries, so a modified file fails to load, and each preload link carries the same hash, since a browser reuses a preload only when its integrity matches the module request's.
| Option | What it does |
|---|---|
--html <file> |
The page whose import map and preload links are replaced or inserted |
--out <folder> |
Where the packages are copied, vendor by default; it is the URL prefix too |
--development |
Maps the entries with diagnostic text and the inspector instead of the production ones |
doctor
zerolith doctor
zerolith doctor --jsonReports coded findings, each with a cause and a fix, as text or as JSON with --json. The exit code is 1 when any finding is an error, so a CI step can run it as it is. It checks the page first:
| Code | Severity | Means |
|---|---|---|
index_missing |
Error | There is no index.html |
module_script_missing |
Warning | The page loads no module script |
importmap_invalid |
Error | The page's import map is not valid JSON |
importmap_target_missing |
Error | The map points a specifier at a file that does not exist |
integrity_mismatch |
Error | A file changed after the import map was written |
integrity_target_missing |
Error | A file has an integrity hash but does not exist |
preload_integrity_mismatch |
Warning | A preload link lacks the hash the map gives its module, so the browser downloads the module twice |
package_outdated |
Warning | The vendored version of a package differs from the installed one |
skill_outdated |
Warning | The zerolith skill describes another version than the installed zerolith |
The import map checks apply only to a page that has a map, as zerolith importmap writes one; a page without one is complete, since dev resolves the packages and build bundles them.
It then runs the template lint of the Vite plugin over the project's .js, .ts, .jsx, and .tsx sources, outside node_modules, vendor, dist, target, test folders, and hidden folders, and reports the same warnings with the file, line, and column: template_frozen_read, image_without_alt, button_without_name, and frame_without_title. JSX is lowered first, keeping its lines, so a finding in a .tsx file points at the line its element starts on. A shared fixture holds the two implementations to the same findings.