Adapters

Supabase

Point Flux at an existing Supabase client and it takes care of turning Postgres change events into live store updates — reconnect handling, single-socket coordination across tabs, and catch-up after time offline all come for free once the adapter is wired in.

Prerequisites
  • An existing Supabase project with a table you want to sync
  • The Supabase JS client already installed (@supabase/supabase-js)
  • @tsworldtech/flux and @tsworldtech/flux_supabase installed
Looking for auth/session handling instead?

createSupabaseAdapter() only handles the realtime/delta layer — Flux doesn't authenticate anyone. @tsworldtech/flux_supabase also ships isReallyOnline(), createConnectivityAwareFetch(), and resolveAuthIntent(), which cover the separate (and easy to get wrong) problem of telling a real logout apart from a session check that just failed because the device is offline. See core_concepts/offline_auth_intent.

1

Create the adapter

Pass your existing Supabase client straight into createSupabaseAdapter(), then hand that to createFlux(). There is no separate connect step and nothing to await — the adapter opens a connection lazily, only once a store actually registers a channel.
typescript
1import { createClient } from '@supabase/supabase-js'
2import { createFlux } from '@tsworldtech/flux'
3import { createSupabaseAdapter } from '@tsworldtech/flux_supabase'
4
5const supabase = createClient(
6 process.env.NEXT_PUBLIC_SUPABASE_URL!,
7 process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
8)
9
10const flux = createFlux({
11 adapter: createSupabaseAdapter(supabase),
12})
2

Register a store against a table

Registration is where a store gets connected to a real table. table is the actual Postgres table name; channel is just the name your app uses internally and can be anything you like. event controls which row operations you care about — use '*' for all of them, or scope down to just INSERT, UPDATE, or DELETE if that is all a given store needs.
typescript
1flux.register({
2 store: useTeamStore,
3 channel: 'team_members',
4 table: 'team_members', // actual Postgres table name
5 event: '*', // INSERT, UPDATE, DELETE, or '*' for all
6 idbKey: 'team:members',
7 ttl: 'medium',
8 ingestionType: 'COLLECTION_ALL',
9 diffBeforeUpdate: true,
10 hydrateState: (store, data) => {
11 // store.getState().setState handles both array hydrations and delta frames
12 store.getState().setState(data)
13 },
14})
Rows are identified by an id column

Flux identifies each row by its id column. A table whose primary key is not named id produces events with no resolvable id, and those events are dropped rather than applied under a made-up id. Catch-up rows are identified by the row_id the changelog stores.

3

Turn on replication for the table

Supabase requires an explicit opt-in per table before it will emit realtime change events at all.
Enable replication in the dashboard

Realtime replication is off by default per table in Supabase. In the Supabase dashboard, go to Database → Replication, and toggle on the tables you want Flux to receive live updates for. A table that is not toggled on will still work for your initial data load — it just will not push live updates until replication is enabled for it.

4

Delta Catch-up & Database Setup

To enable retroactive catch-up after a client goes offline, run the flux_changelog SQL script in your Supabase SQL Editor, then tell the adapter how long the changelog is kept.
Automatic Sync Anchor RPC Integration

While live push updates work out of the box once table Replication is enabled, catch-up delta backfill (receiving what changed while offline) requires running the single-state flux_changelog SQL script in your Supabase SQL Editor. The adapter invokes the flux_request_delta RPC function on reconnect automatically. If you also install flux_request_delta_batch, the adapter catches up every table in a single request; without it, the adapter falls back to one flux_request_delta call per table. Deletion reconciliation uses the flux_reconcile function from the same script.

typescript
1const flux = createFlux({
2 adapter: createSupabaseAdapter(supabase, {
3 // Optional: how far back (ms) flux_changelog can reliably enumerate ALL
4 // changes, deletions included. Match your flux_changelog cleanup
5 // retention. Leave unset if you have not run the delta-sync SQL.
6 deltaRetentionMs: 14 * 24 * 60 * 60 * 1000,
7 }),
8})
5

What catch-up proves

After a reconnect, the adapter turns the changelog answer into a result Flux can act on:
typescript
1// After a reconnect the adapter asks flux_request_delta for what changed,
2// and the rows come back over a broadcast. Flux needs to know whether that
3// PROVED the data is current. The adapter reports one of:
4//
5// rows delivered and applied -> applied
6// an empty delta -> confirmed-current
7// gap older than deltaRetentionMs -> too-old (Flux refetches)
8// a changelog row could not be understood -> failed (Flux refetches)
9// (no op, no id, no timestamp, or unreadable row_data)
10// a row handler threw -> failed (Flux refetches)
11// the RPC returned an error -> failed (Flux refetches)
12// no broadcast within 30 seconds -> failed (Flux refetches)
13// subscription dropped or channel removed -> failed
14//
15// Flux also downgrades applied / confirmed-current to failed if a row was
16// dropped before it reached the store, or could not be written to disk.
17//
18// "failed" is never a data loss. It means "I can't prove this channel is
19// current", so Flux keeps the gap open and refetches.
20//
21// The adapter does not stamp its own clock on the result. Flux records the
22// channel as current through the moment the catch-up started (server
23// clock) minus a safety margin, so a device with a fast clock can't make
24// Flux claim data it never fetched.
6

Channel name vs. table name

These two fields are easy to mix up when a table's real name doesn't match how you refer to it in your app. Keep in mind that channel never has to match table — Flux only ever uses table to decide what to subscribe to on the Postgres side.
typescript
1// channel is just your internal name — table is the real Postgres table
2flux.register({
3 channel: 'pricing_matrix', // whatever name you want to use in your app
4 table: 'pricing', // the actual table Supabase watches
5 // ...
6})
7
8// Several registrations on the SAME table share one realtime subscription,
9// so this costs one channel on Supabase, not two:
10flux.register({ channel: 'pricing_all', table: 'pricing', /* ... */ })
11flux.register({ channel: 'pricing_detail', table: 'pricing', /* ... */ })
7

Row-level security just works

You don't need to think about security separately for the realtime path versus your normal queries.
RLS applies to realtime the same way it applies to reads

Supabase's realtime replication respects Row Level Security the same way normal reads do. If a row would not be visible to a signed-in user in a regular select, they will not receive an update for it over the realtime channel either. There is nothing extra to configure on the Flux side for this — it inherits whatever policies you have already set on the table.

Everything else — reconnecting after a dropped connection, making sure only one browser tab holds the live socket at a time, and catching back up on whatever changed while you were offline — happens automatically once these steps are done. There is nothing further to configure for a standard table.

For what happens after a reconnect specifically, see core_concepts/sync_anchors and core_concepts/activity_bus. For tables with a lot of rows where you only want detail pages cached on demand rather than all at once, see ingestion/lru. For telling a real logout apart from an offline session check, see core_concepts/offline_auth_intent.

Powered by Flux