Skip to main content
JG is here with you โœจ
Building a Real-Time Notification System with Supabase in 2 Hours
Back to Blog

Building a Real-Time Notification System with Supabase in 2 Hours

From database triggers to React UI with real-time subscriptions. Complete implementation of likes, comments, and notification bell with unread badges.

2025-11-05

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 post

Performance Considerations

1. Limit Query Size

.limit(20) // Only fetch latest 20 notifications

2. 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 decreases

All 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

Open to AI-Focused Roles

AI Sales โ€ข AI Strategy โ€ข AI Success โ€ข Creative Tech โ€ข Toronto / Remote

Let's connect โ†’
Terms of ServiceLicense AgreementPrivacy PolicyInstagram
Copyright ยฉ 2026 JMFG. All rights reserved.