--- title: Zero description: Zero is retired. Spaces that run it keep working, and Zero 0.9, its last release, moves apps to First. sidebar: order: 0 --- **Zero is retired.** Spaces that run a Zero app keep serving, and you can keep publishing them. Zero 0.9 will be its last release apart from security fixes, and it exists to move Zero apps to First, Zero's successor. Start new apps on First: see the [First docs](https://first-docs.space.fast/). Zero is available on every account, with [usage allowances and metered rates](/usage). To use it, declare the full [runtime block](#declare-it) in `sf.jsonc` and provide the server entry. ## What Zero is Zero is the runtime a Space gets when its project declares it. Your server code runs on the machine that already serves the site, next to that space's own MySQL database, inside a QuickJS runner. You write one TypeScript app. It declares tables, queries, mutations, actions, and HTTP endpoints. `sf publish` compiles it into a **capsule** the platform installs on the space. There is no account-level switch to enable. The other runtime is [Functions](/functions), a worker for npm-heavy code and framework output like OpenNext. One version declares one runtime, not both. ## Declare it Zero is never inferred. A runtime that owns a database does not spring into existence from a directory shape, so both entry paths are required. ```jsonc sf.jsonc { "$schema": "https://spacefast.com/schemas/sf.json", "name": "my-app", "runtime": { "kind": "zero", "server": "server/index.ts", "client": "client/index.tsx" } } ``` The fastest way to get that file plus a working app is the scaffold. ```bash sf init my-app --runtime zero --template todo cd my-app sf dev sf publish ``` `--template` accepts `todo`, `guestbook`, or `contact`. The client entry is optional on disk. If no file backs it, the build generates a shell and the capsule is server-only. The server entry is not optional. ## What you get | Piece | What it is | Page | | --- | --- | --- | | Database | The space's own MySQL. Your capsule declares the tables; the platform migrates them when a version finalizes. | [Database](/database) | | Endpoints | Plain HTTP routes in your capsule, for webhooks and callers outside the app. | This page | | Queries, mutations, and actions | The read, transactional write, and outbound-work paths your client calls. Queries are live. | This page | | Environment variables | `ctx.env`, from `.env.server` or `sf env`. | [Environment variables](/environment-variables) | | Crons | Scheduled `GET` requests to your own paths, declared in `sf.jsonc`. | [Crons](/crons) | | Storage | Runtime object storage for visitor uploads. | [Storage](/object-storage) | | Logs | What your handlers wrote, plus every request the edge served. | [Logs](/logs) | ## The `@spacefast/zero` package The scaffold's `package.json` pins the package to the compiler's own version. Nothing you install reaches the page. The build resolves the SDK, preact, and the rest from the platform, so these dependencies exist for types and editor completion. | Import | Use it from | What it holds | | --- | --- | --- | | `@spacefast/zero/server` | `server/` | `capsule`, `query`, `mutation`, `action`, `endpoint`, `table`, field constructors, response helpers | | `@spacefast/zero/client` | `client/` | `useQuery`, `usePaginatedQuery`, `useMutation`, `useAction`, `useAuth`, `storage`, `Router` and friends | | `@spacefast/zero/kit` | `client/` | The component set: `Button`, `Card`, `Input`, `Dialog`, `Table`, and about 30 more | | `@spacefast/zero/charts` | `client/` | `LineChart`, `BarChart`, `Sparkline`, `StatTile` | `action`, `useAction`, and the `actions` capsule field require compiler 0.5.0 or later. ## The minimal app Server code declares the schema and the handlers. ```ts server/index.ts import { boolean, capsule, endpoint, mutation, query, string, table, text } from "@spacefast/zero/server"; export default capsule({ name: "notes", schema: { notes: table({ body: string(), done: boolean().default(false), ownerId: string(), }).index("by_owner", ["ownerId"]), }, queries: { notes: query(async (ctx) => ctx.db.notes .withIndex("by_owner", (range) => range.eq("ownerId", ctx.auth.userId)) .order("desc") .collect(), ), }, mutations: { addNote: mutation(async (ctx, body: string) => { await ctx.db.notes.insert({ body, done: false, ownerId: ctx.auth.userId }); ctx.invalidate("notes"); }), }, endpoints: { status: endpoint({ mode: "read", method: "GET", path: "/api/status" }, () => text("ok")), }, }); ``` Client code reads through queries and writes through mutations. ```tsx client/index.tsx import { useMutation, useQuery } from "@spacefast/zero/client"; import { Button, Card } from "@spacefast/zero/kit"; export function App() { const notes = useQuery<{ id: string; body: string }[]>("notes"); const addNote = useMutation<[body: string], void>("addNote"); return ( ); } ``` `capsule()` takes `name`, `favicon`, `schema`, `queries`, `mutations`, `actions`, and `endpoints`, and throws on a key it does not know. ### Tables `table(fields)` takes five field constructors: `string()`, `boolean()`, `number()`, `id("otherTable")`, and `userId()`. Store dates as strings. The wire schema also accepts `json`, but the SDK has no `json()` field constructor. `.default(value)` makes a field optional on insert. Every row gets `id`, `createdAt`, and `updatedAt`, so those three names are reserved, as is the built-in `by_creation` index. Field and index names must match `/^[A-Za-z_][A-Za-z0-9_]*$/`. A leading underscore is allowed. Declare an index with `.index(name, fields)` and read it through `withIndex`. ### Handler context Every query, mutation, action, and endpoint handler takes `ctx` first. | On `ctx` | Queries and read endpoints | Mutations and write endpoints | Actions | What it is | | --- | --- | --- | --- | --- | | `db` | read only | read and write | read only | `get`, `withIndex`, and on the write side `insert`, `update`, `delete` | | `auth` | yes | yes | yes | `userId`, `displayName`, `isGuest`, `isSignedIn`, `isAuthenticated`, `provider` | | `env` | yes | yes | yes | Server-only variables. See [Environment variables](/environment-variables) | | `log` | yes | yes | yes | `info`, `warn`, `error`, each with an optional data bag | | `invalidate` | no | yes | no | Names the live queries this mutation changed | A signed-out visitor is a guest with a real `userId` (`guest:…`), so `!ctx.auth.userId` never rejects anyone. Gate sign-in on `ctx.auth.isAuthenticated`. A query can run again when it satisfies a subscription, so any `fetch()` it makes can run again too. Queries have no `invalidate`; only write handlers tell subscriptions to refresh. An action runs without a transaction. It reads the database but cannot write to it, and it has no `invalidate`. Use it when a call causes an external effect, not to gain more runtime: the whole action has a 5-second deadline. To keep what it fetched, hand the result to a mutation. The client calls one with `useAction(name)`. `invalidate()` takes **query names, not table names**. `ctx.invalidate("notes")` refreshes every `notes` subscription whatever arguments it carries. Calling it with nothing is the safe default and refreshes every live query on the page. ### Endpoints `endpoint(route, handler)` takes `{ method, path, mode }`, where `mode` is `"read"` or `"write"`. Both `method` and `path` must be literal strings, because the compiler reads them from source without running it. The handler gets `(ctx, req)`, where `req` carries `method`, `path`, `url`, `headers`, `query`, and `text()` / `json()` / `bytes()`. Return one of the response helpers: `json(value)`, `text(value)`, `empty()` for a 204, `redirect(url)` for a 302, or `new ImageResponse(element)` for a rendered 1200x630 image. ## The request lifecycle 1. Static bytes win. Anything committed in the version serves from disk without waking your code. 2. Platform paths are reserved. First segments `__spacefast`, `__stattic`, and `__span` belong to the platform, as do `/auth/` and `/_spacefast/` inside the app. 3. Your endpoint table matches. A hit runs the handler in the QuickJS runner on the space's own machine, in one transaction with the database. 4. Everything else falls to the app shell, which serves your client bundle. Queries, mutations, and actions do not get their own URLs. The client sends them over `/__zero/run`. When a mutation commits, the runtime publishes an invalidation event. Every browser holding a matching subscription re-runs its query. That is the whole realtime story. Do not poll. An endpoint cannot claim `/`, `/index.html`, `/client.js`, `/auth/callback`, anything under `/auth/`, or anything under `/_spacefast/`. The shell, the client bundle, and the sign-in flow already answer those. ## What a capsule is, and what compiles A capsule is the value `capsule({...})` returns. `sf publish` runs esbuild over your tree. It produces one ESM server bundle for the runner, one browser client bundle, and a migration plan for the tables you declared. Rolling a version back rolls all three back together, because they travel with the version. Only three source roots are read: `client/`, `server/`, and `shared/`. Beyond them the compile admits `package.json`, `.env.server`, and `.env.lakebed.server` and nothing else. `shared/` imports its own relative files only, no packages at all. Client code cannot import the server SDK, and server code cannot import client files. | Limit | Value | | --- | --- | | Server bundle | 768 KiB | | Client bundle, your code and your npm packages | 8 MiB | | Server imports | `@spacefast/zero/server`, `preact`, `preact/jsx-runtime`, `preact/jsx-dev-runtime` | | Client imports | Anything except the server SDK and `node:*` built-ins | | Endpoint request body | 2 MiB | | Rejected everywhere | `eval`, `Function`, `require()`, dynamic `import()`, `process`, `__dirname`, `__filename`, `Bun`, `Deno` | | Rejected in `server/` and `shared/` | `window`, `document`, `localStorage`, `WebSocket`, `Worker`, `XMLHttpRequest`, `globalThis` | Platform modules do not count against your client cap. The SDK, kit, charts, preact, recharts, and lucide serve from immutable same-origin URLs the browser keeps across publishes. ### Outbound HTTP `fetch()` works in every Zero handler, HTTPS only. A mutation or write endpoint holds its database transaction open while it waits, so each fetch has a 5-second timeout. Queries and read endpoints get 10 seconds per fetch. An action gets 5 seconds for the whole invocation, shared across every fetch it makes. How far `fetch()` reaches depends on who owns the space: | Space | Reaches | | --- | --- | | Claimed | Any public HTTPS host | | Unclaimed | Only the trusted list: `api.anthropic.com`, `api.openai.com`, `api.github.com`, `api.stripe.com`, `api.groq.com`, `api.mistral.ai`, `api.together.xyz`, `generativelanguage.googleapis.com`, `openrouter.ai` | From an unclaimed space, `fetch()` to any other host throws a `SpacefastFetchError` with code `zero_fetch_host_untrusted` and the message "This host isn't reachable from an unclaimed space. Claim the space to reach any public host." Claiming opens it up without a republish. Private addresses and cloud metadata stay blocked either way. Outbound access has nothing to do with who can open your space. Calling an API does not make the space public or touch its Grants. ### Example: invite People from your app There is no `ctx.people`. To invite someone to a private space, or resend their invite, call the People API from an action with a [machine credential](/share#machine-credentials). ```bash sf share token create --name invites --path '/**' --role manager --show-secret --json \ | jq -r '.data.token' \ | sf env set INVITE_TOKEN --value-from-stdin sf env set INVITE_SPACE_ID spc_123 sf env set INVITE_ADMINS user-id-1,user-id-2 ``` The `manager` role carries `access.manage`, which the People routes require. Narrow `--path` to limit where the token can invite people. An invite with `scope: "/docs"` requires `--path '/docs/**'`; the bare `/docs` path does not cover that subtree. `INVITE_ADMINS` lists the `ctx.auth.userId` values allowed to send invites. The People API only works on a claimed space. Each `sf env set` queues a settings-only finalize; the action sees the new value after that operation finishes. An action is callable by anyone who can load your app, so gate it before it spends that token. ```ts server/index.ts import { action, capsule } from "@spacefast/zero/server"; type Ctx = { auth: { isAuthenticated: boolean; userId: string | null }; env: Record; }; async function callPeople(ctx: Ctx, path: string, body?: unknown) { const admins = (ctx.env.INVITE_ADMINS ?? "").split(",").map((id) => id.trim()); if (!ctx.auth.isAuthenticated || !admins.includes(ctx.auth.userId ?? "")) { throw new Error("You can't send invites."); } const response = await fetch( `https://api.spacefast.com/v1/spaces/${ctx.env.INVITE_SPACE_ID}/people${path}`, { method: "POST", headers: { authorization: `Bearer ${ctx.env.INVITE_TOKEN}`, "content-type": "application/json", }, body: body === undefined ? undefined : JSON.stringify(body), }, ); if (!response.ok) throw new Error(`People API answered ${response.status}.`); return (await response.json()).data; } export default capsule({ actions: { invite: action((ctx, email: string) => callPeople(ctx, "", { email, grants: [{ scope: "/", role: "viewer" }] }), ), resendInvite: action((ctx, personId: string) => callPeople(ctx, `/${encodeURIComponent(personId)}/resend`), ), }, }); ``` Call them from the client with `useAction("invite")`. `scope: "/"` covers the whole space; `/docs` covers one subtree. Both routes return the Person in `{ data }`. The invite link itself only ever travels by email. ## Styling Write Tailwind classes in your JSX. Zero compiles them at publish, so do not add a CSS, PostCSS, or Tailwind pipeline. `@plugin` and `@config` directives are rejected, and `theme.json` plus utility classes are the whole styling story. Class names must be static strings. The compiler finds them by scanning source text, so `bg-${tone}-500` produces no CSS for that class. Branch to whole literals instead. Prefer the semantic tokens, which re-skin from `theme.json` and handle light and dark on their own: `canvas`, `surface`, `ink`, `ink-muted`, `line`, `accent`, `success`, `warning`, `danger`. The shadcn names (`bg-background`, `text-muted-foreground`, `bg-primary`) alias onto the same tokens. ## Run it locally ```bash sf dev ``` `sf dev` starts the capsule dev server on `http://127.0.0.1:4173`. State lives in memory and resets on restart. Pass `--state-backend sqlite` to keep it between runs. `--watch` is on by default and reloads the runtime when a source file changes. A non-loopback `--host` needs `--allow-network`. Migrations, dumps, exports, and the SQL console. Workers, the `functions/` file router, and OpenNext. Every key the config schema accepts. Follow what your handlers log.