Lakebed Reference

Use this as the quick contract when building a Lakebed capsule.

Capsule

A capsule is one complete Lakebed app: source, server API, client UI, state, auth, logs, and deploy URL.

V0 expects this directory shape:

server/index.ts
client/index.tsx
shared/
.env.lakebed.server

There is no lakebed.config.ts in v0.

Module Boundaries

Server API

import { action, boolean, capsule, mutation, query, string, table, userId } from "lakebed/server";

Export one default capsule() call:

export default capsule({
  auth: { requireSignIn: false },
  schema: {
    todos: table({
      text: string(),
      done: boolean().default(false),
      ownerId: userId()
    }).index("by_owner", ["ownerId"])
  },
  queries: {
    todos: query(async (ctx) => {
      const { userId } = ctx.auth.requireIdentity();
      return ctx.db.todos
        .withIndex("by_owner", (q) => q.eq("ownerId", userId))
        .order("desc")
        .collect();
    })
  },
  mutations: {
    addTodo: mutation(async (ctx, text: string) => {
      const { userId } = ctx.auth.requireIdentity();
      return ctx.db.todos.insert({
        text,
        done: false,
        ownerId: userId
      });
    })
  },
  actions: {
    summarize: action(async (ctx, label: string) => {
      const { userId } = ctx.auth.requireIdentity();
      const todos = await ctx.db.todos
        .withIndex("by_owner", (q) => q.eq("ownerId", userId))
        .collect();
      return { count: todos.length, label };
    })
  }
});

capsule() accepts these keys, and nothing else:

KeyTypePurpose
namestringApp title used for the browser tab. Defaults to Lakebed Capsule.
faviconstringRelative path to an .svg or .ico file in the capsule, at most 64 KiB. Without it, Lakebed uses favicon.svg or favicon.ico at the capsule root. If neither file exists, the capsule serves no favicon. lakebed new writes a starter favicon.svg so a fresh capsule has one.
authCapsuleAuth{ requireSignIn?, onGuestUpgrade? }. See require sign-in and guest upgrades.
schematable mapTables declared with table({ ...fields }).
querieshandler mapRead-only handlers declared with query().
mutationshandler mapRead-write handlers declared with mutation().
actionshandler mapRead-only handlers declared with action(), called directly by the client.
endpointsendpoint mapHTTP routes declared with endpoint().
export default capsule({
  name: "Todo",
  favicon: "assets/icon.svg",
  // schema, queries, mutations, actions, and endpoints follow
});

The favicon path must stay inside the capsule. Absolute paths, .. segments, and extensions other than .svg and .ico fail the build.

Server handlers receive:

Actions and queries have read-only database access. Mutations and endpoints can write to the database. An endpoint with readOnly: true cannot write.

query(), mutation(), and action() only wrap a handler so TypeScript can infer its ctx. endpoint() does more. It rejects a non-boolean readOnly, then returns an EndpointDefinition that holds kind, the upper-cased method, the path, and readOnly. Lakebed reads that object to register the route. The capsule API guide shows each one in a full capsule.

Data API

Tables are declared with table({ ...fields }). Field helpers are:

HelperRow typeNotes
string()string
boolean()boolean
number()numberFinite float64. NaN and Infinity are rejected on write.
id("table")stringRow id from the named table.
userId()stringLakebed user reference that follows a guest upgrade.
.default(value)sameFills the value when an insert omits the field.
.optional()`T \undefined`The field may be absent. Pass null to clear it.
.index(name, fields)Declares an index on a table.
scores: table({
  playerId: id("players"),
  points: number(),
  note: string().optional(),
  streak: number().optional().default(0)
}).index("by_points", ["points"])

number(), userId(), and .optional() need a database API v1 artifact. Every new build produces one. Rebuild and redeploy older capsules before using them.

Index order is numeric for number() fields. Absent optional values sort first. See the database guide for the full sort rules and the schema-change rule.

Every stored row includes:

Table methods are async. Use withIndex(name, range), then order("asc" | "desc") and one terminal: collect(), take(count), first(), or paginate(options). Direct get, insert, update, and delete are also awaited. Legacy where, orderBy, limit, and all calls are rejected for new artifacts.

