In-app notifications live where users are: the dashboard. A bell icon with an unread badge (red dot, count) sits in the header. Click it—a dropdown slides in showing your last 10 notifications, newest first. Click a notification; it turns grey and links you to the context (a deal closed, an approval needed, a report is ready). Mark-read happens on click. Supabase Realtime pushes new notifications to your bell instantly, no refresh needed. Your unread count updates in real-time. This beats email-only because dashboards are where reps live; email is spam folder purgatory. Sales teams see the notification, act immediately, and ship faster.
Why In-App Beats Email-Only
Email notifications are fire-and-forget. Users miss them. They land in spam. You have no signal on whether someone read it. In-app notifications sit right in the UI—they compete for attention at the moment of need. A rep opens the dashboard to check their quota; the bell pings. They see "Deal closed: $52k ACV." They click through to the deal record, update notes, move on. The action is 20 seconds, not an email hunt. Engagement on in-app is 10× higher than email for time-sensitive SaaS. If a deal closure, approval request, or report needs immediate action, the bell is the channel that works.
Architecture: Table + Realtime + Radix DropdownMenu
The notifications table has columns: id, user_id, title, message, link, is_read, created_at. A trigger on deals or approvals or reports inserts a row. Your React component subscribes to Supabase Realtime on the notifications table, filtered to the current user via RLS. Radix DropdownMenu wraps the bell and the dropdown list. An unread count comes from a COUNT() subquery: SELECT COUNT(*) FROM notifications WHERE user_id = $1 AND is_read = false. When the component mounts, fetch recent notifications; subscribe to changes; upsert local state on new rows. When the user clicks a notification, call an UPDATE setting is_read = true; the Realtime listener updates the local state and re-renders the count.
// Notifications table schema
CREATE TABLE notifications (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID NOT NULL REFERENCES auth.users(id),
title TEXT NOT NULL,
message TEXT,
link TEXT,
is_read BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP DEFAULT now()
);
CREATE INDEX idx_notifications_user_created
ON notifications(user_id, created_at DESC);
-- RLS: users see only their own
CREATE POLICY "Users see own notifications"
ON notifications FOR SELECT
USING (auth.uid() = user_id);
CREATE POLICY "Users mark own as read"
ON notifications FOR UPDATE
USING (auth.uid() = user_id);
React Component: Bell + Dropdown
The component holds notifications (array) and unreadCount in state. On mount, fetch recent notifications and subscribe to Realtime. The bell icon shows the unread count as a red badge in the top-right corner. Radix DropdownMenu.Content renders a scrollable list; each notification is clickable. Click a notification → call the mark-read mutation → Realtime fires back the UPDATE event → local state updates → count decrements. Link href routes to the notification's context. The dropdown closes on blur; it re-opens on the next click.
import { useEffect, useState } from 'react';
import * as DropdownMenu from '@radix-ui/react-dropdown-menu';
import { supabase } from '../../lib/supabase';
export function NotificationsBell() {
const [notifications, setNotifications] = useState([]);
const [unreadCount, setUnreadCount] = useState(0);
const userId = (useAuth()).user?.id;
useEffect(() => {
// Fetch initial notifications
supabase
.from('notifications')
.select('*')
.eq('user_id', userId)
.order('created_at', { ascending: false })
.limit(10)
.then(({ data }) => setNotifications(data || []));
// Subscribe to changes
const channel = supabase
.channel(`notifications:user_${userId}`)
.on('postgres_changes',
{ event: '*', table: 'notifications', filter: `user_id=eq.${userId}` },
(payload) => {
const { eventType, new: newNotif, old: oldNotif } = payload;
if (eventType === 'INSERT') {
setNotifications((prev) => [newNotif, ...prev]);
} else if (eventType === 'UPDATE') {
setNotifications((prev) =>
prev.map((n) => (n.id === newNotif.id ? newNotif : n))
);
}
}
)
.subscribe();
// Update unread count
const unread = notifications.filter((n) => !n.is_read).length;
setUnreadCount(unread);
return () => channel.unsubscribe();
}, [userId]);
const handleMarkRead = async (notifId) => {
await supabase
.from('notifications')
.update({ is_read: true })
.eq('id', notifId);
};
return (
<DropdownMenu.Root>
<DropdownMenu.Trigger asChild>
<button className="relative">
🔔
{unreadCount > 0 && (
<span className="absolute top-0 right-0 bg-red-500 text-white text-xs rounded-full w-5 h-5 flex items-center justify-center">
{unreadCount}
</span>
)}
</button>
</DropdownMenu.Trigger>
<DropdownMenu.Content className="bg-white border rounded shadow-lg w-80 max-h-96 overflow-y-auto">
{notifications.length === 0 ? (
<div className="p-4 text-gray-500 text-sm">No notifications</div>
) : (
notifications.map((notif) => (
<DropdownMenu.Item
key={notif.id}
onSelect={() => handleMarkRead(notif.id)}
className={(`p-3 border-b cursor-pointer transition`, {
'bg-gray-50': notif.is_read,
'bg-blue-50': !notif.is_read,
})}
>
<a href={notif.link} className="block">
<div className="font-semibold text-sm">{notif.title}</div>
<div className="text-xs text-gray-600">{notif.message}</div>
</a>
</DropdownMenu.Item>
))
)}
</DropdownMenu.Content>
</DropdownMenu.Root>
);
}
Mark-Read Flow: Click → Mutation → Realtime → State
When a user clicks a notification, call supabase.from('notifications').update({ is_read: true }). The Realtime listener catches the UPDATE event and fires the callback. The callback finds the notification in local state and re-renders it as read (greyed out). The unread count subquery decrements automatically since is_read is now true. The notification stays in the dropdown for context; it just visually shifts to grey. No page reload. No flicker. Instant.
Unread Count Badge: Subquery Pattern
The bell displays unread count. Rather than subscribe to a separate unread_count table, compute it in-memory from the notifications array: const count = notifications.filter(n => !n.is_read).length. This avoids an extra Realtime subscription. When Realtime fires an UPDATE event, the array updates, and the count re-renders automatically. If you prefer server-side computation (e.g., for expensive counts with complex joins), create a VIEW called unread_notification_count and subscribe to it instead. Velocity X uses the client-side filter pattern for sub-100ms badge updates.
Quick FAQs
Does the bell auto-refresh? Only via Realtime push. When a new notification is inserted by a backend job, the Realtime listener catches it and updates your local state. No polling. What if the user doesn't click the notification? It stays unread forever unless auto-marked by the backend (e.g., after 7 days, a batch job marks stale notifications as read). How many notifications should the dropdown show? 10 is a sweet spot. Paginate older ones in a link to a full notifications page if needed. Should I send email AND in-app? Yes, for high-urgency events (critical approvals, large deals). But make email a toggle; many teams prefer bell-only and turn off email spam. What happens on disconnect? The dropdown still shows the last-fetched notifications. On reconnect, Realtime re-syncs. If a notification was marked read offline, the next sync merges it. How do I test this? Insert a test notification row manually; watch the bell update in real-time. Then click it; watch the count decrement.
The Verdict
In-app notifications are table stakes for any SaaS dashboard. A bell icon with an unread badge, a Radix dropdown, and Supabase Realtime push creates a notification UX that users actually notice and act on. No email. No polling. No stale dashboards. The architecture is straightforward: a table, RLS, a Realtime subscription, and a React component. The payoff is reps closing deals faster because they see the signal instantly. See it live in Velocity X's Realtime channels post. Ready to ship? Check our pricing tiers to get started.