Skip to main content

SupabaseSupabase Replication PluginReal-Time, Offline-First Sync

The Supabase Replication Plugin for RxDB delivers seamless, two-way synchronization between your RxDB collections and a Supabase (Postgres) table. It uses PostgREST for pull/push and Supabase Realtime (logical replication) to stream live updates, so your data stays consistent across devices with first-class local-first, offline-ready support.

Under the hood, the plugin is powered by the RxDB Sync Engine. It handles checkpointed incremental pulls, robust retry logic, and conflict detection/resolution for you. You focus on features, and RxDB takes care of sync.

Supabase in 100 Seconds
2:36
Supabase in 100 Seconds

Key Features of the RxDB-Supabase Plugin

  • Cloud Only Backend: No self-hosted server required. Client devices directly sync with the Supabase Servers.
  • Two-way replication between Supabase tables and RxDB collections
  • Offline-first with resumable, incremental sync
  • Live updates via Supabase Realtime channels
  • Conflict resolution handled by the RxDB Sync Engine
  • Works in browsers and Node.js with @supabase/supabase-js

Architecture Overview

Client A
Supabase
Client B
Client C

Clients connect directly to Supabase using the official JS client. The plugin:

  • Pulls documents over PostgREST using a checkpoint (modified, id) and deterministic ordering.
  • Pushes inserts/updates using optimistic concurrency guards.
  • Streams new changes using Supabase Realtime so live replication stays up to date.
note

Because Supabase exposes Postgres over HTTP/WebSocket, you can safely replicate from browsers and mobile apps. Protect your data with Row Level Security (RLS) policies; use the anon key on clients and the service role key only on trusted servers.

Setting up RxDB ↔ Supabase Sync

1

Install Dependencies

npm install rxdb @supabase/supabase-js
2

Create a Supabase Project & Table

In your supabase project, create a new table. Ensure that:

  • The primary key must have the type text (Primary keys must always be strings in RxDB)
  • You have an modified field which stores the last modification timestamp of a row (default is _modified)
  • You have a boolean field which stores if a row should is "deleted". You should not hard-delete rows in Supabase, because clients would miss the deletion if they haven't been online at the deletion time. Instead, use a deleted boolean to mark rows as deleted. This way all clients can still pull the deletion, and RxDB will hide the complexity on the client side.
  • Enable the realtime observation of writes to the table.

Here is an example for a "human" table:

create extension if not exists moddatetime schema extensions;
 
create table "public"."humans" (
    "passportId" text primary key,
    "firstName" text not null,
    "lastName" text not null,
    "age" integer,
 
    "_deleted" boolean DEFAULT false NOT NULL,
    "_modified" timestamp with time zone DEFAULT now() NOT NULL
);
 
-- auto-update the _modified timestamp
CREATE TRIGGER update_modified_datetime BEFORE UPDATE ON public.humans FOR EACH ROW
EXECUTE FUNCTION extensions.moddatetime('_modified');
 
-- add a table to the publication so we can subscribe to changes
alter publication supabase_realtime add table "public"."humans";
3

Create an RxDB Database & Collection

Create a normal RxDB database, then add a collection whose schema mirrors your Supabase table. The primary key must match (same column name and type), and fields should be top-level simple types (string/number/boolean). You don’t need to model server internals: the plugin maps the server’s _deleted flag to doc._deleted automatically, and _modified is optional in your schema (the plugin strips it on push and will include it on pull only if you define it). For browsers use a persistent storage like Localstorage or IndexedDB. For tests you can use the in-memory storage.

// client
import { createRxDatabase } from 'rxdb/plugins/core';
import { getRxStorageLocalstorage } from 'rxdb/plugins/storage-localstorage';
 
export const db = await createRxDatabase({
  name: 'mydb',
  storage: getRxStorageLocalstorage()
});
 
await db.addCollections({
  humans: {
    schema: {
      version: 0,
      primaryKey: 'passportId',
      type: 'object',
      properties: {
        passportId: { type: 'string', maxLength: 100 },
        firstName:   { type: 'string' },
        lastName:    { type: 'string' },
        age: { type: 'number' }
      },
      required: ['passportId', 'firstName', 'lastName']
    }
  }
});
4

Create the Supabase Client

Make a single Supabase client and reuse it across your app. In the browser, use the anon key (RLS-protected). On trusted servers you may use the service role key, but never ship that to clients.