See the database guide for index and consistency details. Use the Database API v1 migration guide for before-and-after syntax and a copy-paste agent prompt.

Treat queries and mutations as the source of truth. Get a verified caller with ctx.auth.requireIdentity(), filter user-owned data by its userId, and re-check ownership before changes or deletes. userId() does not enforce ownership. Shared queries must deliberately omit the owner filter.

External Endpoints

Use endpoint({ method, path }, handler) for webhooks and other services that call your app over HTTP.

import { endpoint, json, text } from "lakebed/server";

endpoints: {
  stripeWebhook: endpoint({ method: "POST", path: "/webhooks/stripe" }, async (ctx, req) => {
    if (req.headers.get("x-webhook-secret") !== ctx.env.STRIPE_WEBHOOK_SECRET) {
      return text("unauthorized", { status: 401 });
    }

    const body = await req.text();
    ctx.log.info("stripe webhook received", { bytes: body.length });
    return json({ ok: true });
  })
}

Endpoint handlers receive ctx.auth, ctx.db, ctx.env, and ctx.log. The request exposes method, path, url, headers.get(name), query, text(), json(), and bytes(). Successful endpoint calls can write to the database and publish subscribed queries. Use .env.lakebed.server secrets for webhook checks.

Return one of four response helpers: json(value, options?), text(value, options?), empty(options?) for a 204, or redirect(url, options?) for a 302. All four take { status?, headers? }. See endpoint responses.

An endpoint reads a Lakebed identity from the reserved X-Lakebed-Token header. Browser code gets that token from getIdentity(). See endpoint credentials.

Set readOnly: true for an endpoint that only reads the database:

endpoint({ method: "GET", path: "/api/items", readOnly: true }, async (ctx) =>
  json(await ctx.db.items.withIndex("by_creation").collect())
);

Read-only endpoints can run at the same time as other reads and mutations. Lakebed rejects database writes from these handlers. They use request quota, not mutation quota. GET and HEAD endpoints default to read-only. Other methods default to writable. Set readOnly: false explicitly if a GET or HEAD endpoint must write. This default applies when an app is built with the updated SDK. Existing deployed artifacts keep their original behavior.

Custom endpoint Authorization headers belong to the app, including Bearer and Basic webhook credentials. Send a Lakebed identity token in X-Lakebed-Token, without a Bearer prefix. Lakebed verifies and removes that reserved header before calling the handler. Same-origin guest cookies also identify the caller. See endpoint credentials.

Client API

import {
  canAccessApp,
  createClient,
  ErrorBoundary,
  getIdentity,
  Link,
  Route,
  Router,
  Routes,
  SignInWithGoogle,
  navigate,
  retryAuth,
  signInWithGoogle,
  signOut,
  useAction,
  useAuth,
  useLocation,
  useMutation,
  useNavigate,
  useParams,
  usePaginatedQuery,
  useQuery
} from "lakebed/client";

At most 4 mutation and action calls are in flight at once on a connection. Later calls wait in the client and go out in call order as earlier ones finish, so fast input such as drag-and-drop does not fail. Calls in flight at the same time can finish in a different order, so await a call before a call that depends on its result. A call that has not gone out yet fails with an error that says "the server did not apply it" when the connection closes or the signed-in user changes. It is not sent later. Read the current state, then call it again if it still applies. A call that already went out keeps the old rule: if the connection closes first, its result is unknown. At most 1,000 calls can wait. Put many rows in one mutation instead of one call per row.

The typed client reads the server definition through a type-only import:

import { createClient } from "lakebed/client";
import type app from "../server/index";

const client = createClient<typeof app>();

const todos = client.useQuery("todos");
const addTodo = client.useMutation("addTodo");
const summarize = client.useAction("summarize");

await addTodo("Ship the app");
const summary = await summarize("today");

client.useQuery("todos") returns undefined until the first result arrives. Mutation and action calls return promises. Query, mutation, and action arguments and results must be JSON-compatible.

On a hosted app, the client also sends anonymous timing counts to Lakebed over the app connection: how long page loads, first query results, mutations, and reconnects take. It sends only histogram counts, with no user IDs, query names, arguments, results, or URLs.

