helipod
Get Started

Tutorial

Build a small reactive chat app, end to end, with a real React UI.

The Quickstart got one query and one mutation running and showed you reactivity in the dashboard. This tutorial goes further. You'll build a small chat app: multiple conversations, a real React UI, pagination, an action that calls out to the network, and live updates across two browser tabs. It's the full shape a real helipod app is built from.

Every step below is runnable. You'll need Bun for this one. The tutorial bundles its own UI with bun build, so Bun is required here even though the backend engine itself also runs on Node.

What you'll build

  • A conversations table and a messages table, related by an indexed foreign key, with messages also carrying a shard key (the scale-out annotation: free and inert on a single-node deployment, but the same code that would let this table's writes fan out across nodes later).
  • Queries that read a whole conversation (.eq on an index) and page through one 20 messages at a time (.paginate).
  • Mutations that insert, replace, and delete (the three ways a mutation writes), each fanning out reactively to every open subscription that reads what it touched.
  • An action that reaches out to the network, callable from the client with useAction.
  • A React UI wired with HelipodProvider, useQuery, useMutation (with an optimistic update for instant sends), and useAction, plus one-shot manual pagination via the client directly.
  • Two browser tabs open side by side, proving the whole thing is reactive with zero refresh code.

Before you start

You need Bun, for bun build (bundling the web UI) and to run helipod dev itself. No prior helipod project needed. This tutorial creates one from scratch.

First time through? Skim past the sharding commentary (.shardKey/shardBy notes in steps 2 and 4). It changes nothing about how this app behaves on one node, and the app works identically if you ignore it. Come back to it with Scaling when you care about multi-node.

Set up the project

mkdir chat-app && cd chat-app
bun init -y
bun add @helipod/cli @helipod/values @helipod/client react react-dom
  • @helipod/cli is the helipod command.
  • @helipod/values gives you defineSchema, defineTable, and the v validators.
  • @helipod/client is the browser client: the reactive connection, plus its /react subpath for hooks.

Define the schema

Your backend lives in helipod/ (the default function directory). Create helipod/schema.ts:

helipod/schema.ts
import { defineSchema, defineTable, v } from "@helipod/values";

export default defineSchema({
  conversations: defineTable({
    title: v.string(),
  }),
  messages: defineTable({
    conversationId: v.id("conversations"),
    author: v.string(),
    body: v.string(),
    edited: v.optional(v.boolean()),
  })
    .index("by_conversation", ["conversationId"])
    .shardKey("conversationId"),
});

Two tables:

  • conversations: just a title. Every table automatically gets a typed _id and a _creationTime, so this needs nothing else.
  • messages: a typed reference to its conversation (v.id("conversations")), an author, a body, and an optional edited flag (v.optional(...), absent until a message is actually edited). See Schema & tables for the full validator catalog.

Two annotations on messages matter for everything that follows:

  • .index("by_conversation", ["conversationId"]) lets a query ask for exactly one conversation's messages instead of scanning the whole table.
  • .shardKey("conversationId") marks conversationId as this table's shard key, the scale-out seam from Scaling. On the single-node deployment this tutorial runs, it's inert metadata: it costs nothing and changes nothing about how the app behaves. It does mean every mutation that writes messages must declare which shard (which conversation) it's writing to, which you'll see in the next step.

Conversations: create and list

Create helipod/conversations.ts:

helipod/conversations.ts
import { v } from "@helipod/values";
import { query, mutation } from "./_generated/server";

export const create = mutation({
  args: { title: v.string() },
  returns: v.id("conversations"),
  handler: (ctx, { title }) => ctx.db.insert("conversations", { title }),
});

export const list = query({
  returns: v.array(
    v.object({ _id: v.id("conversations"), _creationTime: v.number(), title: v.string() }),
  ),
  handler: (ctx) => ctx.db.query("conversations", "by_creation").collect(),
});

Nothing new here beyond the Quickstart: create inserts, list reads every conversation via the built-in by_creation index (every table gets one, in insertion order). The returns validator on both is what lets the client (and, later, an optimistic update) know their exact result type. See Queries.

Messages: insert, replace, delete

Create helipod/messages.ts. This is the file that does the real work: three mutations covering every way a mutation writes, plus two queries:

helipod/messages.ts
import { v } from "@helipod/values";
import { query, mutation } from "./_generated/server";

const messageObject = v.object({
  _id: v.id("messages"),
  _creationTime: v.number(),
  conversationId: v.id("conversations"),
  author: v.string(),
  body: v.string(),
  edited: v.optional(v.boolean()),
});

// Insert. `shardBy: "conversationId"` routes this write to the shard that owns this
// conversation. That's required because `messages` declared `.shardKey("conversationId")` in
// the schema, and because `conversationId` is a required arg of the same type as the shard key
// field, codegen cross-checks the pairing at every `helipod dev`/`helipod codegen` run.
export const send = mutation({
  args: { conversationId: v.id("conversations"), author: v.string(), body: v.string() },
  returns: v.id("messages"),
  shardBy: "conversationId",
  handler: (ctx, args) =>
    ctx.db.insert("messages", {
      conversationId: args.conversationId,
      author: args.author,
      body: args.body,
    }),
});

// Replace. There is no partial-patch method: read the document, then write back every field,
// changed or not (see Mutations: "Writing data").
export const edit = mutation({
  args: { conversationId: v.id("conversations"), id: v.id("messages"), body: v.string() },
  returns: v.null(),
  shardBy: "conversationId",
  handler: async (ctx, { id, body }) => {
    const message = await ctx.db.get(id);
    if (message === null) return null;
    await ctx.db.replace(id, {
      conversationId: message.conversationId,
      author: message.author,
      body,
      edited: true,
    });
    return null;
  },
});

// Delete.
export const remove = mutation({
  args: { conversationId: v.id("conversations"), id: v.id("messages") },
  returns: v.null(),
  shardBy: "conversationId",
  handler: async (ctx, { id }) => {
    await ctx.db.delete(id);
    return null;
  },
});

// A single message, by id, used by the webhook action in the next step.
export const get = query({
  args: { id: v.id("messages") },
  returns: v.union(messageObject, v.null()),
  handler: (ctx, { id }) => ctx.db.get(id),
});

// The whole conversation, live.
export const list = query({
  args: { conversationId: v.id("conversations") },
  returns: v.array(messageObject),
  handler: (ctx, args) =>
    ctx.db.query("messages", "by_conversation").eq("conversationId", args.conversationId).collect(),
});

// One page at a time, newest first.
export const listPaginated = query({
  args: {
    conversationId: v.id("conversations"),
    cursor: v.optional(v.string()),
    pageSize: v.optional(v.number()),
  },
  returns: v.object({
    page: v.array(messageObject),
    nextCursor: v.union(v.string(), v.null()),
    hasMore: v.boolean(),
    scanCapped: v.boolean(),
  }),
  handler: (ctx, args) =>
    ctx.db
      .query("messages", "by_conversation")
      .eq("conversationId", args.conversationId)
      .order("desc")
      .paginate({ cursor: args.cursor ?? null, pageSize: args.pageSize ?? 20 }),
});

A few things worth calling out:

  • Every write to messages names its shard. edit and remove only strictly need a message id to do their job, but because messages is sharded, each mutation also takes conversationId and declares shardBy: "conversationId", the arg that tells the engine which shard's writer to run this transaction on. This is the one recurring cost of declaring a shard key: every mutation touching that table must be able to say, from its own arguments, which shard key value it's writing. (If you never plan to shard, just skip .shardKey/shardBy entirely. An app that never declares either runs exactly as it would without this feature existing at all.)
  • .eq("conversationId", id) narrows the read to one conversation's slice of the by_conversation index, not the whole messages table. That narrowing is also what makes the subscription precise: a write to a different conversation never re-runs this query. See Reactivity.
  • .order("desc") reverses the index scan (newest-first) before .paginate slices it into pages. Useful for a chat thread, where you want the most recent messages first and older ones loaded on demand.
  • .paginate({ cursor, pageSize }) returns { page, nextCursor, hasMore, scanCapped }: a page of documents, an opaque cursor for the next call (null once there isn't one), whether more remain, and whether an internal scan cap was hit before filling the page (rare, and never a correctness problem, just possibly a shorter page than requested).
  • v.union(messageObject, v.null()) is how you type "this document, or nothing": ctx.db.get returns null for a missing id, never throws.

An action: notify a webhook

Actions are the only place fetch is allowed. Queries and mutations must stay deterministic, so anything that talks to the outside world runs outside the transaction, in an action. Create helipod/webhook.ts:

helipod/webhook.ts
import { v } from "@helipod/values";
import { action } from "./_generated/server";

export const pingWebhook = action({
  args: { url: v.string(), messageId: v.id("messages") },
  returns: v.object({ ok: v.boolean(), status: v.number() }),
  handler: async (ctx, { url, messageId }) => {
    const message = await ctx.runQuery("messages:get", { id: messageId });
    const res = await fetch(url, {
      method: "POST",
      headers: { "content-type": "application/json" },
      body: JSON.stringify({ author: message?.author, body: message?.body, sentAt: Date.now() }),
    });
    return { ok: res.ok, status: res.status };
  },
});

ctx.runQuery is how an action reaches into the database. It runs messages:get as its own independent read (an action has no ctx.db of its own). fetch and Date.now(), forbidden in a query or mutation, work normally here. The result isn't reactive (an action has nothing for a client to subscribe to), but its return value still resolves back to whoever called it, same as a mutation's does. See Actions for the full picture, including ctx.runMutation/ctx.runAction for orchestrating writes and other actions.

Build the web UI

Create a web/ directory alongside helipod/:

web/index.html
<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>Chat</title>
  </head>
  <body>
    <div id="root"></div>
    <script type="module" src="/main.js"></script>
  </body>
</html>

Now the client. This is the longest piece, so it's introduced in sections. They all belong in one file, web/main.tsx.

Client setup

web/main.tsx (part 1, setup)
import { StrictMode, useState, type FormEvent } from "react";
import { createRoot } from "react-dom/client";
import { HelipodClient, webSocketTransport, anyApi, type OptimisticLocalStore } from "@helipod/client";
import { HelipodProvider, useQuery, useMutation, useAction, useHelipodClient } from "@helipod/client/react";
// Type-only imports, erased at bundle time, so this never pulls the server-side
// `@helipod/executor` re-exports in `_generated/server.ts` into the browser bundle. `anyApi`
// (below) is the real runtime value; `Api` just shapes it, at the import site, for free.
import type { Api } from "../helipod/_generated/api";
import type { Doc, Id } from "../helipod/_generated/dataModel";

const api = anyApi as Api;

const wsProtocol = location.protocol === "https:" ? "wss" : "ws";
const client = new HelipodClient(webSocketTransport(`${wsProtocol}://${location.host}/api/sync`));

webSocketTransport opens the reactive connection to /api/sync, the same path helipod dev serves the sync WebSocket on.

Conversations sidebar

web/main.tsx (part 2, conversations)
function Sidebar({ selected, onSelect }: { selected: Id<"conversations"> | null; onSelect: (id: Id<"conversations">) => void }) {
  const conversations = useQuery(api.conversations.list, {});
  const create = useMutation(api.conversations.create);
  const [title, setTitle] = useState("");

  async function submit(e: FormEvent) {
    e.preventDefault();
    const trimmed = title.trim();
    if (!trimmed) return;
    const id = await create({ title: trimmed });
    setTitle("");
    onSelect(id);
  }

  return (
    <aside>
      <ul>
        {conversations?.map((c) => (
          <li key={c._id}>
            <button aria-pressed={c._id === selected} onClick={() => onSelect(c._id)}>
              {c.title}
            </button>
          </li>
        ))}
      </ul>
      <form onSubmit={submit}>
        <input value={title} onChange={(e) => setTitle(e.target.value)} placeholder="New conversation…" />
        <button type="submit">Create</button>
      </form>
    </aside>
  );
}

useQuery(api.conversations.list, {}) subscribes. The list re-renders on its own the moment create commits a new row, including in this very component, since create's write and this query's read are the same table. create({ title }) resolves with the new conversation's _id (its returns: v.id("conversations") validator), which is why onSelect(id) can switch straight to the conversation you just made.

The message thread, with an optimistic send

web/main.tsx (part 3, thread)
type Message = Doc<"messages">;
// An optimistically-inserted row carries a placeholder `_id` (a deterministic-but-non-decodable
// string, never a real `Id<"messages">`) until the real commit swaps it in. See
// docs/client/optimistic-updates.mdx for the recipe this type widening follows.
type PendingMessage = Message | (Omit<Message, "_id"> & { _id: string; pending: true });

// Module-scoped (not an inline closure) so `.withOptimisticUpdate` returns the SAME bound
// callable across re-renders. An inline arrow here would churn it every render.
function appendOptimistic(
  store: OptimisticLocalStore,
  args: { conversationId: Id<"conversations">; author: string; body: string },
): void {
  const list = store.getQuery(api.messages.list, { conversationId: args.conversationId });
  if (list === undefined) return; // nothing subscribed locally yet, nothing to patch
  const pending: PendingMessage = {
    _id: store.placeholderId("messages"), // deterministic across replays, never crypto.randomUUID()
    _creationTime: store.now(),           // fixed at entry creation, never Date.now()
    conversationId: args.conversationId,
    author: args.author,
    body: args.body,
    pending: true,
  };
  store.setQuery(api.messages.list, { conversationId: args.conversationId }, [...(list as PendingMessage[]), pending]);
}

function Thread({ conversationId, author }: { conversationId: Id<"conversations">; author: string }) {
  const messages = useQuery(api.messages.list, { conversationId }) as PendingMessage[] | undefined;
  const send = useMutation(api.messages.send).withOptimisticUpdate(appendOptimistic);
  const edit = useMutation(api.messages.edit);
  const remove = useMutation(api.messages.remove);
  const pingWebhook = useAction(api.webhook.pingWebhook);
  const [text, setText] = useState("");

  function submit(e: FormEvent) {
    e.preventDefault();
    const body = text.trim();
    if (!body) return;
    void send({ conversationId, author, body });
    setText("");
  }

  if (messages === undefined) return <p>Loading…</p>;

  return (
    <div>
      <ul>
        {messages.length === 0 && <li>No messages yet. Say hi.</li>}
        {messages.map((m) => (
          <li key={m._id} className={"pending" in m ? "pending" : undefined}>
            <b>{m.author}</b>: {m.body} {m.edited && <em>(edited)</em>}
            {!("pending" in m) && m.author === author && (
              <>
                <button
                  onClick={() => {
                    const next = prompt("Edit message", m.body);
                    if (next && next !== m.body) void edit({ conversationId, id: m._id, body: next });
                  }}
                >
                  Edit
                </button>
                <button onClick={() => void remove({ conversationId, id: m._id })}>Delete</button>
              </>
            )}
            {!("pending" in m) && (
              <button
                onClick={() =>
                  void pingWebhook({ url: "https://httpbin.org/post", messageId: m._id }).then((r) =>
                    console.log("webhook:", r),
                  )
                }
              >
                Ping webhook
              </button>
            )}
          </li>
        ))}
      </ul>
      <form onSubmit={submit}>
        <input value={text} onChange={(e) => setText(e.target.value)} placeholder="Type a message…" autoFocus />
        <button type="submit">Send</button>
      </form>
    </div>
  );
}

Four hooks, four jobs:

  • useQuery(api.messages.list, { conversationId }) subscribes; changing conversationId (switching the sidebar selection) unsubscribes the old query and subscribes the new one, and the component shows undefined (rendered here as "Loading…") until the first result for the new conversation arrives.
  • useMutation(api.messages.send).withOptimisticUpdate(appendOptimistic) renders your own sent message instantly, in the same tick as the click. appendOptimistic runs synchronously against the local query cache before the mutation is even sent. The pending row is dropped the moment a real server push demonstrably includes the commit, never merely "when the promise resolves," so there's no flicker between the guessed row and the real one. placeholderId/now() (never crypto.randomUUID()/Date.now()) exist because this updater can replay more than once for the same pending mutation; using the real globals there would mint a fresh value on every replay and break React's keying. Full detail in Optimistic updates.
  • useMutation(api.messages.edit) / useMutation(api.messages.remove) are plain callbacks. No optimistic update here, so an edit or delete round-trips before you see it, same as send would without .withOptimisticUpdate.
  • useAction(api.webhook.pingWebhook) is a plain callback too, exactly like useMutation without the subscription machinery. There's nothing to subscribe to, since an action's result isn't part of the reactive model.

One-shot pagination

listPaginated isn't wired to useQuery. It's a page-at-a-time read, not something you keep a live subscription open on. Call it directly on the client through useHelipodClient():

web/main.tsx (part 4, load older messages)
function LoadOlder({ conversationId }: { conversationId: Id<"conversations"> }) {
  const client = useHelipodClient();
  const [older, setOlder] = useState<Message[]>([]);
  const [cursor, setCursor] = useState<string | undefined>(undefined);
  const [hasMore, setHasMore] = useState(true);

  async function loadMore() {
    const result = await client.query(api.messages.listPaginated, { conversationId, cursor, pageSize: 20 });
    setOlder((prev) => [...prev, ...result.page]);
    setCursor(result.nextCursor ?? undefined);
    setHasMore(result.hasMore);
  }

  return (
    <div>
      <ul>
        {older.map((m) => (
          <li key={m._id}><b>{m.author}</b>: {m.body}</li>
        ))}
      </ul>
      {hasMore && <button onClick={() => void loadMore()}>Load older messages</button>}
    </div>
  );
}

useHelipodClient() returns the same HelipodClient instance <HelipodProvider> holds: the escape hatch for anything that isn't a live subscription. client.query(ref, args) is a one-shot read: it resolves once with the current result and then unsubscribes. Nothing here stays live, so a new message arriving elsewhere doesn't reshuffle a page you've already loaded. Each call passes the previous call's nextCursor to resume exactly where the last page left off.

Putting it together

web/main.tsx (part 5, the app)
function App() {
  const [conversationId, setConversationId] = useState<Id<"conversations"> | null>(null);
  const [author] = useState(() => `user-${Math.floor(Math.random() * 1000)}`);

  return (
    <div>
      <Sidebar selected={conversationId} onSelect={setConversationId} />
      {conversationId ? (
        <>
          <Thread conversationId={conversationId} author={author} />
          <LoadOlder conversationId={conversationId} />
        </>
      ) : (
        <p>Create or pick a conversation to start chatting.</p>
      )}
    </div>
  );
}

const root = document.getElementById("root");
if (root) {
  createRoot(root).render(
    <StrictMode>
      <HelipodProvider client={client}>
        <App />
      </HelipodProvider>
    </StrictMode>,
  );
}

<HelipodProvider client={client}> is what makes useQuery/useMutation/useAction/ useHelipodClient work anywhere beneath it in the tree: exactly one client, shared by the whole app.

Bundle the client

bun build web/main.tsx --outfile web/main.js

This produces the web/main.js that web/index.html loads. Re-run it whenever you change web/main.tsx, or add --watch to have it rebuild automatically on save:

bun build web/main.tsx --outfile web/main.js --watch

Run it

bunx helipod dev --web web

helipod dev generates helipod/_generated/, boots the reactive engine on local SQLite, serves HTTP + the sync WebSocket + the dashboard. Because of --web web, it also serves your bundled UI from the same origin and port, so there's no separate dev server or proxy to configure. It prints the dashboard URL and the admin key on startup. Open the printed URL in your browser to see the app itself.

Watch it react

  1. Create a conversation, pick it, and send a couple of messages. Each appears instantly: that's the optimistic update rendering before the round trip completes, then settling into the real committed row with no visible change.
  2. Edit one of your own messages, then delete another. Both round-trip (no optimistic update on those two) and update the thread once they commit.
  3. Click Load older messages a few times as you add more. That's listPaginated walking backward through history one page at a time.
  4. Open the same URL in a second browser tab, side by side with the first. Pick the same conversation.
  5. Send a message in one tab. It appears in the other tab too, with no refresh: the same mutation-commits-a-write-set / subscription-re-runs loop from How it works, now driving a UI you wrote yourself. Edit or delete a message in one tab and watch the other tab update as well.

There is no client-side polling, no manual refetch, and no invalidation code anywhere in web/main.tsx. Every re-render above is the engine intersecting a commit's write-set against a live query's read-set and pushing the result to every open subscription that matched.

Hot reload while you work

helipod dev watches helipod/ and reloads automatically: edit a query, mutation, or schema field and the running server picks it up without a restart. Try adding a field to messages or tweaking list's handler and watch the dashboard/your app reflect it on the next read. The web UI is not part of that watch loop. After editing web/main.tsx, re-run bun build (or keep --watch running from step 7) and reload the browser tab to see the change; there's no HMR for the static bundle in this setup.

What you built

A two-table schema with an index and a shard key, five backend functions covering every write a mutation can make (insert, replace, delete) plus both ways to read (a full collect and a paginated walk), one action reaching out to the network, and a React UI wired to all of it with HelipodProvider, useQuery, useMutation (with an optimistic update), useAction, and direct one-shot client.query() calls. That's the same shape every helipod app is built from, whether it's a chat app, a dashboard, or something much bigger.

From here:

  • Client SDK: the full hook and client surface, including connection state and ephemeral broadcasts (presence/typing) that bypass the engine entirely.
  • Optimistic updates: the full reconciliation contract behind .withOptimisticUpdate, purity rules, and reconnect behavior.
  • Offline sync: durable offline mutations that survive a reload or crash, for when a brief reconnect isn't enough.
  • Reactivity: exactly how precise the invalidation you just saw really is, and when a query falls back to coarser table-level matching.
  • Scaling (fleet): what .shardKey/shardBy actually unlock once you outgrow a single node.
  • Components: opt-in pieces like scheduling, workflows, triggers, and auth that compose onto an app exactly like this one.
  • Self-hosting and Deploy & build: taking this from helipod dev to a real deployment.

On this page