helipod
Get Started

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/cli

values 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:

helipod/schema.ts
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:

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 dev

helipod 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 → 3q2xN7vRkYw8pTzL5mA9cB1dE6fGhJ0u

The 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:

  1. Open the dashboard URL that helipod dev printed.
  2. Open the data browser and select the messages table. Leave it on screen.
  3. Open the function runner and call messages:send with { "author": "Ada", "body": "hello" }.
  4. 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:

App.tsx
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.

See Client SDK for the full hook surface, and Tutorial for a complete app built this way.

Going deeper

Next steps

On this page