Skip to content
Spacefast Docs
Esc
↑↓navigate↵open⌘Jpreview
On this page

Dynamic sites with Zero

Zero is the runtime a Space gets when its project declares it. A database, HTTP endpoints, live queries, crons, and storage, from one TypeScript app.

After this page you can tell whether your project needs Zero, declare it in sf.jsonc, write the smallest app that works, and know which page owns each piece.

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, and sf publish compiles it into a capsule the platform installs on the space.

Zero is on for every account. There is nothing to enable.

The other runtime is 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.

{
  "$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.

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
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
Crons Scheduled GETs against your own paths, declared in sf.jsonc. Crons
Storage Runtime object storage for visitor uploads. Storage
Logs What your handlers wrote, plus every request the edge served. 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.

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.

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 (
    <Card title="Notes">
      <Button onClick={() => void addNote("hello")}>Add</Button>
      <ul>{notes.map((note) => <li key={note.id}>{note.body}</li>)}</ul>
    </Card>
  );
}

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
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 and 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 and 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.

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.

import { action, capsule } from "@spacefast/zero/server";

type Ctx = {
  auth: { isAuthenticated: boolean; userId: string | null };
  env: Record<string, string | undefined>;
};

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 at all and the capsule ships unstyled. 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

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.

Last updated on October 4, 2026

Was this page helpful?