Sync
Keep IndexedDB and a server database in sync, per user, step by step
Sync keeps each user's IndexedDB data and your server's database in step. Writes made in the browser are pushed to the server; changes on the server are pulled back. It works offline: writes wait in the browser until the connection returns.
In this tutorial you add sync to an app that already uses Prisma 8 IDB, with a Postgres server. It uses three packages:
| Package | Runs in | Does |
|---|---|---|
@prisma-idb/sync-extension-idb | The browser | Records tracked writes in an outbox, in the same transaction, and runs a worker that pushes and pulls. |
@prisma-idb/sync-server | Your server | Works out, from your schema's relations, whether a record belongs to the signed-in user. |
@prisma-idb/sync-server-sql | Your server | Runs those checks and the writes against a Prisma 8 SQL client. |
sync-server never touches your database or your HTTP framework. You write two endpoints, push and pull, and call it from them.
Before you start
You need a working Prisma 8 IDB app: a schema.prisma, a prisma.config.ts and at least one migration. If you don't
have one, do the Quick Start first. If you already have a Postgres server with its
own Prisma config, you'll change how that config reads the schema in step 4, but not your data.
1. Install the packages
npm install @prisma-idb/sync-extension-idb @prisma-idb/sync-server @prisma-idb/sync-server-sqlIn a full-stack framework such as SvelteKit or Next.js, browser and server code share a package.json. Otherwise, install sync-extension-idb in the browser app and the other two on the server.
2. Mark what stays on the server
One schema.prisma describes both databases. Mark fields and models the browser must never see with @idb.exclude and @@idb.exclude:
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)
}Sync decides who owns a record by following its relations up to a root model, here User: a Todo belongs to its Board, which belongs to a User. Every model the browser syncs needs such a path. Client Contracts explains what exclusion does to relations.
3. Set up the browser
Tell prisma.config.ts to leave out the excluded fields and models:
contract: prismaIdbContract("src/prisma/schema.prisma", { projection: "client" }),Emit the contract and plan a migration, as for any schema change:
npx prisma@latest contract emit
npx prisma-idb migration plan --name add-sync
npx prisma-idb migration contract-space
npx prisma-idb migration preflightOpen the database with a sync client. Pass idbSyncExtension so the outbox stores are created alongside yours:
// src/db.ts
import { createManagedAutoSyncIdbClient } from "@prisma-idb/sync-extension-idb/client";
import { idbSyncExtension } from "@prisma-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(); // call on sign-outCopy the signed-in user into IndexedDB without tracking it; that row already exists on the server. Use the same id as the authenticated session. Other tracked writes record outbox events in the same transaction:
const db = await getDb();
const user = { id: "u1", name: "Alice" }; // replace with your signed-in user
await db.withoutTracking((orm) => orm.user.upsert({ where: { id: user.id }, create: user, update: user }));
await db.orm.board.create({ id: "b1", name: "My Board", userId: user.id });4. Set up the server
Create prisma.config.postgres.ts. It reads the same schema, removes the @idb.exclude attributes that Postgres can't parse, and adds a Changelog model that records every synced change:
import { definePrismaConfig } from "@prisma/cli-engine";
import { defineConfig } from "@prisma-idb/sync-server/postgres";
export default definePrismaConfig({
orm: defineConfig({
schema: "src/prisma/schema.prisma",
// Keep this separate from the browser's contract.json, which sits next to the schema.
output: "src/prisma/schema.postgres.generated.json",
db: { connection: process.env.DATABASE_URL },
migrations: { dir: "migrations-postgres" }, // separate from the browser's migrations/
}),
});If your schema already declares a Changelog model or ChangeOperation enum, delete them; the config adds its own.
Create the Postgres tables:
npx prisma@latest contract emit --config prisma.config.postgres.ts
npx prisma@latest db init --config prisma.config.postgres.tsAfter later schema changes, run contract emit and db update with the same --config.
Create the sync server once, at startup. Import both generated contracts and cast them to their generated types:
// src/server/sync.ts
import { createSyncServer } from "@prisma-idb/sync-server";
import { createSqlSyncAdapter, sqlGetKeyField } from "@prisma-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,
clientContract: clientContractJson as unknown as ClientContract,
rootModel: "User",
getKeyField: sqlGetKeyField,
});
export const sqlSyncAdapter = createSqlSyncAdapter({ contract: serverContract, syncServer });createSyncServer throws at startup if a synced model has no relation path to User, or the relations form a cycle.
Add the push endpoint. It checks each event against the signed-in user and applies it:
// POST /api/sync/push
import { pushRequestBodySchema } from "@prisma-idb/sync-extension-idb/schemas";
import { sqlSyncAdapter } from "./sync";
import { getAuthenticatedUserId } from "./auth"; // your session lookup
import { db } from "./db"; // your Prisma 8 Postgres client
export async function POST(req: Request) {
const scopeKey = await getAuthenticatedUserId(req); // never trust an id from the body
if (!scopeKey) return Response.json({ error: "Unauthorized" }, { status: 401 });
let body: unknown;
try {
body = await req.json();
} catch {
return Response.json({ error: "Invalid JSON" }, { status: 400 });
}
const parsed = pushRequestBodySchema.safeParse(body);
if (!parsed.success) {
return Response.json({ error: "Malformed request body", details: parsed.error.issues }, { status: 400 });
}
const outcome = await sqlSyncAdapter.applyPush(db, {
events: parsed.data.events,
scopeKey,
clientStorageHash: req.headers.get("x-storage-hash"),
});
if (!outcome.ok) {
switch (outcome.reason) {
case "batch-too-large":
return Response.json({ error: "Too many events" }, { status: 400 });
case "duplicate-event-id":
return Response.json({ error: "Duplicate event id" }, { status: 400 });
case "contract-mismatch":
return Response.json({ error: "Client contract is out of date" }, { status: 409 });
}
throw new Error(`Unhandled push outcome: ${outcome satisfies never}`);
}
return Response.json(outcome.results);
}Both endpoints read the client's storage hash from a request header and refuse a client whose contract differs from the server's. See Contract check.
applyPush decodes wire values and validates each event. It applies events in order, checking ownership and writing the record and its Changelog row in one transaction. It stops at the first retryable failure so dependent events remain pending until their parent succeeds.
Add the pull endpoint. It reads the user's changelog after the client's last position, then re-checks each record's ownership against the current data:
// GET /api/sync/pull?since=<changelogId>
import { sqlSyncAdapter } from "./sync";
import { getAuthenticatedUserId } from "./auth";
import { db } from "./db";
export async function GET(req: Request) {
const scopeKey = await getAuthenticatedUserId(req);
if (!scopeKey) return Response.json({ error: "Unauthorized" }, { status: 401 });
const outcome = await sqlSyncAdapter.pull(db, {
scopeKey,
lastChangelogId: new URL(req.url).searchParams.get("since") || null,
limit: 50,
clientStorageHash: req.headers.get("x-storage-hash"),
});
if (!outcome.ok) {
switch (outcome.reason) {
case "invalid-cursor":
return Response.json({ error: "since must be a changelog id" }, { status: 400 });
case "contract-mismatch":
return Response.json({ error: "Client contract is out of date" }, { status: 409 });
}
throw new Error(`Unhandled pull outcome: ${outcome satisfies never}`);
}
return Response.json(outcome.logs);
}pull returns up to 50 changes after the cursor, in changelog-id order. A record of null tells the client to delete its copy: the record was deleted, or no longer belongs to this user. If key decoding throws KEYPATH_VALIDATION_FAILURE, the response includes a validationError marker; the client skips that entry without deleting a local record. A root-model ownership rejection can return record: null before decoding. See Pull validation for details.
5. Start the worker
Back in the browser, create a worker that calls your two endpoints, and start it once the user is signed in:
import { ContractMismatchError, type SyncRequestContext } from "@prisma-idb/sync-extension-idb/client";
const db = await getDb();
const send = async (url: string, init: RequestInit, { storageHash }: SyncRequestContext) => {
const headers = { ...init.headers, "x-storage-hash": storageHash };
const response = await fetch(url, { ...init, headers });
if (response.status === 409) throw new ContractMismatchError();
return response.json();
};
const worker = db.createSyncWorker({
pushHandler: (events, signal, context) =>
send("/api/sync/push", { method: "POST", body: JSON.stringify({ events }), signal }, context),
pullHandler: (fromChangelogId, signal, context) =>
send(`/api/sync/pull?since=${fromChangelogId ?? ""}`, { signal }, context),
});
worker.start();The worker pushes and then pulls every five seconds, and backs off after failures. Call worker.stop() and resetDb() on sign-out.
Check the result
Create a board in the browser. Its outbox event reaches the push endpoint, and a Changelog row appears in Postgres. Open the app in a second browser session as the same user; the next pull loads that board.
For a complete session lookup and database setup, see the kanban example. The server reference describes batch limits, validation failures and cursor handling.