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.
Database
A schema in code, migrations on publish, live queries in the client.
Authentication
Guest identity for every visitor, hosted sign-in to upgrade it.
Storage
Browser uploads with revocable read URLs and no credential setup.
Styling
Tailwind classes in JSX, the platform kit, and SVG charts.
Endpoints
Raw HTTP routes for webhooks, feeds, and generated images.
Operations
Status, logs, backups, and rollbacks through the sf CLI.
Quick start
Create and run
sf init my-app --runtime zero
cd my-app
sf devOpen 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 publishThe 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
- client/
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
signmutation 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
emailDigestaction reads the same tables and sends throughctx.email.send()with no mail credential to configure, anduseAction()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.
SignInWithGoogleupgrades the visitor’s guest identity, andsignOut()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, andLinkgive 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.serverbefore 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, withButton,Card,Input,Badge,Tabs,Table,CodeBlock, and more, plusIconwith the Lucide icon set.@spacefast/zero/charts:LineChart,BarChart, andSparkline, 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/serverandlakebed/clientresolve to the Zero runtime.@spacefast/zero/serverand@spacefast/zero/clientare the canonical names; both work. .env.lakebed.serveris read wherever.env.serveris.
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
SignInWithGooglerenders the “Sign in with Gravatar” button. - The project is a space:
sf.jsoncconfig, 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(), andImageResponseendpoint helpers.