Skip to content
Spacefast Docs
Esc
navigateopen⌘Jpreview
On this page

Zero

Build apps with a database, storage, authentication, and typed server functions.

Zero is Spacefast’s full-stack runtime. One project contains a Preact client, a typed server capsule, a database schema, authentication, and storage. One publish command makes the whole app live as a normal space version. Zero is generally available.

Spacefast Zero is based on the capsule apps concept created by the Lakebed project, close enough that a Lakebed capsule compiles unchanged. See Compare to Lakebed.

Quick start

Create and run

sf init my-app --runtime zero
cd my-app
sf dev

Open the private URL sf dev prints, not http://localhost:4173: the dev server is capability-gated, and the printed URL carries the capability in its fragment. http://localhost:4173 on its own serves a page that tells you to go back and copy it.

Open this private URL: http://127.0.0.1:4173/#zero-dev-capability=<capability>

Edit client/index.tsx and server/index.ts. The local server reloads when you save a file. State is in memory unless you pass --state-backend sqlite.

Publish

sf publish

The publish command compiles the capsule, plans and applies the database migration, uploads the client, and activates the version.

Project layout

  • my-app/
    • client/
      • index.tsx
    • server/
      • index.ts
    • shared/
    • .env.server
    • sf.jsonc
    • package.json

The runtime is explicit:

{
  "$schema": "https://spacefast.com/schemas/sf.json",
  "name": "My app",
  "runtime": {
    "kind": "zero",
    "server": "server/index.ts",
    "client": "client/index.tsx",
  },
}

Both entries are required. The publish fails when Spacefast cannot resolve an entry. Spacefast does not expose the source as static files.

The capsule

A capsule is the default export of the Zero server entry. It declares the database schema and every callable server handler.

import { boolean, capsule, mutation, query, string, table }
  from "@spacefast/zero/server";

export default capsule({
  name: "Todos",
  schema: {
    todos: table({
      text: string(),
      done: boolean().default(false),
      ownerId: string(),
    }).index("by_owner", ["ownerId"]),
  },
  queries: {
    todos: query(async (ctx) =>
      ctx.db.todos
        .withIndex("by_owner", (range) => range.eq("ownerId", ctx.auth.userId))
        .order("desc")
        .collect()
    ),
  },
  mutations: {
    addTodo: mutation(async (ctx, text: string) => {
      await ctx.db.todos.insert({ text, done: false, ownerId: ctx.auth.userId });
    }),
  },
});

Handler types:

  • query(): reads data and supports live client subscriptions.
  • mutation(): writes data.
  • action(): performs a one-shot server call.
  • endpoint(): exposes a raw HTTP method and path.

Endpoint helpers include json(), text(), empty(), redirect(), and ImageResponse for images rendered from Preact nodes. Paths begin with /. Spacefast reserves the authentication and platform namespaces.

Every handler receives ctx with:

  • ctx.auth: identity.
  • ctx.db: the declared tables.
  • ctx.env: server-only variables.
  • ctx.log: structured logging.
  • ctx.email, ctx.spam, ctx.gravatar: platform services with no credential to configure. See Email, spam, and Gravatar.

Call named handlers from the client with hooks:

import { useAction, useMutation, useQuery } from "@spacefast/zero/client";

const todos = useQuery<Todo[]>("todos");
const addTodo = useMutation<[text: string], void>("addTodo");

useMutation() and useAction() take the handler’s argument tuple and its result type, so addTodo("Buy milk") is typed end to end and addTodo(42) fails to compile. useQuery() subscribes and re-renders when the result changes. For long lists, use usePaginatedQuery() and its loadMore() method. The client also exports Router, Routes, Route, Link, useNavigate(), useParams(), and useLocation() for client-side routes.

A complete app

A guestbook that uses the whole runtime: a schema, a live query, a validated mutation with a spam check and Gravatar avatars, an action that emails a digest, a webhook, hosted sign-in, a photo upload to storage, two client-side routes, and a stats chart, styled with the platform kit and Tailwind classes. Two files are the whole app.

