Building a Real-Time Notification System with Supabase in 2 Hours
Goal: Build a complete notification system with:
- Auto-generated notifications for likes/comments
- Real-time delivery (no polling)
- Unread badge counter
- Mark as read functionality
- React UI component
Time: 2 hours from start to finish.
Let's build it.
The Database Schema
Step 1: Notifications Table
-- Create notifications table
create table public.notifications (
id uuid default gen_random_uuid() primary key,
user_id uuid references auth.users not null,
actor_id uuid references auth.users not null,
type text not null check (type in ('like', 'comment', 'mention', 'follow')),
post_id uuid references public.posts,
comment_id uuid references public.comments,
read boolean default false,
created_at timestamp with time zone default timezone('utc'::text, now()) not null
);
-- Index for fast queries
create index notifications_user_id_idx on public.notifications(user_id);
create index notifications_read_idx on public.notifications(read);
create index notifications_created_at_idx on public.notifications(created_at desc);Step 2: Row Level Security (RLS)
-- Enable RLS
alter table public.notifications enable row level security;
-- Users can only see their own notifications
create policy "Users can view own notifications"
on public.notifications for select
using (auth.uid() = user_id);
-- Users can mark their own notifications as read
create policy "Users can update own notifications"
on public.notifications for update
using (auth.uid() = user_id);The Database Triggers
Auto-Generate Notifications on Like
-- Trigger function for likes
create or replace function public.handle_new_like()
returns trigger as $$
begin
-- Don't notify if user likes their own post
if new.user_id != (select user_id from public.posts where id = new.post_id) then
insert into public.notifications (user_id, actor_id, type, post_id)
values (
(select user_id from public.posts where id = new.post_id),
new.user_id,
'like',
new.post_id
);
end if;
return new;
end;
$$ language plpgsql security definer;
-- Attach trigger to likes table
create trigger on_like_created
after insert on public.likes
for each row execute procedure public.handle_new_like();Explain Like I'm 3
Imagine you draw a picture and your friend puts a gold star sticker on it. Wouldn't you want to know? This is like a magic robot that watches for gold stars. Every time someone puts a star on your picture, the robot AUTOMATICALLY tells you "Hey! Someone liked your drawing!" You don't have to keep checking - the robot does it for you!
Explain Like You're My Boss
Database triggers execute server-side logic automatically on data changes, eliminating the need for application-layer notification generation. This handle_new_like() trigger fires on every row insert to the likes table, creating notification records atomically within the same transaction. The business logic (don't notify self-likes) is enforced at the database level, ensuring consistency even if multiple applications interact with the data.
Architecture Impact: Zero application code required for notification creation. Reduces bug surface area, ensures consistency, and scales automatically with database performance.
Explain Like You're My Girlfriend
You know how you always want me to text you when I get home safe? But sometimes I forget and you get worried? This is like if my phone automatically sent you "James got home!" the SECOND I walked in the door. I don't have to remember - it just happens! Database triggers are like that: someone likes a post? BOOM, notification created automatically. No forgetting, no bugs, no "sorry babe I was distracted." The database handles it so I can't mess it up. Which, let's be honest, is better for everyone. 😅💕
Auto-Generate Notifications on Comment
-- Trigger function for comments
create or replace function public.handle_new_comment()
returns trigger as $$
begin
-- Don't notify if user comments on their own post
if new.user_id != (select user_id from public.posts where id = new.post_id) then
insert into public.notifications (user_id, actor_id, type, post_id, comment_id)
values (
(select user_id from public.posts where id = new.post_id),
new.user_id,
'comment',
new.post_id,
new.id
);
end if;
return new;
end;
$$ language plpgsql security definer;
-- Attach trigger to comments table
create trigger on_comment_created
after insert on public.comments
for each row execute procedure public.handle_new_comment();Result: Notifications automatically created when users like or comment.
The React Hook
`useNotifications.ts`
import { useState, useEffect } from 'react';
import { useSupabaseClient, useUser } from '@supabase/auth-helpers-react';
interface Notification {
id: string;
type: 'like' | 'comment' | 'mention' | 'follow';
actor: {
id: string;
name: string;
avatar: string;
};
post_id?: string;
comment_id?: string;
read: boolean;
created_at: string;
}
export function useNotifications() {
const supabase = useSupabaseClient();
const user = useUser();
const [notifications, setNotifications] = useState<Notification[]>([]);
const [unreadCount, setUnreadCount] = useState(0);
const [loading, setLoading] = useState(true);
// Fetch notifications
useEffect(() => {
if (!user) return;
const fetchNotifications = async () => {
const { data, error } = await supabase
.from('notifications')
.select(`
*,
actor:actor_id (
id,
name,
avatar_url
)
`)
.eq('user_id', user.id)
.order('created_at', { ascending: false })
.limit(20);
if (data) {
setNotifications(data);
setUnreadCount(data.filter(n => !n.read).length);
}
setLoading(false);
};
fetchNotifications();
}, [user, supabase]);
// Real-time subscription
useEffect(() => {
if (!user) return;
const channel = supabase
.channel('notifications')
.on(
'postgres_changes',
{
event: 'INSERT',
schema: 'public',
table: 'notifications',
filter: `user_id=eq.${user.id}`,
},
(payload) => {
// New notification arrived!
setNotifications(prev => [payload.new as Notification, ...prev]);
setUnreadCount(prev => prev + 1);
// Optional: Show browser notification
if ('Notification' in window && Notification.permission === 'granted') {
new Notification('New notification', {
body: getNotificationText(payload.new),
icon: '/icon.png',
});
}
}
)
.on(
'postgres_changes',
{
event: 'UPDATE',
schema: 'public',
table: 'notifications',
filter: `user_id=eq.${user.id}`,
},
(payload) => {
// Notification marked as read
setNotifications(prev =>
prev.map(n => (n.id === payload.new.id ? payload.new as Notification : n))
);
if (payload.new.read) {
setUnreadCount(prev => Math.max(0, prev - 1));
}
}
)
.subscribe();
return () => {
supabase.removeChannel(channel);
};
}, [user, supabase]);
const markAsRead = async (notificationId: string) => {
const { error } = await supabase
.from('notifications')
.update({ read: true })
.eq('id', notificationId);
if (!error) {
setNotifications(prev =>
prev.map(n => (n.id === notificationId ? { ...n, read: true } : n))
);
setUnreadCount(prev => Math.max(0, prev - 1));
}
};
const markAllAsRead = async () => {
const { error } = await supabase
.from('notifications')
.update({ read: true })
.eq('user_id', user?.id)
.eq('read', false);
if (!error) {
setNotifications(prev => prev.map(n => ({ ...n, read: true })));
setUnreadCount(0);
}
};
return {
notifications,
unreadCount,
loading,
markAsRead,
markAllAsRead,
};
}
function getNotificationText(notification: any): string {
switch (notification.type) {
case 'like':
return 'Someone liked your post';
case 'comment':
return 'Someone commented on your post';
case 'mention':
return 'You were mentioned in a comment';
case 'follow':
return 'Someone started following you';
default:
return 'You have a new notification';
}
}The UI Component
`NotificationBell.tsx`
'use client';
import { Bell } from 'lucide-react';
import { useState } from 'react';
import { useNotifications } from '@/hooks/useNotifications';
import { motion, AnimatePresence } from 'framer-motion';
export function NotificationBell() {
const { notifications, unreadCount, markAsRead, markAllAsRead } = useNotifications();
const [isOpen, setIsOpen] = useState(false);
return (
<div className="relative">
{/* Bell Icon with Badge */}
<button
onClick={() => setIsOpen(!isOpen)}
className="relative p-2 rounded-lg hover:bg-gray-800 transition-colors"
>
<Bell className="w-5 h-5" />
{unreadCount > 0 && (
<motion.span
initial={{ scale: 0 }}
animate={{ scale: 1 }}
className="absolute -top-1 -right-1 w-5 h-5 bg-red-500 rounded-full text-xs font-bold flex items-center justify-center"
>
{unreadCount > 9 ? '9+' : unreadCount}
</motion.span>
)}
</button>
{/* Dropdown */}
<AnimatePresence>
{isOpen && (
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 10 }}
className="absolute right-0 mt-2 w-96 bg-gray-900 border border-gray-800 rounded-lg shadow-2xl z-50"
>
{/* Header */}
<div className="p-4 border-b border-gray-800 flex items-center justify-between">
<h3 className="font-bold">Notifications</h3>
{unreadCount > 0 && (
<button
onClick={markAllAsRead}
className="text-sm text-blue-400 hover:text-blue-300"
>
Mark all as read
</button>
)}
</div>
{/* Notifications List */}
<div className="max-h-96 overflow-y-auto">
{notifications.length === 0 ? (
<div className="p-8 text-center text-gray-500">
No notifications yet
</div>
) : (
notifications.map((notification) => (
<motion.div
key={notification.id}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className={`p-4 border-b border-gray-800 cursor-pointer transition-colors ${
notification.read ? 'bg-gray-900' : 'bg-blue-900/20'
} hover:bg-gray-800`}
onClick={() => {
if (!notification.read) {
markAsRead(notification.id);
}
// Navigate to post/comment
if (notification.post_id) {
window.location.href = `/posts/${notification.post_id}`;
}
}}
>
<div className="flex items-start gap-3">
{/* Avatar */}
<img
src={notification.actor.avatar || '/default-avatar.png'}
alt={notification.actor.name}
className="w-10 h-10 rounded-full"
/>
{/* Content */}
<div className="flex-1">
<p className="text-sm">
<span className="font-semibold">{notification.actor.name}</span>
{' '}
{notification.type === 'like' && 'liked your post'}
{notification.type === 'comment' && 'commented on your post'}
{notification.type === 'mention' && 'mentioned you'}
{notification.type === 'follow' && 'started following you'}
</p>
<p className="text-xs text-gray-500 mt-1">
{formatTimeAgo(notification.created_at)}
</p>
</div>
{/* Unread indicator */}
{!notification.read && (
<div className="w-2 h-2 bg-blue-500 rounded-full" />
)}
</div>
</motion.div>
))
)}
</div>
</motion.div>
)}
</AnimatePresence>
</div>
);
}
function formatTimeAgo(date: string): string {
const seconds = Math.floor((new Date().getTime() - new Date(date).getTime()) / 1000);
if (seconds < 60) return 'Just now';
if (seconds < 3600) return `${Math.floor(seconds / 60)}m ago`;
if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ago`;
return `${Math.floor(seconds / 86400)}d ago`;
}Usage
// In your layout or header
import { NotificationBell } from '@/components/NotificationBell';
export function Header() {
return (
<header>
<nav>
{/* ... other nav items ... */}
<NotificationBell />
</nav>
</header>
);
}Testing
# 1. Like a post (in browser/API)
POST /api/likes
{ "post_id": "...", "user_id": "..." }
# 2. Watch notification appear instantly (no refresh needed)
# 3. Bell badge updates in real-time
# 4. Click notification → marks as read → navigates to postPerformance Considerations
1. Limit Query Size
.limit(20) // Only fetch latest 20 notifications2. Debounce Read Updates
const debouncedMarkAsRead = useMemo(
() => debounce(markAsRead, 500),
[markAsRead]
);3. Cleanup Subscriptions
return () => {
supabase.removeChannel(channel); // Always cleanup!
};4. Optimize Queries
-- Create composite index for common queries
create index notifications_user_read_created_idx
on public.notifications(user_id, read, created_at desc);The Complete Flow
1. User A likes User B's post
↓
2. Database trigger fires (handle_new_like)
↓
3. Notification inserted into notifications table
↓
4. Supabase Realtime broadcasts INSERT event
↓
5. User B's browser receives real-time update
↓
6. React hook updates state
↓
7. UI updates: Bell badge shows unread count
↓
8. User B clicks notification
↓
9. Marked as read in database
↓
10. Real-time UPDATE event received
↓
11. Badge count decreasesAll of this happens in < 100ms.
The Results
- ✅ Real-time: No polling, instant delivery
- ✅ Automatic: Triggers handle notification creation
- ✅ Secure: RLS ensures users only see their own
- ✅ Performant: Indexed queries, limited results
- ✅ Complete: Unread badges, mark as read, navigation
Total build time: 2 hours
The Bottom Line
Supabase makes real-time notifications trivial:
- Database triggers auto-generate notifications
- Real-time subscriptions deliver instantly
- RLS handles security
- React hooks manage state
No Redis. No WebSockets. No complex infrastructure.
Just SQL + Supabase Realtime.
Building real-time features with Supabase? Questions about the implementation? Let me know!
James G. - AI Alchemist | Full-Stack Developer