Prisma IDB FaviconPrisma IDB

Sync

Set up bidirectional sync between IndexedDB and a server, step by step

Sync keeps a browser IndexedDB database and a server database in sync, per user. It's three packages:

  • @prisma-next-idb/sync-extension-idb — browser-side. Wraps your IDB ORM client so every mutation atomically writes an outbox event alongside the model write, and gives you a worker that pushes those events to your server and pulls remote changes back.
  • @prisma-next-idb/sync-server — server-side. Given a rootModel (e.g. User), builds an authorization graph from your schema's relations and tells you, per event, what to check before applying it. Never touches a database or an HTTP framework directly — you wire it into your own routes.
  • @prisma-next-idb/sync-server-sql — server-side, optional. If your server contract is SQL (Postgres, SQLite, …), this is the execution half of sync-server's checks — it actually runs the ownership walk and the row writes against a prisma-next SQL ORM client, so you don't hand-write that against your own tables.

Sync is optional. A schema with neither package behaves exactly as described in Client and Migrations.

Prerequisite

Sync builds on top of a plain Prisma Next IDB client — it doesn't replace Quick Start. Every path below assumes a schema.prisma, a prisma.config.ts, and a bootstrapped migration chain already exist on the browser side (even "starting fresh" with sync means starting fresh with sync — do Quick Start first if you haven't touched Prisma Next IDB at all yet).

Where are you starting from?

