Realtime
Typed broadcast topics with generated authorization, row-change triggers and disposable subscriptions.
defineTopic describes a private Realtime broadcast topic once. The same
definition names the channel, authorizes it in SQL, validates payloads, and
subscribes.
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
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 JWTtenant_id(orapp_metadata.tenant_id).{userId}must equalauth.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.
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
(tenant_ids_with, or is_platform for scope: 'platform'). With an
authorization provider,
sql: "provider" calls its idsWith and isPlatform functions for any
scope:
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. 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.
// 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 (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
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:
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
- 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.messagesis not a history. Database broadcasts are rows inrealtime.messages, which is partitioned by day and pruned after a few days. Clients that reconnect should refetch from your tables.
Row changes
triggerSql writes a trigger that sends row changes with
realtime.broadcast_changes. Placeholders map to columns by their app names:
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.
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:
const change = rowChange(betterSupabase, "customers", message);
// { operation: 'UPDATE', table: 'customers', record: Customer | null, oldRecord: Partial<Customer> | null }Subscribe
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 refusedusing 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
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
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:
export const customerViewers = defineTopic(
"organization:{organizationId}:customer:{customerId}:viewers",
{ presence: v.object({ userId: v.string(), name: v.string() }) },
);"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
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
defineTopictemplate with a tenant or owner placeholder, and turn off public access (see Before production). - Persist messages. Insert each message into a table and broadcast it
with
triggerSql, so the row is the record. Clients that join late or reconnect load the history from the table, and the sender's id comes fromauth.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;
sendandtrackdon'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
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 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:
const status = useBroadcast(
customersTopic,
{ organizationId },
{ updated: (payload) => refresh(payload.id) },
{ client: supabase, queryClient },
);Last updated on