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.resolveremote:for server-only follow-up logicresolve.argsfor queries that should be re-fetched after resolveschema.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
| Option | Required | Description |
|---|---|---|
modules | Yes | Lazy ESM registry keyed by canonical module id, e.g. "./convex/tasks.ts": () => import("./tasks"). |
schema | No | Default export from convex/schema.ts. Enables write-time validation. |
name | No | IndexedDB database name. Tabs sharing the same name share data. Defaults to "convex-embedded". |
remote | No | { url: string } — enables remote. Omit for a purely local embedded database. |
auth | No | Auth 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 failremoteOnly(...)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.