---
title: "Use offline-first replication in your app"
description: "Keep a local copy of a person's rows on their device, work offline, and merge changes back to the server without losing edits."
section: "guides"
status: "Building for 1.1"
spec: "sync"
canonical: "https://mainsdk.dev/docs/guides/use-sync"
updated: "2026-10-28"
---

# 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.

Status: Building for 1.1 · Canonical: https://mainsdk.dev/docs/guides/use-sync · 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.

> **Note:** `sync` is experimental (spec `sync`) and runs from the repository; it is not published to npm.

## You need

- A MainSDK app with a `main.yaml` (see [Thirty-minute path](https://mainsdk.dev/docs/getting-started/thirty-minutes)).
- Users that have an id and a tenant. The server checks access for each row.

## 1. Declare the table

```yaml title="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.