You have a plain client from Quick Start (or you'll set one up first) but no sync on either side yet. Follow every step below in order: Install, Mark server-only fields, Client setup, Server setup, then Worker.

1. Install

sync-extension-idb goes wherever your browser code lives; sync-server (and sync-server-sql, if your server is SQL) goes wherever your server code lives. In a full-stack framework (SvelteKit, Next.js) that's usually one package and both land in the same package.json.

npm install @prisma-next-idb/sync-extension-idb @prisma-next-idb/sync-server @prisma-next-idb/sync-server-sql

2. Mark server-only fields

One schema.prisma feeds both sides. Anything that should never reach the browser — password hashes, session tokens, internal audit tables — gets @idb.exclude (field) or @@idb.exclude (model):

model User {
  id       String @id
  name     String
  password String  @idb.exclude
  boards   Board[]
}

model Board {
  id     String @id
  name   String
  userId String
  user   User   @relation(fields: [userId], references: [id], onDelete: Cascade)
  todos  Todo[]
}

model Todo {
  id      String  @id
  title   String
  boardId String
  board   Board   @relation(fields: [boardId], references: [id], onDelete: Cascade)
}

Ownership flows from TodoBoardUser. That relation chain is what the server's ownership graph walks later — see Server. Full mechanics of the exclusion, including what happens to a relation that crosses it, are in Client Contracts.

3. Client setup

Project the client contract. Your IDB config needs projection: "client" so excluded fields/models never reach the bundle:

// prisma.config.ts
contract: prismaIdbContract("schema.prisma", { projection: "client" }),

If you already have a working client from Quick Start, this is the only change to that file.

Re-emit and bootstrap (or extend) migrations, same commands as any other schema change:

npx prisma contract emit
npx prisma-next-idb migration plan   # auto-detects baseline vs. incremental
npx prisma-next-idb migration contract-space
npx prisma-next-idb migration preflight

Open a sync-aware, managed client. createManagedAutoSyncIdbClient combines auto-migration, sync-tracking, and a race-safe singleton wrapper (see Client#managed-client) in one call — pass the sync extension's own contract space via extensions so its stores get migrated alongside yours:

import { createManagedAutoSyncIdbClient } from "@prisma-next-idb/sync-extension-idb/client";
import { idbSyncExtension } from "@prisma-next-idb/sync-extension-idb/control";
import { contractSpace } from "./prisma/contract-space.generated";

const managedDb = createManagedAutoSyncIdbClient({
  contractSpace,
  dbName: "my-app",
  extensions: [idbSyncExtension],
});

export const getDb = () => managedDb.get();
export const resetDb = () => managedDb.reset(); // e.g. on logout

Use it exactly like a normal client — every tracked mutation now also writes an outbox event in the same transaction:

const db = await getDb();
await db.orm.board.create({ id: "b1", name: "My Board", userId: "u1" });

Full client API — the outbox event, withoutTracking, version metadata — is in Client.

4. Server setup

Point your server config at the shared schema through the sync helper, not through the IDB family — a SQL/Postgres parser can't read @idb.exclude directly:

// prisma.config.postgres.ts
import { definePrismaConfig } from "@prisma/cli-engine";
import { defineConfig as ormConfig } from "@prisma/orm-postgres/config";
import { writeSqlSchemaWithSync } from "@prisma-next-idb/sync-server/schema";

export default definePrismaConfig({
  orm: ormConfig({
    contract: writeSqlSchemaWithSync("schema.prisma", "schema.postgres.generated.prisma"),
    db: { connection: process.env.DATABASE_URL },
    migrations: { dir: "migrations-postgres" }, // keep this separate from the IDB side's migrations/
  }),
});

writeSqlSchemaWithSync strips the @idb.exclude markers and appends a Changelog model for you — see Client Contracts for why, and Server for what it generates.

Emit and migrate, using the same prisma CLI you already use for this database, pointed at the config above:

npx prisma contract emit --config prisma.config.postgres.ts
npx prisma db init --config prisma.config.postgres.ts       # first time only
npx prisma migration new --config prisma.config.postgres.ts
npx prisma db update --config prisma.config.postgres.ts

Build a SyncServer once, at startup. Import the two generated .json contracts and cast each through its generated .d.ts type — the contract's id-like fields (e.g. NamespaceId) are branded types, so a raw JSON import won't structurally satisfy them. For a SQL contract, also build a sqlSyncAdapter alongside it — sync-server only describes what to authorize, sync-server-sql is what actually runs the checks and writes against your tables:

import { createSyncServer } from "@prisma-next-idb/sync-server";
import { createSqlSyncAdapter, sqlGetKeyField } from "@prisma-next-idb/sync-server-sql";
import type { Contract as ServerContract } from "./prisma/schema.postgres.generated";
import type { Contract as ClientContract } from "./prisma/contract";
import serverContractJson from "./prisma/schema.postgres.generated.json" with { type: "json" };
import clientContractJson from "./prisma/contract.json" with { type: "json" };

const serverContract = serverContractJson as unknown as ServerContract;

export const syncServer = createSyncServer({
  contract: serverContract, // your full server contract (from step above)
  clientContract: clientContractJson as unknown as ClientContract, // the client-projected contract from step 3
  rootModel: "User",
  getKeyField: sqlGetKeyField, // SQL contracts keep their primary key on the table, not model.storage.keyPath
});

export const sqlSyncAdapter = createSqlSyncAdapter({ contract: serverContract });

createSyncServer throws immediately if the schema is broken (a cycle, or a synced model with no ownership chain back to rootModel) — see Server for more on getKeyField and what a non-SQL contract needs instead.

Add a push endpoint. Resolve the caller's identity server-side, authorize each event, then write it:

// POST /api/sync/push
import { pushRequestBodySchema } from "@prisma-next-idb/sync-extension-idb/schemas";
import { syncServer, sqlSyncAdapter } from "./sync"; // built above
import { getAuthenticatedUserId } from "./auth"; // your own session lookup
import { db } from "./db"; // your own database client

export async function POST(req: Request) {
  const scopeKey = await getAuthenticatedUserId(req);
  // `req.json()` is untrusted network input — `pushRequestBodySchema` (zod)
  // validates it and narrows `operation` to the union sync-server expects,
  // so no manual cast is needed below. See Server for the full picture.
  const parsed = pushRequestBodySchema.safeParse(await req.json());
  if (!parsed.success) return new Response("Malformed request body", { status: 400 });
  const { events } = parsed.data;

  const pushEvents = events.map((e) => ({
    id: e.id,
    model: e.entityType,
    operation: e.operation,
    payload: sqlSyncAdapter.toSyncPushPayload(e.operation, e.payload, sqlSyncAdapter.getKeyField(e.entityType)),
  }));
  const checks = syncServer.validatePush(pushEvents, { scopeKey });

  const results = [];
  for (const { eventId, model, check } of checks) {
    // ... apply each authorized event — full loop (including why it's
    // sequential, not Promise.all) is in Server
    results.push(
      await sqlSyncAdapter.applyPushEvent(
        db,
        events.find((e) => e.id === eventId)!,
        model,
        check,
        scopeKey
      )
    );
  }
  return Response.json(results);
}

Add a pull endpoint. Pre-filter your own changelog storage by scopeKey, then re-check ownership live via buildPullQueries:

// GET /api/sync/pull?since=<changelogId>
import { syncServer, sqlSyncAdapter } from "./sync"; // built above
import { getAuthenticatedUserId } from "./auth"; // your own session lookup
import { db } from "./db"; // your own database client — built from the SQL contract, see Server#gotchas

export async function GET(req: Request) {
  const scopeKey = await getAuthenticatedUserId(req);
  const since = new URL(req.url).searchParams.get("since");
  const sinceId = since !== null ? Number(since) : null;
  if (sinceId !== null && !Number.isInteger(sinceId)) {
    return new Response("since must be an integer", { status: 400 });
  }

  // Your own query, scoped + cursored on your changelog storage — sync-server
  // has no opinion on it, but cap it with `.take()`: without a limit, a
  // client that's been offline a long time pulls its entire history at once.
  const ordered = db.orm.public.Changelog.where({ scopeKey })
    .select("id", "model", "keyPath", "operation")
    .orderBy((c) => c.id.asc());
  const rows = await (sinceId !== null ? ordered.cursor({ id: sinceId }) : ordered).take(50).all();

  const checks = syncServer.buildPullQueries(
    rows.map((r) => ({ changelogId: String(r.id), model: r.model, key: r.keyPath })),
    { scopeKey }
  );

  // One LogWithRecord per row (@prisma-next-idb/sync-extension-idb/client)
  // — the shape pullHandler's response is parsed as, client-side.
  const logs = await Promise.all(
    checks.map(async ({ changelogId, model, check }) => {
      const row = rows.find((r) => String(r.id) === changelogId)!;
      const operation = row.operation as "create" | "update" | "delete";
      const record = await sqlSyncAdapter.resolvePullRecord(db, model, check, row.keyPath, operation);
      return { changelogId, model, operation, keyPath: row.keyPath, record };
    })
  );
  return Response.json(logs);
}

The OwnershipCheck shapes, the two-step pull design's rationale, and a Gotchas section (contract mix-ups, model name casing, unbounded pulls) are in Server.

5. Wire up the worker

Back on the client, point a SyncWorker at the two endpoints you just built and start it:

const worker = db.createSyncWorker({
  pushHandler: async (events, signal) =>
    fetch("/api/sync/push", { method: "POST", body: JSON.stringify({ events }), signal }).then((r) => r.json()),
  pullHandler: async (fromChangelogId, signal) =>
    fetch(`/api/sync/pull?since=${fromChangelogId ?? ""}`, { signal }).then((r) => r.json()),
});
worker.start();

Worker lifecycle (stop(), forceSync(), status events, backoff tuning) is in Client.

Next

On this page