Quickstart
Get a helipod backend running and watch it update live, in a few minutes.
You'll build a tiny backend with a messages table, a query that lists messages, and a mutation that
adds one. Then you'll watch the list update the moment you write a new row. It takes a few minutes,
and every step below is real.
Before you start
You need Bun or Node.js 22.5+ and a package manager. Your backend lives in a folder called
helipod/. That's just the default name (everything you import is @helipod/*), and you can
change it later with --dir.
Install helipod
mkdir my-app && cd my-app && npm init -y
npm install @helipod/values @helipod/client
npm install -D @helipod/climkdir my-app && cd my-app && pnpm init
pnpm add @helipod/values @helipod/client
pnpm add -D @helipod/climkdir my-app && cd my-app && bun init -y
bun add @helipod/values @helipod/client
bun add -d @helipod/clivalues gives you the schema and validators, client is the browser and Node client, and cli is
the helipod command you'll run.
Define a schema
Create helipod/schema.ts. It lists your tables and the shape of each row:
import { defineSchema, defineTable, v } from "@helipod/values";
export default defineSchema({
messages: defineTable({
author: v.string(),
body: v.string(),
}),
});Every row also gets an _id and a _creationTime automatically, so you never declare those. The full
list of field types (v.number(), v.id(...), v.optional(...), and so on) is in
Schema & tables.
Write a query and a mutation
Create helipod/messages.ts:
import { v } from "@helipod/values";
import { query, mutation } from "./_generated/server";
// Reads data. Your app subscribes to this and gets live updates.
export const list = query({
handler: (ctx) => ctx.db.query("messages", "by_creation").collect(),
});
// Writes data. Runs as one transaction.
export const send = mutation({
args: { author: v.string(), body: v.string() },
handler: (ctx, args) => ctx.db.insert("messages", args),
});by_creation is a built-in index every table has, so list returns every message oldest-first with
no index of your own.
Your editor will flag ./_generated/server as missing. That's expected. The next step creates it.
Run it
npx helipod devhelipod dev generates the typed helipod/_generated/ helpers, starts the engine on a local SQLite
file, serves a dashboard, and reloads whenever you edit a file. It prints its URL and a dev admin key:
helipod dev → http://127.0.0.1:3000 (dashboard: http://127.0.0.1:3000/_dashboard)
admin key → 3q2xN7vRkYw8pTzL5mA9cB1dE6fGhJ0uThe admin key is a fresh random value on each run (unless you set HELIPOD_ADMIN_KEY yourself).
Yours will differ.
Watch it update live
You don't even need client code to see reactivity. The dashboard's data browser is itself a live subscription:
- Open the dashboard URL that
helipod devprinted. - Open the data browser and select the
messagestable. Leave it on screen. - Open the function runner and call
messages:sendwith{ "author": "Ada", "body": "hello" }. - The new row shows up in the data browser right away, with no refresh.
That live view is a real reactive subscription (the dashboard subscribes to a built-in admin query,
_admin:browseTable, over the same sync connection your app would use). A mutation wrote a row, and
every subscription reading that table refreshed on its own, the dashboard's included. You wrote no
polling and no invalidation code.
Connect a client
With the server running, point a client at the ws://127.0.0.1:3000/api/sync endpoint it opened:
import { HelipodClient, webSocketTransport } from "@helipod/client";
import { HelipodProvider, useQuery, useMutation } from "@helipod/client/react";
import { api } from "./helipod/_generated/server";
const client = new HelipodClient(webSocketTransport("ws://127.0.0.1:3000/api/sync"));
function Messages() {
const messages = useQuery(api.messages.list, {});
const send = useMutation(api.messages.send);
if (messages === undefined) return <p>Loading…</p>;
return <ul>{messages.map((m) => <li key={m._id}>{m.author}: {m.body}</li>)}</ul>;
}
export function App() {
return (
<HelipodProvider client={client}>
<Messages />
</HelipodProvider>
);
}useQuery subscribes the component to the query. It returns undefined until the first result
arrives, then re-renders on its own every time the result changes. That's the whole pattern.
import { HelipodClient, webSocketTransport } from "@helipod/client";
import { api } from "./helipod/_generated/server";
const client = new HelipodClient(webSocketTransport("ws://127.0.0.1:3000/api/sync"));
// Subscribe. The callback fires again every time the result changes.
client.subscribe(api.messages.list, {}, (messages) => {
console.log("messages:", messages);
});
// Write. The subscription above fires right after this commits.
await client.mutation(api.messages.send, { author: "Ada", body: "hello" });See Client SDK for the full hook surface, and Tutorial for a complete app built this way.
Going deeper
| Flag | Default | What it does |
|---|---|---|
--port | 3000 | HTTP and WebSocket port |
--ip | 127.0.0.1 | Bind address |
--dir | helipod | Function directory |
--data | .helipod/data.db | SQLite file path |
--web | (none) | Serve a built web UI alongside the API |
--database-url | (SQLite) | Use Postgres instead |
--storage-bucket | (local disk) | Use S3-compatible file storage |
--storage-endpoint | (none) | S3-compatible endpoint URL (MinIO, R2, a non-default AWS region) |
Run helipod help for every command and flag.
After the first run, helipod/_generated/ holds typed helpers derived from your schema and functions:
Doc<"messages">andId<"messages">: the type of a row, and a typed id to reference one.api: a typed object mirroring your functions (api.messages.list,api.messages.send). Pass these touseQuery/useMutationand the client infers the argument and return types.
helipod dev regenerates these on every change, so you rarely run codegen by hand. You do need to
commit _generated/ before a helipod serve or helipod build deploy, since those don't run
codegen at boot.
By default, everything is a single SQLite file at .helipod/data.db (change it with --data).
Nothing to install. To use Postgres instead, pass --database-url postgres://... and change nothing
in your app. See Postgres.
Next steps
- Tutorial: build a real React app (a task list) and watch it sync live across two tabs.
- How it works: why the list updated on its own.
- Schema & tables and Queries: go deeper on what you just wrote.
- Self-hosting: take this to a real deployment with Docker.