Quick start
This guide builds one small app, a task list, and grows it one step at a time. Each step adds one idea. The app works from the third step on, on the device alone; the server comes in later, and the app does not change for it.
Ferry is plain TypeScript, so the app can be React, React Native, Vue, Svelte or no framework at all. Where the code differs between platforms, UIs or backend frameworks, it has a tab for each.
Install
npm install ferrysync zodschema.ts · imported by both sidesstep 2
Device
Screenstep 4
Local databasestep 3
Outboxstep 6
Your backend
Route · createSyncServerstep 5
Server databasestep 5
What you didAdded two packages: ferrysync, and zod to describe the data.
What works nowNothing runs yet. Every piece below is still to come.
Describe the data
The schema is one file, imported by the app and, later, by the server. Here a task belongs to a project, and a project is a space (the engine and the wire call it a group: same thing): the people in it see its tasks, and nobody else does. See Schema.
import { defineSchema } from 'ferrysync';
import { z } from 'zod';
export const schema = defineSchema(
{
projects: z.object({ id: z.string(), name: z.string() }),
tasks: z.object({
id: z.string(),
projectId: z.string(),
title: z.string(),
done: z.boolean().default(false),
}),
},
{
spaces: {
projects: (project) => `project:${project.id}`,
tasks: (task) => `project:${task.projectId}`,
},
},
);
export type Project = typeof schema.$infer.projects;schema.ts · imported by both sidesNew
Device
Screenstep 4
Local databasestep 3
Outboxstep 6
Your backend
Route · createSyncServerstep 5
Server databasestep 5
What you didDescribed the data once: projects, tasks, and the spaces that say who shares them.
What works nowTypes for every table. The app and, later, the server import this one file.
You should see
your editor knowing every field. Hover Project and it shows id and name;
a typo like task.titel is a type error.
Create the client
The client keeps the data in a local database on the device. Pick the storage of your platform:
import { createSync, indexedDbStorage } from 'ferrysync';
import { schema } from './schema';
export const sync = createSync({ schema, storage: indexedDbStorage('tasks') });That is already a working app. Writes are plain function calls: they apply at once, return the row, and are saved on the device. There is no server yet, so there is nothing to wait for.
await sync.ready;
const trip = sync.projects.create({ name: 'Japan' });
const flights = sync.tasks.create({ projectId: trip.id, title: 'Book flights' });
sync.tasks.update(flights.id, { done: true });schema.ts · imported by both sides
Device
Screenstep 4
Local databaseNew
Empty. Add a task.
Outboxstep 6
Your backend
Route · createSyncServerstep 5
Server databasestep 5
What you didCreated the client, over a database on the device.
What works nowWrites and reads, instantly, offline: there is no server to wait for. Add a task: it is confirmed on the device itself.
You should see
the snippet runs without errors, and sync.tasks.all().get() lists the task
you created, with done set to true.
Show it on screen
Every query hands over its current result and each change to it, from the local database: nothing goes over the network, so there is no loading state per query. Pick your UI:
- React and React Native read through
useQuery, which re-renders only when what it read changes.SyncProvidershows its fallback once, while the local data opens. It is not a fetching hook like TanStack Query's; using that too? Import Ferry's asuseLiveQuery: see React. - Without a framework, a query's
get()andsubscribe()are all there is to it. - Vue, Svelte, Solid and the rest wrap the same two methods in a few lines: see your own bindings.
'use client';
import { SyncProvider, useQuery } from 'ferrysync/react';
import { type Project } from './schema';
import { sync } from './sync';
const ProjectTasks = ({ project }: { project: Project }) => {
const tasks = useQuery(sync.tasks.where({ projectId: project.id }));
const add = () => sync.tasks.create({ projectId: project.id, title: `Task ${tasks.length + 1}` });
return (
<section>
<h2>{project.name}</h2>
{tasks.map((task) => (
<label key={task.id}>
<input
type="checkbox"
checked={task.done}
onChange={() => sync.tasks.update(task.id, { done: !task.done })}
/>
{task.title}
</label>
))}
<button onClick={add}>Add a task</button>
</section>
);
};
const Projects = () => {
const projects = useQuery(sync.projects.all());
const add = () => sync.projects.create({ name: `Project ${projects.length + 1}` });
return (
<main>
<button onClick={add}>New project</button>
{projects.map((project) => (
<ProjectTasks key={project.id} project={project} />
))}
</main>
);
};
export const TasksApp = () => (
<SyncProvider sync={sync} fallback={<p>Opening…</p>}>
<Projects />
</SyncProvider>
);Render <TasksApp /> anywhere in your React app, or load main.ts in a page with a #app element.
Close it and open it again: the projects and tasks are still there. Without a server, the device is
the only copy, and that is a fine way to start.
schema.ts · imported by both sides
Device
ScreenNew
No tasks yet.
Local database
Empty. Add a task.
Outboxstep 6
Your backend
Route · createSyncServerstep 5
Server databasestep 5
What you didPut the data on screen, with a live query.
What works nowThe screen follows the local database: add a task and it shows, with no loading state and no fetch.
You should see
a New project button. Press it, then Add a task: each appears at once. Reload the page and they are still there.
Add a server
To share the data between devices, add the server. It is a library inside your backend, over your database. Everyone is the same user for now; Accounts is where yours come in.
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: () => 'demo-user',
// Whoever creates a project becomes the first member of its space.
claimSpace: ({ table, space, row }) => table === 'projects' && space === `project:${row.id}`,
});Then mount it on one route of the framework you already have. It answers every Ferry request
(push, pull, bootstrap and wait) by the last segment of the path:
import { syncServer } from '../../../../sync-server';
export const POST = syncServer.fetch;schema.ts · imported by both sides
Device
Screen
No tasks yet.
Local database
Empty. Add a task.
Outboxstep 6
Your backend
Route · createSyncServerNew
POST /api/sync/:endpoint
Server databaseNew
Empty: no device is connected.
What you didAdded the server to your backend, over its own database, on one route.
What works nowThe server runs, but no device talks to it yet: the app works exactly as before. Add a task to see.
You should see
the server start and listen on your port. The app does not use it yet, so it behaves exactly as before.
Connect the client
One option links the client to that route: backend, with the URL where you mounted it, without the
endpoint at the end.
import { createSync, http, indexedDbStorage } from 'ferrysync';
import { schema } from './schema';
export const sync = createSync({
schema,
storage: indexedDbStorage('tasks'),
backend: http({ url: '/api/sync' }),
});Nothing else changes. Writes still apply at once on the device; now they also wait in an outbox and reach the server in the background, and changes from other devices arrive as they happen.
schema.ts · imported by both sides
Device
Screen
No tasks yet.
Local database
Empty. Add a task.
Outbox · 0New
Empty: everything reached the server.
Your backend
Route · createSyncServer
POST /api/sync/:endpoint
Server database
Empty. Tasks arrive here.
What you didPointed the client at the route, with one option: backend.
What works nowWrites still apply at once, then wait in the outbox and reach the server. Go offline and add tasks: they queue, and drain when you are back.
You should see
requests to /api/sync/… in the browser's Network tab as you edit, and the
changes arriving in a second window.
See it work
Each check proves one part of what you built:
- It syncs. Open the app in a second window or on a second device. The projects and tasks are there, and a task you tick on one is ticked on the other a moment later.
- It works offline. Cut one off: in the browser, developer tools, then Network, then Offline; on a phone, airplane mode. Add and tick tasks: they apply at once, as before.
- Nothing is lost. Reload the app, or close and reopen it, while it is still offline. The changes are there, waiting in the outbox.
- It catches up. Bring the network back. Within a moment the other window shows every change you made offline.
- The server keeps it. Restart the server. Everything is in its database, and both apps carry on where they were.
If all five pass, you have the whole of Ferry running: a local database, an outbox, a server that orders the changes, and realtime between devices. The Overview shows each part step by step, with a playground to break it in.
The whole app
Every file of the web version with React, SQLite and Next.js, in one place. The project looks like this:
import { defineSchema } from 'ferrysync';
import { z } from 'zod';
export const schema = defineSchema(
{
projects: z.object({ id: z.string(), name: z.string() }),
tasks: z.object({
id: z.string(),
projectId: z.string(),
title: z.string(),
done: z.boolean().default(false),
}),
},
{
spaces: {
projects: (project) => `project:${project.id}`,
tasks: (task) => `project:${task.projectId}`,
},
},
);
export type Project = typeof schema.$infer.projects;If something goes wrong
node:sqliteis not found. It needs Node 22.13 or later. Upgrade, or take the Postgres tab.- Nothing reaches the server. The client's
urlis where you mounted the route, with no endpoint at the end:/api/sync, not/api/sync/push. - A phone or an emulator cannot reach the server.
localhostis the device itself there. The Android emulator reaches your computer at10.0.2.2; a phone, at your computer's address on the network. Put that address in the client'surl. - Realtime cuts out every 10 seconds on Bun.
Bun.servecloses a quiet request after 10 seconds; a realtime wait is held up to 30. PassidleTimeout: 40, as on Bun. - A browser app on another origin gets CORS errors. In development, forward the sync path from
the dev server (Vite's
server.proxy), so app and server share an origin; in production, answer preflight requests, as in Hono's CORS. - You deleted the server's database. Clear the app's local data too (in the browser: developer tools, Application, Clear site data). A device that synced with the old database waits for history the new one does not have.
Next steps
Introduction
Ferry is offline-first sync for TypeScript: your app reads and writes data on the device, and Ferry keeps it in step with your backend in the background.
Overview
Ferry is a sync engine you add to an app you already have. The app keeps its data on the device and works from there; Ferry keeps that data in step with your backend, in the background.