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 arootModel(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 ofsync-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-sql2. 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 Todo → Board → User. 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 preflightOpen 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 logoutUse 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.tsBuild 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.