Treat query results as read-only, including nested objects and arrays. Subscribers share each result. Copy values before you edit or sort them. For example, use [...todos].sort(...) instead of todos.sort(...). Lakebed freezes query results during lakebed dev to catch accidental changes. Use mutations to change stored data.

The standalone useQuery, useMutation, useAction, and usePaginatedQuery hooks remain available when callers provide their own types. Standalone useQuery has the same loading contract as client.useQuery: it returns undefined until the first result arrives.

Paginated query handlers accept a trailing argument whose pagination property is passed to .paginate(...):

messages: query(async (ctx, args: { roomId: string; pagination: { cursor: string | null; numItems: number } }) =>
  ctx.db.messages
    .withIndex("by_room", (q) => q.eq("roomId", args.roomId))
    .order("desc")
    .paginate(args.pagination)
)
const messages = usePaginatedQuery<Message>("messages", { roomId }, { initialNumItems: 25 });

return (
  <>
    {messages.page.map((message) => <p key={message.id}>{message.body}</p>)}
    {!messages.isDone ? <button onClick={messages.loadMore}>Load more</button> : null}
  </>
);

Router example:

function TodoPage() {
  const { id } = useParams<{ id: string }>();
  return <main>Todo {id}</main>;
}

export function App() {
  return (
    <Router>
      <Link to="/todos/123">Open todo</Link>
      <Routes>
        <Route path="/" element={<main>Home</main>} />
        <Route path="/todos/:id" element={<TodoPage />} />
        <Route path="*" element={<main>Not found</main>} />
      </Routes>
    </Router>
  );
}

A server endpoint takes precedence over a client route only when both its path and HTTP method match the request. Opening or reloading a URL makes a GET request. Client router navigation stays in the loaded app. Keep API paths separate from page paths. See client routes.

Actions

action(handler) declares an action in the actions map of a capsule. useAction(name) and client.useAction(name) return a function that runs the named action.

Action handlers receive ctx.auth, read-only ctx.db, ctx.env, and ctx.log. Actions do not write to the database or trigger query invalidation. Outbound fetch and hosted server env require a claimed deploy.

The limits page defines action argument, result, and runtime limits for local development and hosted deployments. The handler capability table compares database writes, fetch, env, auth, and quota use across handlers.

Hosted actions count against both the request limit and the mutation limit. Closing the client connection cancels actions that have not completed.

Auth

Guest access uses protected browser sessions by default. User IDs are opaque references, not session credentials.

StateuserIdproviderisGuestisSignedIn
No sessionnullnullfalsefalse
Gueststring"guest"truefalse
Accountstring"google"falsetrue

isAuthenticated remains an alias for isSignedIn. displayName is present in each state. Signed-in identities can include subject, migration-only identityAliases, email, emailVerified, and picture. Profile data does not prove access.

Server guards:

const identity = ctx.auth.requireIdentity(); // Guest or account, with a user ID.
const account = ctx.auth.requireSignedIn(); // Signed-in account only.

Set auth: { requireSignIn: true } in capsule() to block all app data operations before sign-in. The shell and auth routes remain available. The client exposes auth.requireSignIn, auth.isLoading, and auth.error for UI state. Wait for session setup, show errors with a retryAuth() button, and mount private data components only when the session satisfies the app policy.

Store Lakebed user references with userId(). Those fields transfer automatically on a verified guest upgrade. Existing account rows remain. Set auth.onGuestUpgrade for app-specific conflict rules before automatic transfer. Plain strings and external data do not transfer. See the auth guide for the transaction and session contracts.

Named local test overrides use npx lakebed auth as alice or ?lakebed_guest=alice. They are not available in hosted apps and cannot upgrade to accounts. npx lakebed auth reset restores normal protected sessions. Use the default browser session to test a real upgrade.

Server Env

Put server-only values in .env.lakebed.server:

OPENAI_API_KEY=sk-...

Read them from server handlers:

query((ctx) => Boolean(ctx.env.OPENAI_API_KEY));

npx lakebed dev loads this file locally. Hosted env syncs only after the deploy is claimed. Sync is replace-based: keys removed from .env.lakebed.server are removed from the hosted deploy.

Env values are not exposed to client code and are not embedded in anonymous artifacts.