import { action, capsule, endpoint, json, mutation, query, string, table, text }
  from "@spacefast/zero/server";

export default capsule({
  name: "Guestbook",
  schema: {
    entries: table({
      body: string(),
      photoUrl: string().default(""),
      avatarUrl: string().default(""),
      authorId: string(),
      authorName: string(),
    }),
  },
  queries: {
    entries: query(async (ctx) =>
      ctx.db.entries.withIndex("by_creation").order("desc").take(50)
    ),
  },
  mutations: {
    sign: mutation(async (ctx, body: string, photoUrl: string) => {
      const trimmed = body.trim().slice(0, 500);
      if (!trimmed) return;
      const verdict = await ctx.spam.check({
        content: trimmed,
        type: "comment",
        authorName: ctx.auth.displayName,
      });
      if (verdict.spam) return;
      await ctx.db.entries.insert({
        body: trimmed,
        photoUrl,
        avatarUrl: ctx.auth.email
          ? ctx.gravatar.avatarUrl(ctx.auth.email, { size: 64, default: "retro" })
          : "",
        authorId: ctx.auth.userId,
        authorName: ctx.auth.displayName,
      });
    }),
  },
  actions: {
    emailDigest: action(async (ctx) => {
      if (ctx.auth.isGuest || !ctx.auth.email) return;
      const recent = await ctx.db.entries
        .withIndex("by_creation").order("desc").take(10);
      await ctx.email.send({
        from: { email: "guestbook@example.com", name: "Guestbook" },
        to: ctx.auth.email,
        subject: `Your guestbook digest: ${recent.length} recent entries`,
        text: recent
          .map((entry) => `${entry.authorName}: ${entry.body}`)
          .join("\n"),
      });
    }),
  },
  endpoints: {
    incoming: endpoint({ method: "POST", path: "/webhooks/entries" },
      async (ctx, req) => {
        if (req.headers.get("x-webhook-secret") !== ctx.env.WEBHOOK_SECRET) {
          return text("unauthorized", { status: 401 });
        }
        const payload = await req.json<{ body: string }>();
        await ctx.db.entries.insert({
          body: payload.body,
          photoUrl: "",
          authorId: "webhook",
          authorName: "Webhook",
        });
        return json({ ok: true });
      }),
  },
});
import { Link, Route, Router, Routes, SignInWithGoogle, signOut, storage,
  useAction, useAuth, useMutation, useQuery } from "@spacefast/zero/client";
import { Sparkline } from "@spacefast/zero/charts";
import { Button, Card, EmptyState, Input, Spinner } from "@spacefast/zero/kit";
import { useState } from "preact/hooks";

type Entry = {
  id: string;
  body: string;
  photoUrl: string;
  avatarUrl: string;
  authorId: string;
  authorName: string;
  createdAt: string;
  updatedAt: string;
};

function SignForm() {
  const sign = useMutation<[body: string, photoUrl: string], void>("sign");
  const [draft, setDraft] = useState("");
  const [photo, setPhoto] = useState<File | null>(null);

  return (
    <form
      class="flex items-center gap-2"
      onSubmit={async (event) => {
        event.preventDefault();
        const uploaded = photo ? await storage.upload(photo) : null;
        await sign(draft, uploaded?.url ?? "");
        setDraft("");
        setPhoto(null);
      }}
    >
      <Input
        value={draft}
        onInput={(event) => setDraft(event.currentTarget.value)}
        placeholder="Leave a note"
      />
      <input
        type="file"
        accept="image/*"
        class="text-sm"
        onChange={(event) => setPhoto(event.currentTarget.files?.[0] ?? null)}
      />
      <Button type="submit">Sign</Button>
    </form>
  );
}

