Notifications
In-app notifications with per-recipient state, subject subscriptions, channel preferences, email and push deliveries, and realtime updates on a private topic.
The notifications SQL module stores one event per
change and one row per recipient, with read, dismissed and resolved state
each user controls. createNotifications from better-supabase/blocks/notifications
sends and reads them with typed data, and useNotifications from
better-supabase/blocks/notifications/react keeps a list current over Supabase Realtime.
pnpm better-supabase sql add notificationsSending goes through notify(jsonb), a security definer function, so a
client can't write notification rows directly or pick another user as the
actor. With the access contract installed, the sender
needs notifications.send in the organization and every recipient needs
notifications.read; the actor and non-members are left out. With the
outbox, every notification also emits
notification.created.
Sending
Declare each type with a Standard Schema for its data. send validates the
data before it reaches the database:
import "server-only";
import {
createNotifications,
sqlTransport,
} from "better-supabase/blocks/notifications";
import { z } from "zod";
export const types = {
"task.assigned": z.object({ title: z.string() }),
"approval.requested": z.object({ amount: z.number() }),
};
export const notificationsFor = (claims: Record<string, unknown>) =>
createNotifications({
transport: sqlTransport(postgres.asUser(claims)),
types,
actionable: ["approval.requested"],
events: betterSupabase.events,
render: (item) => ({ title: `${item.type}: ${item.subject?.label ?? ""}` }),
});const ctx = await bs.context();
const notifications = notificationsFor(ctx.auth.claims);
await notifications
.send("task.assigned", {
tenant: organizationId,
recipients: [assigneeId],
subject: { type: "task", id: task.id, label: task.title },
actionPath: `/tasks/${task.id}`,
data: { title: task.title },
key: `task-assigned-${task.id}-${assigneeId}`,
})
.orThrow();send returns { id, recipients }, the notification id and the user ids
that hold it after the watchers, the audience hook, preferences and the read
filter are applied, or null when nobody was left to notify. A composer
can report how many people it reached from recipients.length, and
onSent and the notification.created block event get the same list.
With a key, sending again in the same organization returns the first id,
so a retried request never notifies twice. In SQL, send_notification(jsonb)
returns the same { "id", "recipients" } object and notify(jsonb) returns
only the id. rpcTransport(supabase) calls the
same functions over the Data API instead, when the block schema is exposed.
| Input | Meaning |
|---|---|
recipients | User ids. Subject watchers and the notification_audience hook add more |
subject | What it is about, { type, id, label? }. Watchers of the subject get it too |
activity | participating (default) reaches every watcher; all reaches only watchers at level all |
priority | One of options.priorities (low, normal, high, urgent). Defaults to normal |
channels | The channels to deliver on. Defaults to options.channels |
includeActor | Also notify the user who caused it |
watchers | false leaves the subject's watchers out, for a composer that reaches named people only (a mention, an assignment); ignore still holds |
exclude | User ids left out after the watchers and the hook are added, such as the members a separate mention already reached |
resolved | Store it already resolved, for a record of something that needs no action |
actorId | The actor when the service sends for a user. A signed-in sender is always the actor themselves |
Reading
The same client reads the signed-in user's notifications. render runs at
read time, so text follows the reader's locale and later copy changes:
const items = await notifications
.list({ tenant: organizationId, status: "unread", locale: "nl" })
.orThrow();
const { unread, actionable } = await notifications
.counts({ tenant: organizationId })
.orThrow();
const one = await notifications.get(items[0].id).orThrow();
const { count, items: changed } = await notifications
.markRead({ ids: [items[0].id] })
.orThrow();
await notifications.dismiss([items[0].id]);
await notifications.resolve({
type: "approval.requested",
subject: { type: "invoice", id },
});get(id, { locale?, include? }) reads one of the caller's notifications by
its recipient row id, dismissed ones included, rendered like a list item, or
null when the caller has no such notification. It calls
get_notification(id).
get, list and page parse each item's data with the schema of its type
in types, so item.data narrows on item.type: after checking
item.type === "approval.requested", item.data.amount is a number. Data
the schema rejects makes the read fail with a validation error and the hint
NOTIFICATION_DATA_INVALID; a type that isn't in types keeps its data as
stored. NotificationOf<typeof types> is that item type.
markRead, markUnread, dismiss and resolve return { count, items }:
how many rows changed, and the caller's own notifications as they are after
the change, so an app can update its state without reading them back. For
resolve, count covers every recipient and items holds only the
caller's own copies, which is empty for the service. The SQL functions
return the same object as jsonb, with each item in the shape that
list_notifications returns.
list pages with cursor and limit (50 by default, at most 200). Pass the
last item of the previous page as cursor: the block orders by created_at
and then id, so items created at the same instant are not skipped. It filters with status (all, unread, read, unresolved, settled),
read, resolved, dismissed, types, subjectTypes and search. resolve marks every recipient's copy of a type about one subject
as handled, for example when someone approves the invoice.
search matches text in the summary, the subject label and the type,
ignoring case; % and _ in it are plain characters. subjectTypes keeps
notifications about subjects of the listed types. Every status except
settled leaves dismissed notifications out, and settled lists the
resolved and the dismissed ones, for an archive view.
read, resolved and dismissed are independent booleans that combine
with each other and with status. read: false keeps unread ones, and
resolved: true keeps resolved ones. dismissed defaults to false;
true lists only dismissed notifications and null lists both. settled
keeps its meaning and ignores dismissed.
| View | Filters |
|---|---|
| Unread and open | { read: false, resolved: false } |
| Handled | { resolved: true } |
| Dismissed | { dismissed: true } |
| Everything | { dismissed: null } |
| Archive (settled) | { status: "settled" } |
Without a resolved_at column, resolved: true matches nothing and
resolved: false matches everything.
Pages with a total
An inbox with numbered pages uses page, which takes the same filters with
an offset instead of before and returns the matching total next to the
items:
const { items, total } = await notifications
.page({ tenant: organizationId, search: "invoice", limit: 20, offset: 40 })
.orThrow();It calls notification_page(tenant, status, types, subject_types, search, max_items, skip, read, resolved, dismissed), which counts the matches in
the same query. list_notifications takes the same three filters after
search. list stays
the cheaper choice for an infinite scroll.
Unread again and counts
markUnread({ ids, tenant? }) clears the read time of the caller's own
notifications, so a reader can keep one for later. It returns the count and
the changed notifications and leaves dismissed notifications alone.
counts() returns unread, actionable (unresolved notifications of the
actionable types) and actionableSubjects, which counts each subject
once. Two reminders about the same invoice count as two in actionable and
as one in actionableSubjects, which suits a badge that counts open tasks.
Hydrating a page
render runs once per item. When it needs data from elsewhere, such as actor
names or subject titles, hydrate loads it for the whole page in one call,
and render reads the result from context.hydrated:
const notifications = createNotifications({
transport,
types,
hydrate: async (items) => ({
tasks: await loadTaskTitles(
items.flatMap((item) => (item.subject ? [item.subject.id] : [])),
),
}),
render: (item, { locale, hydrated }) => ({
title: t(locale, item.type, {
task: hydrated?.tasks.get(item.subject?.id ?? ""),
}),
}),
});With the profiles module installed alongside,
list({ include: ["actor"] }) adds each item's actor (id, username,
fullName, firstName, lastName, avatar and avatarPath, those the
profiles table has) in one call to notification_actors(ids), which returns
only actors of the caller's own notifications.
avatarPath is a Storage object path. With the avatars option the block
turns it into actor.avatarUrl: pass { url, bucket } for a public bucket
(<url>/storage/v1/object/public/<bucket>/<path>), or a function of the
path for signed or transformed URLs:
const notifications = createNotifications({
transport,
types,
avatars: { url: process.env.SUPABASE_URL!, bucket: "users" },
});Realtime
By default each new or changed recipient row is broadcast on the user's
private topic, notifications:{userId}. The payload carries ids only; the
client reloads through RLS. useNotifications joins the topic with the
user's token, reloads on each message and after a reconnect, and merges any
other sources you pass:
"use client";
import { useNotifications } from "better-supabase/blocks/notifications/react";
export function Inbox({
userId,
organizationId,
}: {
userId: string;
organizationId: string;
}) {
const { items, count } = useNotifications({
topic: `organization:${organizationId}:notifications:${userId}`,
load: () =>
fetch(`/api/notifications?organization=${organizationId}`).then((res) =>
res.json(),
),
});
return <Bell count={count} items={items ?? []} />;
}| Option | Default | Meaning |
|---|---|---|
realtime | broadcast | broadcast, changes (adds the table to supabase_realtime) or none |
topic | notifications:{userId} | The topic template; {tenantId} adds the organization |
createdEvent | notification_created | The broadcast event for a new notification |
updatedEvent | notification_updated | The broadcast event for a read, dismissed or resolved one |
The module adds a realtime.messages policy so each user can join only
topics that match the template with their own id.
Subscriptions and preferences
Users watch a subject with subscribe({ subject, level }). Level all gets
routine activity too, participating only what involves them, and ignore
mutes the subject even when a sender names them. null removes the
subscription.
To make the author or an assignee follow a subject without overriding a
choice they made, pass ifAbsent: true with their userId:
await notifications
.subscribe({
subject: { type: "task", id: taskId },
level: "participating",
tenant: organizationId,
userId: assigneeId,
ifAbsent: true,
})
.orThrow();It adds the level only when the member has none for the subject, so their
ignore or all stays. A signed-in sender with the send permission in the
tenant may do this for another member of it; without ifAbsent, only the
service sets another member's level. In SQL the flag is
set_notification_subscription(..., member, if_absent => true).
The caller reads their own subscriptions with subscriptions({ tenant?, subject? }), where subject is { type, id? }, and their channel choices
with preferences({ tenant? }). With tenant, preferences returns the
choices for that organization and the ones that hold everywhere (tenant
null), so a settings page can show both. In SQL they are
list_notification_subscriptions(tenant, subject_type, subject_id) and
list_notification_preferences(tenant), granted to authenticated.
setPreference({ type, channel, enabled, tenant? }) turns a type (or *)
on or off on a channel. The most specific row wins: the organization and
type, the organization and *, everywhere and type, everywhere and *,
then options.channelDefaults. Without a default, in_app is on and every
other channel is off until the user turns it on, so nobody gets email they
did not ask for. The block resolves the preferences of all recipients in one
query, and notify refuses more than options.maxRecipients (1,000)
recipients with NOTIFICATION_TOO_MANY_RECIPIENTS; fan out larger audiences
from a job.
Email, push and other channels
Every channel except in_app gets a pending delivery row per recipient.
Pass a NotificationChannel per channel and call deliver() from a cron
route or a job, with a service connection:
import "server-only";
import {
createNotifications,
sqlTransport,
} from "better-supabase/blocks/notifications";
import { types } from "@/lib/notifications";
const notifications = createNotifications({
transport: sqlTransport(postgres.admin),
types,
channels: [
{
apiVersion: 1,
name: "email",
send: async ({ email, text, notification }) => {
if (!email) return { status: "skipped" };
const { id } = await resend.emails.send({
to: email,
subject: text?.title ?? notification.type,
});
return { provider: "resend", providerMessageId: id };
},
},
],
});
export async function GET() {
return Response.json(await notifications.deliver({ budgetMs: 50_000 }));
}deliver claims pending rows with for update skip locked and leases them
(lease, five minutes by default), so two runs never send the same message.
A channel that throws leaves the row pending until next_attempt_at, a
random time up to 30 seconds that doubles per attempt to at most an hour.
The database marks it failed after maxAttempts (5), and also when a
worker died while it held the last attempt. testNotificationChannel from
better-supabase/testing checks a channel against the contract.
Run purge() (purge_notifications(older_than, batch) in SQL) from a
scheduled job to delete notifications older than 90 days with their
recipients and deliveries, 10,000 per call.
From outbox events
notifications.sink({ map }) is an outbox sink that turns events into
notifications. map receives each CloudEvent, with the
dev.better-supabase. prefix stripped from its type, and returns a
notification or null to skip it:
await outbox.relay(
"notify",
notifications.sink({
map: (event) =>
event.type === "organization.member_added"
? {
type: "organization.joined",
recipients: [String(event.data.userId)],
data: { title: "You joined the organization" },
}
: null,
}),
);The notification's key defaults to the event id and its tenant to the
event's partitionkey, so a replayed batch sends nothing twice. A failed
send throws, and the relay retries the batch.
SQL modules notify through the notifications module in the same
transaction, not through this sink. The comments module already sends a
notification for every mention (comment.mentioned), so don't map that
event again.
Extending it
| Hook or event | Use |
|---|---|
notification_audience hook | A SQL function that adds recipients, e.g. everyone with a role on the subject |
before_notification_send hook | A SQL function that gets the notification as jsonb and raises to refuse it |
after_notify hook | A SQL function that runs in the same transaction after the rows are written |
hooks option | Refuses, rewrites or observes any method in TypeScript, e.g. quiet hours on send; see Extending blocks |
onSent option | Runs after send stores a notification, e.g. to call Next's updateTag |
notification.* block events | created, delivered and failed on betterSupabase.events |
notifications.send permission | Rename it with sql.modules.notifications.permissions.send |
Existing tables
The managed tables are notification_events (with type, data and
actor_id), notification_recipients (keyed on user_id),
notification_deliveries, notification_subscriptions and
notification_preferences, keyed on organization_id. Adopt yours and
rename what differs; map columns and tables you don't have to null:
export default defineConfig({
sql: {
modules: {
notifications: {
mode: "adopt",
schema: "public",
idType: "uuid",
tables: { subscriptions: null },
columns: {
events: { key: null, actor: "actor_user_id", data: "metadata" },
recipients: { user: "recipient_user_id", resolvedAt: null },
},
options: {
topic: "organization:{tenantId}:notifications:{userId}",
channels: ["in_app", "email"],
},
},
},
},
});Without a key column, keyed sends derive the event id from the organization
and key instead: a UUIDv8 (RFC 9562) built from the MD5 of both, so the id
stays deterministic and passes strict validators such as z.uuid(). Events
stored by an earlier version under the plain MD5 id keep that id, and a
keyed send that matches one still returns it instead of notifying twice. A recipient with in-app off for a type still gets a row,
stored as dismissed, so other channels can deliver it.
Error codes
hint | When |
|---|---|
NOTIFICATION_TYPE_REQUIRED | notify without a type |
NOTIFICATION_TYPE_UNKNOWN | send with a type that isn't in types (checked in TypeScript) |
NOTIFICATION_DATA_INVALID | stored data that its type's schema rejects on a read (TypeScript) |
NOTIFICATION_FORBIDDEN | The sender lacks notifications.send in the organization |
NOTIFICATION_PRIORITY_UNKNOWN | A priority that isn't in options.priorities |
NOTIFICATION_ACTIVITY_UNKNOWN | An activity other than participating or all |
NOTIFICATION_LEVEL_UNKNOWN | A subscription level other than participating, all or ignore |
NOTIFICATION_STATUS_UNKNOWN | Completing a delivery with a status the block doesn't know |
NOTIFICATION_TOO_MANY_RECIPIENTS | More recipients than options.maxRecipients |
Last updated on