CLI environment variables

.env.lakebed.server is your app's env. These four variables configure the Lakebed CLI on your machine or in your CI, where there is no saved login. They never reach your app or the hosted service. Every one is optional.

NameEffect
LAKEBED_TOKENCredential from npx lakebed token create. Used instead of the saved developer login.
LAKEBED_TOKEN_APIThe one API origin allowed to receive LAKEBED_TOKEN. Required when you also pass a custom --api.
LAKEBED_DEPLOY_APIDefault deploy API origin. --api overrides it.
LAKEBED_INSPECT_TOKENToken for inspect, db list, db dump, db export, logs, and storage list against a hosted deploy. --inspect-token overrides it.
LAKEBED_TOKEN=lkb_... npx lakebed deploy
LAKEBED_INSPECT_TOKEN=lkb_... npx lakebed db export <deploy-id-or-url> --out backup.json

Styling

Use Tailwind classes directly in JSX. Lakebed compiles them automatically during dev, build, and deploy. New builds include compiled CSS without loading the Tailwind browser compiler. Redeploy older apps with the current CLI to use compiled CSS.

Write complete class names in client files or shared files imported by the client. Conditional classes such as done ? "text-neutral-500" : "text-white" work. Constructed names such as bg-${color}-500 do not. For values loaded at runtime, use inline styles or select from a map of complete class names in the client source.

V0 does not support CSS files, CSS modules, PostCSS, or Tailwind config. You do not need a separate CSS build command.

Runtime Inspection

npx lakebed dev and the hosted runner serve the same inspection routes with the same JSON shapes. The inspect loop is identical: build locally, read a route, deploy, read the same route.

While npx lakebed dev is running:

npx lakebed inspect --port 3000
npx lakebed db list --port 3000
npx lakebed db dump --port 3000
npx lakebed db export --port 3000 --out backup.json
npx lakebed logs --port 3000

For a deployed app:

npx lakebed inspect <deploy-id-or-url>
npx lakebed db list <deploy-id-or-url>
npx lakebed db dump <deploy-id-or-url>
npx lakebed db export <deploy-id-or-url> --out backup.json
npx lakebed logs <deploy-id-or-url>

The CLI reads these GET routes on the app origin. Call them directly with curl when the CLI is not enough.

RouteReturns
/__lakebed/manifestApp name, deploy id, URL, runtime version, inspect policy, query, mutation, action, and endpoint names, schema, favicon, limits, database index and read metrics.
/__lakebed/usagelimits, state with stored stateRows and stateBytes, and usage with the daily requests and mutations counters.
/__lakebed/urlbasePath and the app url.
/__lakebed/db/tablesTable names, row counts, and database index state.
/__lakebed/dbBounded row dump per table plus truncated. Stops at the rowsReturned limit.
/__lakebed/db/exportBackup metadata. Add ?table=<name>&schemaHash=<hash> for pages.
/__lakebed/logsThe last 100 log entries as { at, level, message, data }.
/__lakebed/storageUploaded objects with size, visibility, and uploader.

Local /__lakebed/usage counts requests and mutations the same way the hosted runner does, so an agent can see how close an app is to a daily limit before deploying. npx lakebed dev only counts. It never rejects on a daily limit.

Two routes exist only on the hosted runner: /__lakebed/access/status (restricted access users) and /__lakebed/report (abuse reports). npx lakebed dev answers them with 404 and a JSON body with hostedOnly: true and a hint to run npx lakebed deploy, so an agent knows the route exists and what to do.

Local state is in-memory and resets when npx lakebed dev restarts.

db dump is a bounded inspection view. db export scans the built-in by_creation index in bounded pages and writes a versioned JSON backup through a same-directory temporary file before atomically replacing the destination. Tables are ordered by name; rows are ordered by immutable (createdAt, id) ascending; row object keys are serialized canonically. Each page is a separate store snapshot, so the complete backup is not a point-in-time transaction: concurrent inserts after the cursor may appear, deletes not yet visited may disappear, and updates reflect the page that reads them. A schema change, authorization failure, network/write error, or handled interruption fails the export, preserves an existing destination, and removes the temporary file.

