Ferry
Examples

Supabase

Supabase gives you a Postgres database and Auth. Ferry adds offline-first sync on top of them, from a backend you own: the app reads and writes its local database, and your backend syncs it into Supabase's Postgres, for the users Supabase Auth signed in.

PieceHere
Where the server keeps dataSupabase's Postgres, through postgresStore
Who the user isSupabase Auth: its access token, verified
Where the server runsYour backend: a Next.js route here
Where the device keeps dataIndexedDB

It uses the schema of the Quick start.

The server

auth reads the access token the app sends, and asks Supabase whose it is. The store connects to the same Postgres with its connection string; Ferry creates its own tables there, next to yours.

sync-server.tsServer
import { createClient } from '@supabase/supabase-js';
import { createSyncServer, nodePostgres, postgresStore } from 'ferrysync/server';
import { Pool } from 'pg';

import { schema } from './schema';

const supabase = createClient(process.env.SUPABASE_URL ?? '', process.env.SUPABASE_ANON_KEY ?? '');

/** The Supabase user the request's access token belongs to, or null. */
const userOf = async (request: Request) => {
  const token = request.headers.get('authorization')?.replace(/^Bearer /, '');
  if (!token) {
    return null;
  }
  const { data } = await supabase.auth.getUser(token);
  return data.user?.id ?? null;
};

export const syncServer = createSyncServer({
  schema,
  store: postgresStore(nodePostgres(new Pool({ connectionString: process.env.DATABASE_URL }))),
  auth: userOf,
  claimSpace: ({ table, space, row }) => table === 'projects' && space === `project:${row.id}`,
});

Mount it as in Next.js, or on any other backend.

The client

The app starts on the device alone, signed in or not. At sign-in it connects to your route and uploads what the user did before; at sign-out it starts over. See Accounts.

sync.tsClient
import { createClient } from '@supabase/supabase-js';
import { createSync, http, indexedDbStorage, local } from 'ferrysync';

import { schema } from './schema';

export const supabase = createClient(
  process.env.NEXT_PUBLIC_SUPABASE_URL ?? '',
  process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY ?? '',
);

/** Your route, with the current access token on every request, so a refreshed one is used. */
const backend = http({
  url: '/api/sync',
  headers: async () => {
    const { data } = await supabase.auth.getSession();
    return { authorization: `Bearer ${data.session?.access_token ?? ''}` };
  },
});

const { data } = await supabase.auth.getSession();

export const sync = createSync({
  schema,
  storage: indexedDbStorage('tasks'),
  backend: data.session ? backend : local(),
});

export const signIn = async (email: string, password: string) => {
  const { error } = await supabase.auth.signInWithPassword({ email, password });
  if (error) {
    throw error;
  }
  await sync.connect(backend, { upload: true });
};

export const signOut = async () => {
  await supabase.auth.signOut();
  await sync.reset(local());
};

Gotchas

  • The app does not read the synced tables through Supabase's client. It reads its local database; your backend is the only one writing them. Row Level Security does not apply to that connection, so auth, the schema's spaces and the server's validate hook are where access is decided.
  • Connect from the sign-in, not from onAuthStateChange. That event also fires when a session is restored or refreshed, and connect with upload: true must run once per sign-in.

On this page