function HomePage() {
  const entries = useQuery<Entry[]>("entries");
  return (
    <div class="space-y-4">
      <SignForm />
      {entries.length === 0 ? (
        <EmptyState title="No entries yet" description="Be the first to sign." />
      ) : (
        <ul class="space-y-2">
          {entries.map((entry) => (
            <li key={entry.id}>
              <Card>
                <p class="text-sm">
                  {entry.avatarUrl ? (
                    <img
                      class="mr-1 inline h-5 w-5 rounded-full align-text-bottom"
                      src={entry.avatarUrl}
                      alt=""
                    />
                  ) : null}
                  <strong>{entry.authorName}</strong> {entry.body}
                </p>
                {entry.photoUrl ? (
                  <img class="mt-2 max-h-40 rounded" src={entry.photoUrl} alt="" />
                ) : null}
              </Card>
            </li>
          ))}
        </ul>
      )}
    </div>
  );
}

function StatsPage() {
  const auth = useAuth();
  const entries = useQuery<Entry[]>("entries");
  const emailDigest = useAction<[], void>("emailDigest");
  const perDay = [...Array(7)].map((_, index) => {
    const day = new Date(Date.now() - (6 - index) * 86_400_000).toDateString();
    return entries.filter((entry) => new Date(entry.createdAt).toDateString() === day).length;
  });
  return (
    <Card title="Last 7 days" subtitle={`${entries.length} signatures so far`}>
      <Sparkline values={perDay} width={280} height={48} />
      {auth.isAuthenticated ? (
        <Button variant="ghost" size="sm" onClick={() => emailDigest()}>
          Email me this digest
        </Button>
      ) : null}
    </Card>
  );
}

export function App() {
  const auth = useAuth();
  return (
    <Router>
      <main class="mx-auto max-w-lg space-y-4 p-6">
        <header class="flex items-center justify-between">
          <nav class="flex items-baseline gap-3">
            <h1 class="text-lg font-semibold">
              <Link to="/">Guestbook</Link>
            </h1>
            <Link class="text-sm text-ink-muted" to="/stats">
              Stats
            </Link>
          </nav>
          {auth.isLoading ? (
            <Spinner size="sm" />
          ) : auth.isGuest ? (
            <SignInWithGoogle />
          ) : (
            <Button variant="ghost" size="sm" onClick={() => signOut()}>
              Sign out {auth.displayName}
            </Button>
          )}
        </header>
        <Routes>
          <Route path="/" element={<HomePage />} />
          <Route path="/stats" element={<StatsPage />} />
        </Routes>
      </main>
    </Router>
  );
}

Everything on the page is one of the runtime’s features doing its job:

  • Live queries. useQuery("entries") starts as an empty array and re-renders in every open tab whenever a mutation changes the rows, including rows the webhook writes. There is no cache wiring anywhere: write a row and every live query that reads it refreshes on its own.
  • Validation lives on the server. The sign mutation trims and caps the body. The client never writes rows directly.
  • Spam checking is one call. ctx.spam.check() classifies the entry before it is written. The runtime supplies the visitor’s network evidence from the trusted request envelope. A browser cannot claim its own IP.
  • Avatars come from Gravatar. ctx.gravatar.avatarUrl() is synchronous and credential-free. The mutation stores the URL with the row.
  • Actions send email. The emailDigest action reads the same tables and sends through ctx.email.send() with no mail credential to configure, and useAction() wires it to a button. In a mutation, a send commits atomically with the writes. See Email, spam, and Gravatar.
  • Sign-in is one component. SignInWithGoogle upgrades the visitor’s guest identity, and signOut() drops back to it.
  • Uploads are one call. storage.upload(photo) returns a read URL the row can keep. The photo is optional, and the form works without it.
  • Routing is included. Router, Routes, and Link give the app two pages without a dependency.
  • Charts are included. The stats page derives a week of counts from the same live query and hands them to Sparkline.
  • The webhook is plain HTTP. It checks a shared secret from .env.server before it writes, and answers with the endpoint helpers.

