MainSDK
Search⌘K
How-to

Use offline-first replication in your app

Keep a local copy of a person's rows on their device, work offline, and merge changes back to the server without losing edits.

Building for 1.1

Status Status is where the work is: Planned (in a milestone, not started), Building (tickets in progress), Preview (usable, may change), then the stability it ships at.

Stability The promise an interface ships with.

  • Stable Follows semantic versioning; breaking changes need a new major, 12 months of overlap and a codemod.
  • Beta Usable in production; may change before it becomes stable.
  • Experimental May change or go away; needs an explicit opt-in in main.yaml.
Stability levels
Updated 2026-10-28

You will let people edit their notes with no network: a replica on the device, a server that merges pushes, and a check that two devices keep both edits. It takes about 20 minutes.

You need

  • A MainSDK app with a main.yaml (see Thirty-minute path).
  • Users that have an id and a tenant. The server checks access for each row.

1. Declare the table

main.yaml
sync:
  tables:
    notes: { version: 1, conflicts: last-writer-wins }

Bump version when a table's fields change in a way old apps cannot read. A device on the old version is refused until it updates.

2. Start the server half

ts
import { SyncServer } from "mainsdk/experimental/sync-server";

declare const config: Record<string, unknown>;        // the sync section of main.yaml
declare const access: ConstructorParameters<typeof SyncServer>[0]["access"];
declare const currentUser: () => { id: string; tenant: string };

const server = new SyncServer({ config, access });
export const handle = server.handler(() => currentUser());

Serve handle for POST /sync/push and POST /sync/pull. access is the function collab takes; policyCollabAccess(engine) fits.

3. Open a replica on the device

ts
import { httpTransport, sync } from "mainsdk/experimental/sync";

declare const who: { id: string; tenant: string };

const replica = await sync.open({
  tables: { notes: { version: 1 } },
  for: who,
  transport: httpTransport("https://app.example.com/sync", { fetch }),
});

Pass storage with your IndexedDB or Expo SQLite store so the replica survives a restart.

4. Read and write with no network

ts
import type { Replica } from "mainsdk/experimental/sync";

declare const replica: Replica<"notes">;

const note = await replica.notes.insert({ title: "Buy milk" });
await replica.notes.update(note.id, { done: true });
const open = replica.notes.list();

Every call returns at once. Nothing has reached the server yet.

5. Flush when the network is back

ts
import type { Replica } from "mainsdk/experimental/sync";

declare const replica: Replica<"notes">;

const { pushed, pulled, offline } = await replica.flush();
const clashes = replica.conflicts();

Call flush when the app starts, when the network returns and after a burst of writes. A lost network returns offline: true and keeps the queue.

Check it worked

  • Write on two devices with the network off, flush both, and both show every edit; two edits to one field keep the later one and conflicts() lists the other.
  • pnpm mainsdk check passes, with no MAIN-SYNC-0007 finding for sync.tables.
  • A person you erase disappears from every device on its next flush.

If it fails

  • MAIN-SYNC-0002: the device's table version differs from main.yaml. Update the app.
  • MAIN-SYNC-0001: access refused a row. The row is listed in conflicts() and the rest of the push went through.
  • MAIN-SYNC-0003: the tenant is over its quota. Delete rows or raise sync.quota.

Prompt for your coding agent

Add offline editing for notes to my app with MainSDK's sync interface. Declare the table in main.yaml, serve server.handler, open a replica on the device with a persistent store, call flush when the network returns, and show conflicts() to the person.

Was this page helpful?

↑↓ moveEnter openEsc close

Not decided yet