# Realtime

> Typed broadcast topics with generated authorization, row-change triggers and disposable subscriptions.

Source: https://bettersupabase.com/docs/platform/realtime

`defineTopic` describes a private Realtime broadcast topic once. The same
definition names the channel, authorizes it in SQL, validates payloads, and
subscribes.

```ts title="lib/topics.ts"
import { defineTopic } from "better-supabase/realtime";
import * as v from "valibot";

export const customersTopic = defineTopic(
  "organization:{organizationId}:customers",
);

export const notifications = defineTopic(
  "organization:{organizationId}:notifications:{userId}",
  {
    events: { created: v.object({ id: v.string(), title: v.string() }) },
    send: true,
  },
);
```

Or declare templates under `topics` in `better-supabase.config.ts` and pass
the generated constant: `defineTopic(topics.notifications)`.

## Authorization [#authorization]

Topics are private by default: Realtime checks `realtime.messages` policies
when a client joins. `topic.sql()` generates them:

* The topic must match the template (`^organization:[^:]+:notifications:[^:]+$`).
* `{organizationId}` must equal the JWT `tenant_id` (or `app_metadata.tenant_id`).
* `{userId}` must equal `auth.uid()`.

Both checks switch on automatically when the template has those placeholders.
Configure them with `tenant: { param, claim | sql }` and `owner: { param }`,
or turn them off with `false`. `send: true` adds an insert policy so clients
can broadcast too, and `presence` authorizes [presence](#presence).

`access` replaces the tenant check with permissions: joining needs `receive`
in the scope named by the `{organizationId}` segment, and sending needs
`send`. Without `sql`, the policies call the [access contract](/docs/blocks/access)
(`tenant_ids_with`, or `is_platform` for `scope: 'platform'`). With an
[authorization provider](/docs/extending/authorization-providers),
`sql: "provider"` calls its `idsWith` and `isPlatform` functions for any
scope:

```ts
export const board = defineTopic("organization:{organizationId}:board", {
  access: {
    receive: "board.read",
    send: "board.write",
    scope: "organization",
    sql: "provider",
  },
});
```

`better-supabase sql sync` fills in the config's `authorization.functions`.
Anywhere else, pass them to `topic.sql({ functions })`; without them it
throws. An object with `idsWith` and `isPlatform` copies the templates
instead.

Without `send`, clients can only receive. `segment` (1-based, `:`-separated)
works as for [buckets](/docs/platform/storage#provider-permissions). The
segment is compared as text, so it must be the id's canonical lowercase form:
a topic with an uppercase uuid is denied.

The policies check role and scope, not a permission's row conditions. Use a
key only when its grants have no row conditions beyond the scope: for one
that has them, the topic lets every member of the scope join or send. Keep
those permissions on tables.

```ts
// Wrong: board.read is granted only for boards the member owns
access: { receive: "board.read", scope: "organization", sql },
// Right: board.view is granted per organization, with no row condition
access: { receive: "board.view", scope: "organization", sql },
```

`better-supabase gen` and [`doctor`](/docs/cli/doctor#bs214) (BS214) refuse a
`receive` or `send` key the provider doesn't mark `sqlComplete: true`, for
topics in the config and for generated topic policies in your SQL files.

### Writing the policies to a file [#writing-the-policies-to-a-file]

`realtime.policies` keeps the generated policies in a schema file:
`better-supabase sql sync` imports the modules in `from`, collects every
topic they export, and writes their `sql()` to `output`, and
`sql sync --check` fails when the file no longer matches, for example in CI:

```ts title="better-supabase.config.ts"
export default defineConfig({
  realtime: {
    policies: {
      from: ["src/realtime/topics.ts"],
      output: "supabase/schemas/905_topics.sql",
    },
  },
});
```

Node imports the modules, so their relative imports need the `.ts`
extension. Create a migration from the file as from any other schema file.

Some SQL modules write the receive policy of their own topics: `notifications`
(its `topic` option, `notifications:{userId}` by default, while `realtime` is
`broadcast`), `announcements` (its `topic` option, `announcements` by
default) and `realtime-tables` (every topic that starts with `bs:t:`). When a
module in `sql.modules` covers a topic you export, for example to subscribe
to it with the typed client, `sql sync` leaves that topic out of `output` and
names the module in a comment at the top of the file, so the database doesn't
get two equivalent policies. Templates match by their literal parts, so
`notifications:{user}` matches `notifications:{userId}`. A topic that also
sends or uses presence keeps its policies, because the module only writes a
receive policy for broadcasts.

## Before production [#before-production]

* **Turn off public access.** The policies only apply to private channels. In
  the dashboard's Realtime settings, turn off "Allow public access" so a
  client can't join the same topic as a public channel and skip them.
* **Policies are cached per connection.** Realtime checks them when a client
  joins and keeps the result until the client's access token refreshes or it
  rejoins. A member you remove keeps receiving until then, up to the token's
  expiry.
* **`realtime.messages` is not a history.** Database broadcasts are rows in
  `realtime.messages`, which is partitioned by day and pruned after a few
  days. Clients that reconnect should refetch from your tables.

## Row changes [#row-changes]

`triggerSql` writes a trigger that sends row changes with
`realtime.broadcast_changes`. Placeholders map to columns by their app names:

```ts
customersTopic.triggerSql(betterSupabase, "customers", {
  values: { organizationId: "organizationId" },
});
```

The trigger function goes into the `better_supabase` schema, which the Data
API does not expose, so clients can't call it through `/rpc`. Pass
`functionSchema` to put it somewhere else.

A placeholder can also come from a parent row. A lookup `{ from, via,
select }` reads the `select` column of the `from` row whose primary key
(or `key`) equals `via`, a column of the changed row or another lookup, so a
delivery can reach its thread's topic through the message it belongs to.
A row whose topic comes out null, such as one whose parent is gone, sends
nothing.

```ts
threadTopic.triggerSql(betterSupabase, "deliveries", {
  values: {
    threadId: { from: "messages", via: "messageId", select: "threadId" },
  },
  event: { insert: "delivery_added", update: "delivery_changed" },
  payload: {
    deliveryId: "id",
    organizationId: {
      from: "threads",
      select: "organizationId",
      via: { from: "messages", via: "messageId", select: "threadId" },
    },
    operation: { sql: "lower(tg_op)" },
  },
});
```

`event` names the broadcast event, for every operation or per operation
(`INSERT`, `UPDATE` or `DELETE` otherwise). With `payload` the trigger
sends that object with `realtime.send` instead of the row change: each key is
a column, a lookup, or `{ sql }`, an expression on the row `{row}` with
`tg_op` for the operation. `rowChange` reads only the row-change shape, so
handle a custom payload with its own event schema.

On the client, `rowChange` turns a message into an app-cased change, or `null`
for other tables:

```ts
const change = rowChange(betterSupabase, "customers", message);
// { operation: 'UPDATE', table: 'customers', record: Customer | null, oldRecord: Partial<Customer> | null }
```

## Subscribe [#subscribe]

```ts
using sub = notifications.subscribe(
  supabase,
  { organizationId, userId },
  {
    created: (n) => toast(n.title), // typed from the schema
    "*": (payload, message) => console.log(message.event, payload),
  },
);
await sub.ready; // rejects when the join is refused
```

`using` ends the subscription at the end of the scope. `await using` waits for
it, and you can also call `sub.unsubscribe()`. Subscriptions to the same topic
on one client share a channel, which is removed when the last one ends; they
must agree on `self`. A channel whose first join is refused or times out is
dropped, so the next `subscribe` joins again. For private topics,
`subscribe` calls `realtime.setAuth()` first, so Realtime gets the
session's current token. A token you set yourself with
`supabase.realtime.setAuth(token)`, such as one minted for an agent or a
service, is kept: `subscribe`, `send`, `useBroadcast`, `usePresence` and `useLiveCount` only
refresh a token that came from the session. Payloads that fail their schema
skip the handler and go to `onInvalid`.

## Send [#send]

```ts
await notifications
  .send(supabase, { organizationId, userId }, "created", { id, title })
  .orThrow();
```

The payload is validated first (a `validation` error on failure) and sent over
HTTP without joining the channel. Policy denials come back as `unauthorized`
or `forbidden`.

## Presence [#presence]

Pass a schema as `presence` to show who is on a topic. The policies then
authorize presence, and every subscription can `track` this client's state
and get everyone's states after each sync:

```ts title="lib/topics.ts"
export const customerViewers = defineTopic(
  "organization:{organizationId}:customer:{customerId}:viewers",
  { presence: v.object({ userId: v.string(), name: v.string() }) },
);
```

```tsx title="features/customers/components/viewers.tsx"
"use client";

export function Viewers({ organizationId, customerId, me }: Props) {
  const supabase = useSupabase();
  const [viewers, setViewers] = useState<readonly string[]>([]);

  useEffect(() => {
    const sub = customerViewers.subscribe(
      supabase,
      { organizationId, customerId },
      {},
      {
        onPresence: (members) =>
          setViewers([...new Set(members.map((m) => m.state.name))]),
      },
    );
    void sub.track({ userId: me.id, name: me.name });
    return () => void sub.unsubscribe();
  }, [supabase, organizationId, customerId, me.id, me.name]);

  return <AvatarStack names={viewers} />;
}
```

`track` validates the state (a `validation` error on failure), waits for the
join and returns a `Result`. States other clients track that fail the schema
are left out of `onPresence` and reach `onInvalid` as a `presence` message.
`members()` returns the last sync. Each tab is its own member with its own
`key`, so group by a field of the state (`userId` above) to count people.

Presence shares the topic's channel, so one client has one state per topic:
the last `track` wins, and it goes away with the subscription that tracked
it. `presence: true` skips the schema and accepts any object. Every
definition of a topic name must agree on presence. For cursors or
collaborative editing, broadcast positions as a regular event; presence
suits state that changes a few times a session, since every change reaches
everyone.

## Chat and cursors [#chat-and-cursors]

The realtime chat and cursor blocks in the Supabase library join a public
channel, take the sender's name from the client, and keep messages only in
the browsers that were open when they arrived. To build the same features on
topics:

* **Authorize the topic.** Use a private `defineTopic` template with a
  tenant or owner placeholder, and turn off public access (see
  [Before production](#before-production)).
* **Persist messages.** Insert each message into a table and broadcast it
  with [`triggerSql`](#row-changes), so the row is the record. Clients that
  join late or reconnect load the history from the table, and the sender's
  id comes from `auth.uid()` in the row's policy instead of from the client.
* **Throttle sends in the app.** Realtime limits the messages per second for
  each client and project. Send a cursor position at most every 50 to 100 ms
  and drop the positions in between; `send` and `track` don't throttle for
  you.
* **Name people from the session.** Use the user id as the identity, and
  take names and colors from a profile row or the presence state.

The collaborative editor blocks sync Yjs documents through
`@supabase-labs/y-supabase`. better-supabase ships no Yjs provider, so that
stays an app dependency; check that it joins a private channel before you
rely on a topic policy to guard the document.

## React [#react]

```tsx
import { useBroadcast } from "better-supabase/react";

const status = useBroadcast(
  customersTopic,
  organizationId ? { organizationId } : null,
  {},
  { invalidate: ["customers"] },
);
```

The hook subscribes while mounted and resubscribes when the topic or the
signed-in user changes. `invalidate` refetches the table's
[TanStack queries](/docs/frontend/query) after each message. It needs
`queryClient` on `BetterSupabaseProvider`. The hook returns `joining`,
`subscribed`, `closed` or `error`.

An app that uses supabase-js without the better-supabase client passes its
client (and a `queryClient` for `invalidate`) instead of the provider. The
hook then follows that client's auth state to resubscribe for a new user:

```tsx
const status = useBroadcast(
  customersTopic,
  { organizationId },
  { updated: (payload) => refresh(payload.id) },
  { client: supabase, queryClient },
);
```