Next.js
The browser keeps the data, and one route handler of the same Next.js app is the server. It uses the schema of the Quick start.
The route
One dynamic segment catches the four endpoints (push, pull, bootstrap and wait):
import { syncServer } from '@/lib/sync-server';
export const POST = syncServer.fetch;
// A realtime wait is held up to 30 s: let the function run longer than that.
export const maxDuration = 60;The server
On your machine a SQLite file is enough. On Vercel, every instance must reach the same database, so
use Postgres (Neon, Supabase, Vercel's own), and hand work after the response to after:
import { createSyncServer, nodeSqlite, sqliteStore } from 'ferrysync/server';
import { DatabaseSync } from 'node:sqlite';
import { schema } from './schema';
export const syncServer = createSyncServer({
schema,
store: sqliteStore(nodeSqlite(new DatabaseSync('sync.db'))),
auth: async (request) => (await getSession(request))?.userId ?? null,
});Auth
auth gets the route's Request. Read your session from it:
- Better Auth:
(await auth.api.getSession({ headers: request.headers }))?.user.id ?? null. - Auth.js:
(await auth())?.user?.id ?? null, sinceauth()reads the request's cookies. - A bearer token: see Auth.
The client
The client points at the route, with no endpoint at the end:
import { createSync, http, indexedDbStorage } from 'ferrysync';
import { schema } from './schema';
export const sync = createSync({
schema,
storage: indexedDbStorage('my-app'),
backend: http({ url: '/api/sync' }),
});Components that read it are client components ('use client'). On the server, SyncProvider
renders its fallback; in the browser, the local data opens and shows, with no request in between.
Gotchas
- The Node.js runtime, not Edge. The Postgres and SQLite drivers need Node's sockets and files.
Route handlers run on Node.js by default; do not set
runtime = 'edge'on this one. - Keep the server in its own module (
lib/sync-server.ts), imported by the route. Next.js creates it once per instance; in development, editing that module creates it again, which is fine. - Several instances on Vercel each hold their own waits: a change wakes the devices waiting on the instance that applied it at once, and the others at their next look, within 15 seconds. To make it instant everywhere, pass a broadcast.
- Do not put the route behind a middleware that reads the body.
fetchreads it. node:sqliteneeds Node 22.13 or later. On an older Node, or on Vercel, use Postgres.
Your own bindings
The React hooks, the Zustand store, the Jotai atoms and the Legend-State observables are thin layers over two things every client has, so any framework or state library can have its own in a few lines:
Hono
Hono passes web Requests around, so the sync route is one line, on every runtime Hono runs on. It uses the schema and server of the Quick start.