Run it:

sf init guestbook --runtime zero
cd guestbook
echo 'WEBHOOK_SECRET=pick-a-long-random-value' > .env.server
sf dev

Open the private URL sf dev prints. Signing the guestbook from the browser calls ctx.spam.check, which sf dev does not broker, so that path answers zero_spam_unavailable until you publish; the webhook writes without it.

Post an entry from outside. Application routes need the capability, so send it as a bearer token alongside the webhook’s own secret:

curl -X POST http://localhost:4173/webhooks/entries \
  -H "authorization: Bearer <capability>" \
  -H "content-type: application/json" \
  -H "x-webhook-secret: pick-a-long-random-value" \
  -d '{"body":"hello from a webhook"}'

The capability is the zero-dev-capability value in the URL sf dev printed. A published Space needs no capability: there the endpoint’s own secret is the only check.

sf publish makes it live: the capsule compiles, the migration applies, .env.server syncs as secret variables, and the version activates.

Styling

Write Tailwind utility classes directly in JSX on the class attribute. There is nothing to install or configure. Zero compiles every class used anywhere in client/, server/, and shared/ into the app’s stylesheet, light and dark variants included. Classes nobody uses produce nothing. A theme.json at the project root adjusts the palette and typography the utilities compile against.

The client also ships batteries:

  • @spacefast/zero/kit: the platform interface kit, with Button, Card, Input, Badge, Tabs, Table, CodeBlock, and more, plus Icon with the Lucide icon set.
  • @spacefast/zero/charts: LineChart, BarChart, and Sparkline, drawn as plain SVG with no charting library.
import { LineChart } from "@spacefast/zero/charts";
import { Badge, Card } from "@spacefast/zero/kit";

<Card title="Signups" subtitle="Last quarter">
  <Badge tone="success">Live</Badge>
  <LineChart
    data={[
      { month: "Jan", signups: 12 },
      { month: "Feb", signups: 31 },
      { month: "Mar", signups: 48 },
    ]}
    x="month"
    series={["signups"]}
  />
</Card>;

Authentication

Every Zero visitor starts with a stable guest identity: enough to own rows, return to them later, and keep anonymous users separate. Hosted sign-in upgrades the same browser session to an authenticated identity.

import { SignInWithGoogle, signOut, useAuth } from "@spacefast/zero/client";

function AuthControls() {
  const auth = useAuth();
  if (auth.isLoading) return null;
  if (auth.isGuest) return <SignInWithGoogle />;
  return <button onClick={() => signOut()}>Sign out {auth.displayName}</button>;
}

Hosted sign-in uses Gravatar. SignInWithGoogle is a compatibility alias that renders the “Sign in with Gravatar” button. useAuth() returns userId, displayName, provider ("guest" or "gravatar"), isGuest, isAuthenticated, email, picture, and isLoading.

On the server, the same identity is ctx.auth in every handler. Check ctx.auth.isGuest when a handler requires sign-in. For row ownership, store ctx.auth.userId with the row, filter reads through an owner index, and verify that value before an update or delete. Never accept an owner id from client arguments:

setDone: mutation(async (ctx, id: string, done: boolean) => {
  const todo = await ctx.db.todos.get(id);
  if (!todo || todo.ownerId !== ctx.auth.userId) return;
  await ctx.db.todos.update(id, { done });
}),

sf dev supplies a local guest identity, so authorization logic works the same locally and hosted.

Database

Every Zero app has its own database. Declare the schema in the capsule, and the publish command compares the declaration with the live schema and applies the migration. Fields support string(), boolean(), and id(table), with .default(value). Every row also has id, createdAt, and updatedAt.

Every table has a built-in by_creation index that reads rows in insertion order. Add .index(name, fields) for your own indexed reads with .withIndex():

// One row, by id.
const todo = await ctx.db.todos.get(id);

// Newest rows first, via the built-in creation index.
const latest = await ctx.db.todos.withIndex("by_creation").order("desc").take(20);

