Ferry

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 zod
What you have after step 1

schema.ts · imported by both sidesstep 2

Device

Screenstep 4

Local databasestep 3

Outboxstep 6

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

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}`,
    },
  },
);

export type Project = typeof schema.$infer.projects;
What you have after step 2

schema.ts · imported by both sidesNew

Device

Screenstep 4

Local databasestep 3

Outboxstep 6

step 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:

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

Client
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 });
What you have after step 3

schema.ts · imported by both sides

Device

Screenstep 4

Local databaseNew

Empty. Add a task.

Outboxstep 6

step 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. SyncProvider shows its fallback once, while the local data opens. It is not a fetching hook like TanStack Query's; using that too? Import Ferry's as useLiveQuery: see React.
  • Without a framework, a query's get() and subscribe() 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.
tasks.tsxClient
'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.

What you have after step 4

schema.ts · imported by both sides

Device

ScreenNew

No tasks yet.

Local database

Empty. Add a task.

Outboxstep 6

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

sync-server.tsServer
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:

app/api/sync/[endpoint]/route.tsServer
import { syncServer } from '../../../../sync-server';

export const POST = syncServer.fetch;
What you have after step 5

schema.ts · imported by both sides

Device

Screen

No tasks yet.

Local database

Empty. Add a task.

Outboxstep 6

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

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

What you have after step 6

schema.ts · imported by both sides

Device

Screen

No tasks yet.

Local database

Empty. Add a task.

Outbox · 0New

Empty: everything reached the server.

sync

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:

  1. 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.
  2. 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.
  3. Nothing is lost. Reload the app, or close and reopen it, while it is still offline. The changes are there, waiting in the outbox.
  4. It catches up. Bring the network back. Within a moment the other window shows every change you made offline.
  5. 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:

schema.ts
sync.ts
tasks.tsx
sync-server.ts
route.ts
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}`,
    },
  },
);

export type Project = typeof schema.$infer.projects;

If something goes wrong

  • node:sqlite is not found. It needs Node 22.13 or later. Upgrade, or take the Postgres tab.
  • Nothing reaches the server. The client's url is 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. localhost is the device itself there. The Android emulator reaches your computer at 10.0.2.2; a phone, at your computer's address on the network. Put that address in the client's url.
  • Realtime cuts out every 10 seconds on Bun. Bun.serve closes a quiet request after 10 seconds; a realtime wait is held up to 30. Pass idleTimeout: 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

On this page