In-app notifications are the bell in your app's header: "Sam invited you to Acme", "Your export is ready". On Supabase they need surprisingly little: one table, row-level security so each user sees only their own rows, a query for the unread ones, and Supabase Realtime to push new rows to the open tab without a refresh.
How do you add them? Create a notifications table that users can read and mark as read but not write. Insert rows from server code with the secret key. Load the latest unread rows when the page renders, then subscribe to INSERT events with Supabase Realtime, filtered to the current user, so the bell updates live. Here is each step, with the SQL and the component.
In-app notifications vs push notifications
In-app notifications live inside your product and show up while someone has it open. Push notifications reach the lock screen or the browser when nobody is looking, and they need their own setup: a service worker on the web, push tokens in a mobile app.
Many products end up with both, but in-app is the place to start. It needs no permission prompt, works in every browser, and gives you a record of what each user was told.
Step 1: create the notifications table
Each row belongs to one user, carries the text and an optional link, and has a flag for dismissed:
create table public.notifications ( id bigint generated always as identity primary key, user_id uuid not null references auth.users (id) on delete cascade, body varchar(5000) not null, link varchar(255), dismissed boolean not null default false, created_at timestamptz not null default now() ); create index notifications_user_dismissed_idx on public.notifications (user_id, dismissed);
The index covers the bell's query: one user's rows that aren't dismissed.
Step 2: lock it with row-level security
Users should read their own notifications and mark them as read, and nothing more. Grants decide which commands are allowed, and policies decide which rows:
alter table public.notifications enable row level security; revoke all on public.notifications from anon, authenticated, service_role; grant select, insert, update, delete on public.notifications to service_role; grant select on public.notifications to authenticated; grant update (dismissed) on public.notifications to authenticated; create policy notifications_select on public.notifications for select to authenticated using (user_id = (select auth.uid())); create policy notifications_update on public.notifications for update to authenticated using (user_id = (select auth.uid())) with check (user_id = (select auth.uid()));
Signed-in users get no insert grant, so nobody can send a fake notification to someone else. The update grant lists only dismissed, so nobody can rewrite the text of their own rows. If this pattern is new, Supabase RLS explained covers grants and policies in depth.
Step 3: create notifications from server code
Notifications come from events your server handles: an invitation sent, a payment received, a job finished. Insert them with a client built on the secret key, in a server-only module:
// lib/notify.ts
import 'server-only';
import { createClient } from '@supabase/supabase-js';
const admin = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SUPABASE_SECRET_KEY!,
{ auth: { persistSession: false, autoRefreshToken: false } },
);
export async function notify(userId: string, body: string, link?: string) {
const { error } = await admin
.from('notifications')
.insert({ user_id: userId, body, link });
if (error) {
throw error;
}
}
The secret key bypasses row-level security, so it stays on the server. Call notify() from Server Actions, route handlers and webhooks, after the event has really happened.
Step 4: load the unread list
In a Server Component, read the latest unread rows with the server client from Supabase's Next.js guide:
const { data: notifications } = await supabase
.from('notifications')
.select('id, body, link')
.eq('user_id', userId)
.eq('dismissed', false)
.order('created_at', { ascending: false })
.limit(10);
Row-level security already limits the rows to the signed-in user. The extra user_id filter repeats the policy, but it gives Postgres a better plan. Pass the rows to the bell as its starting list.
Marking one as read is a single update from the browser. The column grant and the policy make sure it can't touch anything else:
await supabase.from('notifications').update({ dismissed: true }).eq('id', id);
Step 5: live updates with Supabase Realtime
Without Realtime, a new notification appears on the next page load. With it, the open tab hears about the row as soon as it is inserted. First, add the table to the supabase_realtime publication:
alter publication supabase_realtime add table public.notifications;
Then subscribe in a Client Component. This is one of the few good uses for useEffect: opening a connection to an outside system, and closing it on unmount:
'use client';
import { useEffect, useState } from 'react';
import { createClient } from '@/lib/supabase/client';
type Notification = { id: number; body: string; link: string | null };
export function NotificationBell(props: {
userId: string;
initial: Notification[];
}) {
const [items, setItems] = useState(props.initial);
useEffect(() => {
const supabase = createClient();
const channel = supabase
.channel(`notifications:${props.userId}:${crypto.randomUUID()}`)
.on(
'postgres_changes',
{
event: 'INSERT',
schema: 'public',
table: 'notifications',
filter: `user_id=eq.${props.userId}`,
},
(payload) => {
setItems((current) => [payload.new as Notification, ...current]);
},
)
.subscribe();
return () => {
void supabase.removeChannel(channel);
};
}, [props.userId]);
return <span aria-label="Unread notifications">{items.length}</span>;
}
The channel name gets a random suffix because supabase-js reuses an existing channel with the same name. Without it, a second bell on the page, such as one in a mobile header, would get the first bell's channel and throw when it adds its listener.
Two things keep this private. Supabase's Postgres Changes guide says the filter is evaluated on the server, so events that don't match never leave the database. And Realtime checks each event against each subscriber's row-level security, so even a tampered filter only returns rows the user could select anyway. Delete events are the exception, covered below.
Postgres Changes or Broadcast?
Supabase offers two ways to stream database changes. Its guide to subscribing to database changes calls Broadcast the recommended method for scalability and security, and Postgres Changes the simpler one that doesn't scale as well.
The reason is in how they work. Postgres Changes authorizes each event once per subscriber, and processes changes on a single thread to keep them in order, so a bigger database doesn't make it much faster. Broadcast uses a trigger that calls realtime.broadcast_changes() and sends to private channels, protected by policies on realtime.messages.
For a notification bell, Postgres Changes is a sensible start: each user receives a handful of rows a day, and the setup is one publication and one subscription. Move to Broadcast when your traffic grows, or when you want replay. Broadcast can replay messages a client missed, on private channels fed from the database. With Postgres Changes, the list you load from the table on each page view is your safety net.
Common mistakes
| Symptom | Cause |
|---|---|
| The subscription never fires | The table isn't in the supabase_realtime publication |
| Signed-in users get no events, though the publication is set | The authenticated role has no select grant, or no policy matches the user |
| Delete events reach users who never saw the row | RLS isn't applied to DELETE events. With RLS on, they carry only the primary key, but don't build features on them |
| The count is wrong after a laptop wakes up | Events missed while offline aren't in the stream; reload the list |
| Duplicate events | The effect subscribed twice and never removed its channel |
How the kit does it
shipanysaas ships in-app notifications for personal and team accounts. They are on by default, behind NEXT_PUBLIC_ENABLE_NOTIFICATIONS. The table, in apps/web/supabase/schemas/11-notifications.sql, belongs to an account rather than a user, so a team can be notified too. Each row has a type (info, warning or error), a body, an optional link and an expiry date, which defaults to a month after it is created. Members of the account can read the rows. Users can change only dismissed, through a column grant, with a trigger that refuses any other change. The table is already in the supabase_realtime publication.
Server code creates notifications with createNotificationsApi(client) from packages/features/notifications. The bell lives in a popover in the personal and team workspaces. It loads the ten latest unread, unexpired notifications across the user's personal account and, in a team workspace, that team. Live updates are off by default; NEXT_PUBLIC_REALTIME_NOTIFICATIONS=true turns on a Postgres Changes subscription to new rows for those accounts. Like the other tables, notifications sit behind the kit's two-step sign-in rule: a user who has an authenticator app can't read them until the second step is done.
The live demo has sample notifications: sign in with the one-click button, switch to the Acme Inc team and open the bell. The phone app doesn't show notifications yet. The feature list is in Features, and the plans are on the pricing page.
Questions people ask
Do I need Supabase Realtime for in-app notifications?
No. A list loaded on each page view already works, and many apps start there. Realtime adds live updates for tabs that stay open.
Can users see each other's notifications?
Not with the policies above. Reads are limited to the user's own rows, Realtime applies the same policies to insert and update events, and users have no insert grant.
How do I notify a whole team?
Either insert one row per member, or give the table an account_id and a policy that checks team membership, as the kit does. The second keeps one row per event, but the dismissed flag is shared: when one member dismisses it, it disappears for the whole team. If each member needs their own read state, insert one row per member.
Should I use Broadcast from the start?
Only if you expect heavy traffic or need replay. Postgres Changes is less setup, and the table and policies stay the same if you switch later.