Quick Start

This guide walks through the current setup model: defining an embedded table, binding the component, writing local-first functions, and creating a browser client.

1. Define Your Schema

In convex/schema.ts, use embeddedTable() instead of defineTable() for tables you want mirrored locally and reconciled with the remote Convex deployment. Fields wrapped with schema.register() become CRDT-aware; plain v.* validators remain last-write-wins.

// convex/schema.ts
import { schema } from "@robelest/convex-embedded/crdt";
import { embeddedTable } from "@robelest/convex-embedded/server";
import { defineSchema } from "convex/server";
import { v } from "convex/values";

export const tasks = embeddedTable("tasks", {
  title: schema.register(v.string()),
  body: schema.prose(),
  votes: schema.counter(),
  remoteAttachmentId: schema.omit(v.string()),
  done: v.boolean(),
});

export default defineSchema({
  tasks,
});

The tasks handle returned by embeddedTable() is both a valid TableDefinition (so defineSchema accepts it) and a builder for mutations and queries.

2. Set Up Server Binding

Create convex/embedded.ts to bind the component reference. This enables CRDT delta recording on the remote Convex backend.

// convex/embedded.ts
import { setup } from "@robelest/convex-embedded/server";
import { components } from "./_generated/api";

setup({ component: components.embedded });

This file is side-effect-only. It binds components.embedded to every table registered with embeddedTable(). Function registration does not depend on this file — mutations and queries are registered at definition time.

3. Write Functions

Write your mutations and queries using the tasks handle from the schema. The API mirrors standard Convex functions:

// convex/tasks.ts
import { v } from "convex/values";
import { tasks } from "./schema";

export const resolve = tasks.resolve;

export const create = tasks.mutation({
  args: { title: v.string(), body: v.string() },
  handler: async (ctx, args) => {
    return await ctx.db.insert("tasks", {
      ...args,
      done: false,
      votes: 0,
    });
  },
  remote: async (_ctx, _args, taskId) => {
    await sendTaskCreatedWebhook(taskId);
  },
});

export const update = tasks.mutation({
  args: { id: v.id("tasks"), title: v.string(), body: v.string() },
  handler: async (ctx, args) => {
    const { id, ...fields } = args;
    await ctx.db.patch(id, fields);
    return id;
  },
});

export const remove = tasks.mutation({
  args: { id: v.id("tasks") },
  handler: async (ctx, args) => {
    await ctx.db.delete(args.id);
    return args.id;
  },
});

export const list = tasks.query({
  args: {},
  returns: v.array(
    v.object({
      _id: v.id("tasks"),
      _creationTime: v.number(),
      title: v.string(),
      body: v.string(),
    }),
  ),
  handler: async (ctx) => {
    return await ctx.db.query("tasks").collect();
  },
  resolve: {
    args: () => ({}),
  },
});

The important embedded-specific pieces here are:

  • export const resolve = tasks.resolve
  • remote: for server-only follow-up logic
  • resolve.args for queries that should be re-fetched after resolve
  • schema.omit(...) for fields that must stay remote-only

4. Create the Client

In your app’s entry point, call createConvexClient(). It returns a standard ConvexClient that works with any Convex framework integration.

First, create a lazy module registry using canonical module ids:

// src/convex-modules.ts
import type { ConvexModuleRegistry } from "@robelest/convex-embedded/browser";

export const modules = {
  "./convex/tasks.ts": () => import("../convex/tasks"),
  "./convex/schema.ts": () => import("../convex/schema"),
  "./convex/_generated/api.ts": () => import("../convex/_generated/api"),
  "./convex/_generated/server.ts": () => import("../convex/_generated/server"),
} satisfies ConvexModuleRegistry;

Key Options

OptionRequiredDescription
modulesYesLazy ESM registry keyed by canonical module id, e.g. "./convex/tasks.ts": () => import("./tasks").
schemaNoDefault export from convex/schema.ts. Enables write-time validation.
nameNoIndexedDB database name. Tabs sharing the same name share data. Defaults to "convex-embedded".
remoteNo{ url: string } — enables remote. Omit for a purely local embedded database.
authNoAuth configuration with fetchToken, getUserIdentity, and related hooks.

Notes on the current architecture

  • browser persistence runs through browser/sqlite/worker.ts
  • auth/session fanout is browser transport, while auth state and replay rules stay in core
  • replay ownership uses processor-scoped leases, so multiple tabs can recover safely after crashes or abandoned sessions

6. Control local vs remote execution explicitly

Use routing helpers when you need strict execution behavior:

import { localOnly, remoteOnly } from "@robelest/convex-embedded/server";
import { action } from "./_generated/server";
import { query } from "./_generated/server";
import { v } from "convex/values";

export const localDrafts = localOnly(
  query({
    args: {},
    handler: async (ctx) => await ctx.db.query("drafts").collect(),
  }),
);

export const sendEmail = remoteOnly(
  action({
    args: { to: v.string() },
    handler: async (_ctx, args) => {
      await sendEmailViaProvider(args.to);
    },
  }),
);
  • localOnly(...) means local or fail
  • remoteOnly(...) means remote or fail

5. Use Queries and Mutations

Once the client is provided to your framework, use queries and mutations exactly as you would with a normal Convex app:

The queries read from the local embedded database (instant, offline-capable). Mutations write locally first and replay to the remote backend in the background.

Next Steps