Ferry

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:

  1. 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.
  2. Understand it first. How Ferry works follows one change from a tap to another device, with demos you can break.
  3. 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 zod

A first app

Describe the data once. The app and the server import the same schema.

schema.tsShared
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.

sync.tsClient
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.

Client
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.

Next steps

On this page