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.
What it is, in plain words
Most apps ask a server for everything. Every screen waits for a request, and with no network the app is stuck. Ferry turns that around:
- The data lives on the device. Reading and writing a task is instant, offline or not. There is no spinner between a tap and the result.
- Changes travel in the background. Each change waits in a queue on the device and is sent when there is a network. Other devices receive it a moment later.
- The server is yours. Ferry runs as a library inside your own backend, over your own database and your own login. It is not a service to sign up for.
- You describe the data once, with Zod. The app, the server and the types all come from that one schema.
It is plain TypeScript, for any JavaScript app: React or React Native, Vue, Svelte, vanilla JS,
Electron, a script on a server. React gets hooks out of the box; anything else reads the client
through two methods, get and subscribe.
Underneath is a Linear-style engine: transactions, a server-ordered log, sync groups called spaces, and rebase of pending writes. You do not need to know any of that to start: How Ferry works explains it with pictures when you want it.
Note
Space and group mean the same thing. "Space" is the word of the app-facing API
(spaces in defineSchema); "group" is what the engine, the wire protocol and the internals docs
call it.
Where to start
Three ways in, depending on what you want right now:
- Build something. Quick start grows a task list step by step: it works on the device first, and a server joins later without changing the app.
- Understand it first. How Ferry works follows one change from a tap to another device, with demos you can break.
- Fit it to your stack. Examples show Supabase, Firebase, Next.js, Hono, your own database and your own auth.
Install
One package, plus Zod to describe the data:
npm install ferrysync zodA first app
Describe the data once. The app and the server import the same 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}`,
},
},
);Create the client. Writes apply at once, on the device, and sync in the background.
import { createSync, indexedDbStorage } from 'ferrysync';
import { schema } from './schema';
export const sync = createSync({ schema, storage: indexedDbStorage('my-app') });
await sync.ready;
const task = sync.tasks.create({ projectId: 'p1', title: 'Book flights' });
sync.tasks.update(task.id, { done: true });Show it on screen, with any UI. A query hands over its result and every change to it; React's hook re-renders a component only when what it read changes.
const OpenTasks = ({ projectId }: { projectId: string }) => {
const tasks = useQuery(sync.tasks.where({ projectId, done: false }));
return (
<ul>
{tasks.map((task) => (
<li key={task.id}>{task.title}</li>
))}
</ul>
);
};
export const App = () => (
<SyncProvider sync={sync}>
<OpenTasks projectId="p1" />
</SyncProvider>
);Vue, Svelte, Solid or anything else that can subscribe: see your own bindings.