Local inspection needs no token. npx lakebed dev binds to 127.0.0.1, so only programs on your machine can reach it. --host 0.0.0.0 serves the whole network, and then every inspection route, including row dumps and logs, is open to that network. Hosted inspection is private by default for manifests, table names, row dumps, logs, and usage. Run hosted inspection commands from the capsule directory so the CLI can find lakebed.json or .lakebed/deploy.json in the working directory tree and send developer auth from the binding or saved anonymous claim token. Direct HTTP callers can use Authorization: Bearer <token>. Non-private hosted manifests expose only app name, deploy id, client bundle hash, runtime version, and favicon metadata (content type, hash, route, and source path) when the app has one.

CLI

npx lakebed new [name] [--template todo] [--no-git]
npx lakebed create [name] [--template todo] [--no-git]
npx lakebed init [--template todo] [--no-git]
npx lakebed dev [capsule-dir] [--port 3000] [--host 127.0.0.1]
npx lakebed build [capsule-dir] [--target claimed|anonymous] [--out .lakebed/artifacts/app.json] [--json]
npx lakebed deploy [capsule-dir] [--api <url>] [--public-inspect] [--json]
npx lakebed deploy list [--api <url>] [--json]
npx lakebed deploy archive <deploy-id> [--api <url>] [--json]
npx lakebed deploy restore <deploy-id> [--api <url>] [--json]
npx lakebed deploy terminate <deploy-id> --yes [--api <url>] [--json]
npx lakebed claim [capsule-dir] [--api <url>] [--json]
npx lakebed auth login [--api <url>] [--json]
npx lakebed auth status [--api <url>] [--json]
npx lakebed auth logout [--api <url>]
npx lakebed token create --name <name> [--personal] [--api <url>] [--json]
npx lakebed token list [--api <url>] [--json]
npx lakebed token revoke <token-id> [--api <url>]
npx lakebed domains add <hostname> [--api <url>] [--json]
npx lakebed domains status <hostname> [--api <url>] [--json]
npx lakebed domains list [--api <url>] [--json]
npx lakebed domains remove <hostname> [--api <url>] [--json]
npx lakebed inspect [deploy-id-or-url] [--port 3000] [--api <url>] [--inspect-token <token>] [--usage] [--json]
npx lakebed run-many [capsule-dir] [--count 20] [--base-port 4000]
npx lakebed auth as <name>
npx lakebed auth reset
npx lakebed db list [deploy-id-or-url] [--port 3000] [--inspect-token <token>]
npx lakebed db dump [deploy-id-or-url] [--port 3000] [--inspect-token <token>]
npx lakebed db export [deploy-id-or-url] [--port 3000] [--inspect-token <token>] [--out <file>]
npx lakebed logs [deploy-id-or-url] [--port 3000] [--inspect-token <token>]
npx lakebed storage list [deploy-id-or-url] [--prefix <key-prefix>] [--port 3000] [--inspect-token <token>] [--json]
npx lakebed storage get <key> [deploy-id-or-url] [--out <file>] [--port 3000] [--json]
npx lakebed storage put <file> [deploy-id-or-url] [--public] [--content-type <type>] [--port 3000] [--json]
npx lakebed storage delete <key> [deploy-id-or-url] [--port 3000] [--json]
npx lakebed users list [deploy-id] [--api <url>] [--json]
npx lakebed users approve <id-or-email> [deploy-id] [--api <url>] [--json]
npx lakebed users deny <id-or-email> [deploy-id] [--api <url>] [--json]
npx lakebed users remove <id-or-email> [deploy-id] [--api <url>] [--json]

Build behavior

npx lakebed build builds locally without sign-in or a deploy. It defaults to --target claimed, which allows server-side fetch. Use --target anonymous only to check anonymous deploy restrictions. Building does not publish the app or change deploy ownership. Hosted fetch and server env still require an owned or claimed deploy.

Deploy Behavior

npx lakebed deploy can publish an anonymous deploy first.

Claim the deploy before relying on hosted server env or outbound server-side fetch, then run npx lakebed deploy again. Anonymous deploys intentionally disable those capabilities while preserving server handler control flow in the source runtime.