// An owner's rows, via a declared index.
const mine = await ctx.db.todos
  .withIndex("by_owner", (range) => range.eq("ownerId", ctx.auth.userId))
  .collect();

Indexed queries finish with .collect(), .take(count), .first(), or .paginate(), and support .order("asc") and .order("desc"). Mutation contexts add .insert(), .update(id, patch), and .delete(id).

Normal additive changes apply during sf publish. Destructive changes require an explicit migration command, because a publish cannot silently drop data. Express the rename or drop in the capsule schema first, then allow the planned migration to include it with the matching boolean flag:

sf db migrate --rename
sf db migrate --drop

Inspect and back up:

sf db dump --table projects --limit 100
sf db console
sf db export --out ./backup.json

The export contains every declared table in a versioned JSON format and only replaces the destination file after the complete export succeeds. A rollback promotes older code. It does not rewind database rows. Check the current schema before you roll back across a migration.

Storage

Zero client storage handles browser uploads without a separate storage credential. Objects are addressed by a random 128-bit id. Read URLs carry a runtime read key that Spacefast can rotate. Rotation immediately invalidates every URL minted under the old key.

import { storage } from "@spacefast/zero/client";

const uploaded = await storage.upload(file);
uploaded.url; // a read URL to store or render
await storage.delete(uploaded.id);

Uploads and deletes require an identified visitor. Anonymous commenters are admitted where Comments admits them, against the daily anonymous budget. Only the uploader can delete an object. The space owner gets an inventory across uploaders with sf storage ls and can force-delete with sf storage rm 0123456789abcdef0123456789abcdef --yes. That delete is destructive and unrecoverable.

Safety and limits: 5 MiB per object, a 200 MiB rolling daily budget for anonymous uploads per space, no empty uploads, and no executable or active web content (HTML, JavaScript, PHP, binaries). Total storage counts against the plan’s storage limit.

Variables

Keep secrets that belong to your app in .env.server. The guestbook’s webhook uses one to authenticate incoming requests:

WEBHOOK_SECRET=replace_with_a_random_secret

The publish command syncs the file as secret variables, and server handlers read them through ctx.env. Platform services are already configured: use ctx.email, ctx.spam, and ctx.gravatar without SMTP or provider API keys. See Variables for shared and space-level management.

Operate a live app

sf runtime status
sf logs runtime --follow

Every space already has its own site, so a capsule publish onto an existing space is an ordinary publish, and nothing migrates. Code belongs to the version. Database rows and stored objects belong to the space and do not roll back with it.

The compiled server bundle is capped at 768 KiB and the client bundle at 8 MiB.

Compare to Lakebed

A Zero project is a Lakebed capsule: the same layout (server/index.ts default-exporting capsule(), client/index.tsx exporting App, shared/ for both sides), the same schema and handler API (table(), string(), boolean(), id(table), queries, mutations, endpoint() with json() and text()), and the same client hooks (useQuery, useMutation, useAuth, the router, SignInWithGoogle, signOut). Tailwind classes in JSX work the same way.

Compatibility is built into the compiler, not left to convention:

  • Imports from lakebed/server and lakebed/client resolve to the Zero runtime. @spacefast/zero/server and @spacefast/zero/client are the canonical names; both work.
  • .env.lakebed.server is read wherever .env.server is.

To port a capsule, copy the project and swap the CLI: sf dev to run it locally and sf publish instead of a deploy.

What changes on Spacefast:

  • Hosted sign-in is Gravatar, and SignInWithGoogle renders the “Sign in with Gravatar” button.
  • The project is a space: sf.jsonc config, versions, rollback, custom domains, and the rest of the platform.

What Zero adds beyond Lakebed:

  • The platform kit and charts, covered in Styling.
  • empty(), redirect(), and ImageResponse endpoint helpers.

Last updated on August 28, 2026

Was this page helpful?