0.1.0-alpha.7

Introduction

zerolith is a standalone frontend framework for single-page applications. State lives in fine-grained signals, HTML templates bind those signals with no compiler, a typed router handles navigation, and one command line serves the app while you work and builds it for production. It talks to any backend over plain HTTP, and renders on the server when a page needs it.

The guides explain one concept each, with the pattern zerolith uses for it and an example that runs as a test. New to zerolith? The tutorial builds a small app from an empty folder, one idea per step, and the playground runs code in your browser.

How it fits together

A component is a plain function that builds a view, and it does not run again when its state changes. Its template binds signals to the places on the page that show them, so a change to a signal updates the text, attribute, list, or branch bound to it and nothing else: there is no virtual DOM to compare and no component to render again.

Everything else is a package around that core, and an application installs the ones it uses:

Package What it gives an application
zerolith Signals, the html template, render and hydrate, lists and conditions, scoped styles, JSX, and server rendering from zerolith/ssr
@zerolith/router A typed route table, plain anchors, loaders, head metadata, and an outlet
@zerolith/forms Native forms that work without JavaScript, enhanced with fetch, pending state, and field errors shared with the server
@zerolith/client A typed HTTP client generated from an OpenAPI document, with failures read as problems
@zerolith/i18n Locale negotiation, cached Intl formatters, and MessageFormat 2 catalogs compiled per locale
@zerolith/a11y Focus trap, roving tabindex, a live announcer, focus origin, and preference signals
@zerolith/test Mounting with cleanup, settling async work, and hydration and accessibility checks
@zerolith/devtools The in-page inspector for signals, roots, and diagnostics
@zerolith/otel OpenTelemetry spans for navigations, hydration, and requests
@zerolith/vite The Vite plugin, for projects that build with Vite
@zerolith/cli The zerolith command: the starter, the dev server, the build, preview, and project checks

Getting started

Node 24 or newer is required. Install the command line once; npm puts the prebuilt binary for your platform on your PATH:

npm install --global @zerolith/cli@alpha

Then write a starter application and run it:

zerolith new my-app
cd my-app
zerolith dev

zerolith new asks a few questions. Enter takes the default, a flag answers a question on the command line, and --yes takes every default without asking:

Choice Default Other choices
Starting point The starter --example fullstack for an app with its own Node backend
Language TSX: TypeScript with JSX --template ts for TypeScript with html templates, --template jsx, or --template js for JavaScript with html templates
Dev server and build The zerolith command line --vite for Vite with @zerolith/vite
Production output One minified bundle --buildless for the modules behind an import map
Inspector Included --no-devtools
Skill for coding agents Included --no-skill
Dependencies Installed --no-install

In the project:

Command What it does
zerolith dev Serves the app at http://127.0.0.1:8080/ and reloads it as you edit
zerolith build Bundles and minifies it into dist/, static files for any host or backend
zerolith preview Serves dist/ the way a host will, to check the build

The starter's README walks through its files, deploying, and environment variables, and the command line guide has every option.

A component

A component is a function that returns a view. State is signals, and the template binds them:

import { computed, html, render, signal } from "zerolith";

function Counter({ start = 0 }: { start?: number })
{
    const count = signal(start);
    const doubled = computed(() => count.get() * 2);
    return html`
        <section>
            <p>Count: ${count}, doubled: ${doubled}</p>
            <button @click=${() => count.set(count.get() + 1)}>Add one</button>
        </section>`;
}

render(Counter({ start: 1 }), "#app");

A click sets count. The text bound to count and doubled changes, and the paragraph reads Count: 2, doubled: 4; Counter does not run again, and nothing else on the page is touched.

The one rule to learn first

Bind the signal, not its value. A template given a signal follows it; a template given count.get() is given a number, read once:

import { html, render, signal } from "zerolith";

const count = signal(1);

render(html`
    <p class="live">${count}</p>
    <p class="frozen">${count.get()}</p>`, "#app");

// The first paragraph now shows 2; the second still shows 1.
count.set(2);

To show a value derived from signals, bind a computed value, as ${computed(() => item.get().name)}.

Putting it on the page

render mounts a view at the end of an element, named by a CSS selector or passed as the element itself, and returns a root: container is the element it mounted into, and dispose() removes the view and stops its effects. A selector that matches nothing fails with mount_target_missing. hydrate takes the same targets for markup the server rendered; see Server rendering.

import { html, render } from "zerolith";

const app = render(html`<p>Hello from zerolith</p>`, "#app");

// Later, to take the view down and stop its effects:
app.dispose();

The page itself is plain HTML. The starter's index.html links the global stylesheet, loads the entry module, src/main.ts or src/main.js, and leaves one element to mount into; a module script runs once the document is parsed, so it can sit in the head and still find #app:

<head>
    <link rel="stylesheet" href="/src/styles.css">
    <script type="module" src="/src/main.ts"></script>
</head>
<body>
    <div id="app"></div>
</body>

zerolith dev serves the modules as they are and resolves the packages they import from node_modules, and zerolith build bundles them.

Where to go next

Read the basics in order; the rest is there for when an application needs it.

Basics

Guide Read it for
Application structure The layout every zerolith application uses, what each file owns, and the AGENTS.md that lets an agent work in it
Signals State, derived values, effects, ownership, and async values
Templates The html tag, components, bindings, lists, conditions, and what the renderer refuses
Styles Scoped component styles with the css tag

Building apps

Guide Read it for
Routing The route table, page components, loaders, layouts, and plain anchors
Forms Native forms enhanced with fetch, pending state, and field errors that share one wire shape with the server
Typed client A route table and types generated from an OpenAPI document, checked calls, and problems for failures
Server rendering Rendering views to HTML with no DOM, and hydrating them in the browser
Custom elements Mounting a view inside a tree another framework owns, with attributes and properties as signals
Internationalization Locale negotiation and state on Intl, formatters that follow the locale, and compiled MessageFormat 2 catalogs
Accessibility Focus trap, roving tabindex, live announcer, focus origin, hidden text, and preference signals

Tooling

Guide Read it for
Command line The starter, the dev server, the production build, preview, zerolith.json, and project checks
Building and serving What the build writes and how to serve it from a static host, a web server, or a backend, under a path, and next to a backend while developing
Vite Hot module replacement that keeps named state, JSX, and templates prepared at build time, for projects on Vite
Testing Mounting with cleanup, settling async work, and hydration and accessibility checks in tests
Dev tools The in-page inspector, named signals, and Performance tracks
Diagnostics The error record, stable codes, and production builds
Observability Spans for navigations, hydration, and requests through the OpenTelemetry API
Coding agents What an agent reads in a zerolith app, the project's AGENTS.md and the zerolith skill, and how to keep the skill current

Every export is in the reference, generated from the source.