//> client
 
import { createClient } from '@supabase/supabase-js';
 
export const supabase = createClient(
  'https://xyzcompany.supabase.co',
  'eyJhbGciOi...'
);
5

Start Replication

Connect your RxDB collection to the Supabase table to start the replication.

//> client
 
import { replicateSupabase } from 'rxdb/plugins/replication-supabase';
 
const replication = replicateSupabase({
  tableName: 'humans',
  client: supabase,
  collection: db.humans,
  replicationIdentifier: 'humans-supabase',
  live: true,
  pull: {
    batchSize: 50,
    // optional: shape incoming docs
    modifier: (doc) => {
      // map nullable age-field
      if (!doc.age) delete doc.age;
      return doc;
    }
    // optional: customize the pull query before fetching
    queryBuilder: ({ query }) => {
      // Add filters, joins, or other PostgREST query modifiers
      // This runs before checkpoint filtering and ordering
      return query.eq("status", "active");
    },
  },
  push: {
    batchSize: 50
  },
  // optional overrides if your column names differ:
  // modifiedField: '_modified',
  // deletedField: '_deleted'
});
 
// (optional) observe errors and wait for the first sync barrier
replication.error$.subscribe(err => console.error('[replication]', err));
await replication.awaitInitialReplication();
Nullable values must be mapped

Supabase returns null for nullable columns, but in RxDB you often model those fields as optional (i.e., they can be undefined/missing). To avoid schema errors, map nullundefined in the pull.modifier (usually by deleting the key).

6

Using Joins

You can use the pull.queryBuilder to use joins and also pull data from related tables. To do that, you have to create a new query object in the pull.queryBuilder with the .select() method and return it.

const replication = replicateSupabase({
    pull: {
        queryBuilder: (/* ignore the passed query instance from here */) => {
            /**
             * Create a totally new query instance
             * and return that.
             */
            return supabase.from('humans').select('*, pets(*), toys(*)');
        }
    }
});
7

Do other things with the replication state

The RxSupabaseReplicationState which is returned from replicateSupabase() allows you to run all functionality of the normal RxReplicationState.

FAQ

Why use Supabase and RxDB as a cloud document database for Node.js and TypeScript?

Supabase and RxDB offer the best of both worlds for Node.js and TypeScript applications. Supabase provides a powerful PostgreSQL backend. You can use PostgreSQL as a document database by storing JSON data. RxDB provides a reactive local document store. You achieve real-time synchronization between the local document store and the Supabase backend. You benefit from strong TypeScript typing on the client and robust SQL querying on the server.

How to connect an anonymous key to a Supabase project?

You connect an anonymous key to a Supabase project by initializing the official @supabase/supabase-js client utilizing your project's SUPABASE_URL and SUPABASE_ANON_KEY. In frontend applications interacting with the RxDB Supabase Replication plugin, you must inject the anon key, while simultaneously configuring strict Row Level Security (RLS) policies within your Supabase PostgreSQL backend to prevent unauthorized data manipulation.

Does Supabase support full offline sync and CRDT capabilities?

Natively, the Supabase JavaScript client does not support advanced offline-first synchronization pipelines or complex Conflict-free Replicated Data Type (CRDT) architectures. To implement full offline sync capable of continuous background disconnected writes, you must attach the RxDB Supabase Replication Plugin. RxDB acts as the offline-first local CRDT-like cache, deferring all local mutations into a unified outbound queue until the Supabase TCP connection is restored.

Should Row Level Security (RLS) be enabled when using Supabase real-time sync?

Yes, Row Level Security (RLS) is strictly mandatory whenever you expose a Supabase database directly to the frontend. Without RLS, the anonymous anon key used by the RxDB client grants full read and write access to your entire PostgreSQL cluster. You must configure RLS policies that enforce auth.uid() = user_id checks to guarantee clients only replicate and mutate their own specific documents.

How does Supabase Realtime architecture work?

Supabase Realtime acts as an Elixir-based WebSocket broadcasting server that taps directly into PostgreSQL's logical replication stream. When a row changes on the database, the Realtime server parses the WAL (Write-Ahead Log) and pushes the event down to subscribed clients. The RxDB Supabase Replication plugin leverages this WebSocket channel strictly for live change detection, triggering rapid localized pulls over PostgREST to guarantee no data is dropped during connection turbulence.

Follow Up