Run npx lakebed auth login before the first deploy to create an owned app. The CLI writes lakebed.json at the capsule root with only deployId; commit it for fresh-checkout and CI deploys. npx lakebed token create --name github-actions returns a deploy-scoped CI credential once. Use npx lakebed token create --personal --name local-automation when automation needs an owner-wide credential instead. Supply the returned value as LAKEBED_TOKEN. For a custom API origin, LAKEBED_TOKEN_API must match the canonical --api origin exactly. The canonical origin is the scheme and host, plus a non-standard port when present, without a path or query.

Hosted deploy inspection is private by default. Use npx lakebed deploy --public-inspect only for demos where making data and logs public is intentional.

Claimed deploys can reserve Lakebed-owned app subdomains with npx lakebed domains add my-app.lakebed.app. Reserved product names such as api, admin, docs, and www cannot be registered.

npx lakebed deploy list shows owned deploys and their lifecycle status. npx lakebed deploy archive <deploy-id> stops an app without deleting its resources. npx lakebed deploy restore <deploy-id> makes an archived app available again.

npx lakebed deploy terminate <deploy-id> --yes stops an app and prevents later deploy updates. Without --yes, the command prints a confirmation prompt and does not terminate the app. A terminated deploy cannot be restored.

Archive and terminate retain the deploy record, stored data, logs, usage records, server env, domain mappings, and referenced artifact. Archived and terminated app URLs do not serve traffic or inspection routes. The CLI does not provide a command to delete retained deployment data.

Hosted anonymous deploys enforce the state byte limit during mutation commit, cap logs by entry count and bytes, and limit deploy creation, app requests, and app mutations. limits.md describes default resource limits and their errors. Unclaimed deploys expire and stop serving. About one week after expiry, Lakebed deletes unclaimed deploys with their data, logs, and server env. Claimed deploys do not expire.

Custom Domains

domains manages the hostnames attached to a claimed, active deploy. *.lakebed.app subdomains are free on every plan. Attaching a domain you own (app.example.com, or an apex example.com) requires a boosted plan; the free plan is capped at 0 custom domains and the pro plan at 5 per account.

npx lakebed domains add <hostname> [--api <url>] [--json]
npx lakebed domains status <hostname> [--api <url>] [--json]
npx lakebed domains list [--api <url>] [--json]
npx lakebed domains remove <hostname> [--api <url>] [--json]

add registers the hostname and prints the DNS records to create: a CNAME pointing the hostname at Lakebed and a TXT record that proves ownership. Create them exactly as printed, then run status until the domain is active. For an apex domain (no subdomain), use your registrar's ALIAS/ANAME or CNAME-flattening record instead of a static A record. The hostname must not be under lakebed.app or lakebed.dev. Wildcards are unsupported, so add each hostname individually.

status re-checks verification and certificate issuance and drives the domain through its lifecycle: pending_dns → verified → active. It can also report failed, disabled, or deleting. Exit codes make it loopable: 0 active, 2 pending, 1 failed.

failed is not terminal. Fix the DNS records and keep polling; retryable certificate failures re-issue on their own. A hostname whose registration the provider removed is the exception: remove and re-add it. disabled and deleting are terminal. Requests to a failed hostname get a 404, and a disabled or deleting hostname gets a 410.

list prints every domain on the deploy. remove detaches a hostname; it works for .lakebed.app subdomains too.

Verification uses the TXT record: the CNAME can resolve while the hostname still returns 404 until the TXT record is observed. This is expected, so keep re-running status. DNS propagation can take time. TLS certificates are issued automatically after verification, with no further action. Signed-in users keep the same userId across the generated *.lakebed.app URL and the custom domain. Archiving or terminating an app releases its custom domains; restoring the app does not resurrect them, so re-add them.

HTTP

POST   /v1/deploys/:id/domains          {hostname}
GET    /v1/deploys/:id/domains
GET    /v1/deploys/:id/domains/:hostname   (refresh)
DELETE /v1/deploys/:id/domains/:hostname

For a custom domain the response envelope adds status, statusDetail, certificateStatus, dnsRecords (an array of {name, type, value}), and lastCheckedAt. The same fields back --json output.

Stable error codes:

Reserved product names (api, admin, docs, www, and similar) remain unregisterable as .lakebed.app subdomains; see Deploy Behavior.