^.^;
Back to Blog
Blog Post

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.

J
JG
Author
2025-11-05
Published
◆ ◆ ◆

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

END OF ARTICLE
J

About JG

Full-stack developer specializing in web performance, authentication systems, and developer experience. Passionate about sharing real-world debugging stories and optimization techniques.

Terms of ServiceLicense AgreementPrivacy Policy
Copyright © 2025 JMFG. All rights reserved.