Live queries
Keep any query fresh over Realtime, without sending row data over the socket.
A live query refetches when a table it reads changes. The database sends
only "customers changed"; the client refetches through PostgREST, so RLS
decides what each user sees, and includes and filters stay correct.
Setup
-
List the tables in the config and regenerate:
better-supabase.config.ts export default defineConfig({ realtime: { tables: ["customers", "notes"] }, }); -
Add the SQL module. It installs a statement-level trigger per table (one broadcast per statement, not per row) and a
realtime.messagespolicy for the topics:better-supabase gen better-supabase sql add realtime-tables
With the tenant plugin, tables broadcast on a
per-tenant topic (bs:t:public.customers:<organization>), and the policy only lets a
user join the tenant in their tenant_id claim. A table without the tenant
column fails to install, so a tenant table never broadcasts to everyone by
mistake. List tables that are global on purpose in realtime.global; they
use one topic that every signed-in user receives:
realtime: { tables: ["customers", "plans"], global: ["plans"] },A table whose rows each belong to one user, such as notifications, can
broadcast per user instead: map it to its user column in realtime.users.
It uses the topic bs:t:public.notifications:u:<user id>, which only that
user receives, so a change to one user's rows doesn't make the rest of the
tenant refetch. The React hooks pass the signed-in user; liveQuery and
liveCount take it as user.
realtime: { tables: ["notifications"], users: { notifications: "user_id" } },React
const spec = betterSupabase.spec.customers.findMany({
include: { notes: true },
limit: 50,
});
function Customers() {
const { data } = useQuery(bs.queries.$spec(spec));
useLiveQuery(spec); // refetches on changes to customers or notes
return <List rows={data} />;
}useLiveQuery needs <BetterSupabaseProvider queryClient={queryClient}>.
The tenant defaults to the tenant_id claim (claims.tenant in the config); pass { tenant } to override it.
Changes are debounced (100 ms by default, debounceMs), so a bulk update
causes one refetch. It returns the channel status.
Counts
Badges (unread messages, open tasks) only need a number. useLiveCount
runs a count spec, which is a HEAD request with no rows, and reruns only
that after each debounced change. It needs no QueryClient:
const unread = betterSupabase.spec.notifications.count({
where: { readAt: null },
});
function UnreadBadge() {
const { count, status, error } = useLiveCount(unread);
return <Link href="/inbox">Inbox {count ?? "–"}</Link>;
}To render the number on first paint, count on the server and pass the seed. The client then skips its first request:
// Server Component, inside <Suspense>
const seed = await bs.liveCount(unread); // { spec, count }
return <UnreadSummary seed={seed} />;
// Client Component
const { count } = useLiveCount(seed);bs.liveCount uses bs.context(); inside 'use cache: private',
pass the db from bs.cached() as the second argument. For a tenant from
the route, pass the db from bs.context({ tenant }) or
bs.cached({ tenant }), and give the client hook the same
{ tenant }. A failed count
gives count: null, and the client fetches it instead. Don't seed from a
cache entry that can outlive changes made by other users: changes made
before the client joins the channel aren't broadcast to it.
A seed costs a database call in the render that makes it. For a badge in
the layout, which is part of every page, skip the seed and let the
browser count. For in-app notifications, the
notifications block has its own tables,
counts and private topic; the Next.js example builds its
header badge on it with useNotifications.
Without React
import { liveQuery } from 'better-supabase/realtime';
const live = liveQuery(betterSupabase, supabase, spec, {
tenant: organizationId,
onChange: (tables) => queryClient.invalidateQueries(...),
});
await live.ready;
live.unwatched; // tables the query reads that don't broadcast
await live.unsubscribe();Pass a list of table keys instead of a spec to watch tables directly.
Channels are shared: ten live queries on customers open one channel.
liveCount is the same for a count spec, with the result:
import { liveCount } from "better-supabase/realtime";
using live = liveCount(betterSupabase, supabase, db, unread, {
tenant: organizationId,
onCount: (count) => render(count),
onError: (error) => report(error), // the previous count stays valid
immediate: true, // pass false when you already have a count
});Out-of-order responses are dropped, so a slow request can't overwrite a newer count.
Reconnects
Broadcast is best effort: changes sent while a client is disconnected are
lost. When a channel rejoins after CHANNEL_ERROR, TIMED_OUT or
CLOSED, every live query and live count on it refetches once, as if its
tables had changed.
Doctor
- BS305 flags a table in
realtime.tableswithout the trigger. - BS306 flags tables in the
supabase_realtimepublication whose replica identity can't identify deleted rows.
Limits
A change to a table outside realtime.tables doesn't refresh the query;
unwatched lists those tables. A refetch costs one request per changed
query, which suits dashboards and lists better than high-frequency streams.
For cursors, presence or chat, use Realtime topics.
Last updated on