This guide walks you through building a fully functional real-time in-app notification system using Supabase Realtime and React. You will have a working system — from database schema to UI bell icon — in approximately 90 minutes. Tested on Supabase 2.x, React 19, and Node 20+.

What You'll Build

  • A notifications table in Supabase with row-level security enabled
  • A Supabase Realtime channel subscription that pushes new notifications to connected clients instantly
  • A React notification bell component with an unread badge count
  • A mark-as-read flow that updates state optimistically and syncs to the database
  • A reusable useNotifications hook you can drop into any React project

Prerequisites

  • A Supabase project (free tier is sufficient — sign up at supabase.com)
  • A React 18+ or React 19 app (Next.js 14+ or Vite both work)
  • Node 20+ and pnpm 9 (or npm 10)
  • Basic familiarity with SQL and React hooks
  • The Supabase CLI installed: npm install -g supabase

Step 1: Create the Notifications Table

Why does this step matter?

Your schema drives everything. Getting the structure right now prevents painful migrations later. Supabase Realtime works by listening to Postgres changes, so the table design directly controls what the client receives.

Open the Supabase SQL Editor and run the following:

create table public.notifications (
  id uuid primary key default gen_random_uuid(),
  user_id uuid not null references auth.users(id) on delete cascade,
  title text not null,
  body text,
  type text not null default 'info',
  is_read boolean not null default false,
  created_at timestamptz not null default now()
);

-- Index for fast per-user queries
create index notifications_user_id_idx on public.notifications(user_id);

-- Enable Row Level Security
alter table public.notifications enable row level security;

create policy "Users see own notifications"
  on public.notifications for select
  using (auth.uid() = user_id);

create policy "Users update own notifications"
  on public.notifications for update
  using (auth.uid() = user_id);

Expected result: The table appears in your Supabase Table Editor with RLS enabled and two policies listed.

Common pitfall: Forgetting enable row level security means every authenticated user can read every row. Always enable RLS before exposing a table to the client.

Step 2: Enable Supabase Realtime on the Table

What if Realtime is not receiving events?

Supabase Realtime requires the table to be added to the supabase_realtime publication. By default, new tables are excluded.

Run this in the SQL Editor:

alter publication supabase_realtime add table public.notifications;

Then go to Database → Replication in your Supabase dashboard and confirm notifications is listed under the publication.

Expected result: The table shows as replicated. Without this step, your React hook will connect but never receive any events — a frustrating silent failure.

Step 3: Install the Supabase Client in Your React App

Install the official client library:

pnpm add @supabase/supabase-js

Create a shared client instance at src/lib/supabase.ts:

import { createClient } from '@supabase/supabase-js';

const supabaseUrl = import.meta.env.VITE_SUPABASE_URL as string;
const supabaseAnonKey = import.meta.env.VITE_SUPABASE_ANON_KEY as string;

export const supabase = createClient(supabaseUrl, supabaseAnonKey);

Add your keys to .env.local. Find them in Project Settings → API inside your Supabase dashboard:

VITE_SUPABASE_URL=https://your-project-ref.supabase.co
VITE_SUPABASE_ANON_KEY=your-anon-key

Common pitfall: Never use the service_role key on the client. It bypasses RLS entirely and exposes your entire database to anyone who inspects your bundle.

Step 4: Build the useNotifications Hook

Why a custom hook instead of inline logic?

Isolating subscription logic in a hook makes it reusable across your app. Any component — a header bell, a mobile drawer, or a toast system — can call the same hook without duplicating subscription code.

Create src/hooks/useNotifications.ts:

import { useEffect, useState, useCallback } from 'react';
import { supabase } from '../lib/supabase';

export type Notification = {
  id: string;
  title: string;
  body: string | null;
  type: string;
  is_read: boolean;
  created_at: string;
};

export function useNotifications(userId: string) {
  const [notifications, setNotifications] = useState([]);
  const [loading, setLoading] = useState(true);

  // Fetch existing notifications on mount
  useEffect(() => {
    if (!userId) return;

    supabase
      .from('notifications')
      .select('*')
      .eq('user_id', userId)
      .order('created_at', { ascending: false })
      .limit(50)
      .then(({ data }) => {
        setNotifications(data ?? []);
        setLoading(false);
      });
  }, [userId]);

  // Subscribe to new notifications in real time
  useEffect(() => {
    if (!userId) return;

    const channel = supabase
      .channel(`notifications:${userId}`)
      .on(
        'postgres_changes',
        {
          event: 'INSERT',
          schema: 'public',
          table: 'notifications',
          filter: `user_id=eq.${userId}`,
        },
        (payload) => {
          setNotifications((prev) => [
            payload.new as Notification,
            ...prev,
          ]);
        }
      )
      .subscribe();

    return () => {
      supabase.removeChannel(channel);
    };
  }, [userId]);

  const markAsRead = useCallback(async (notificationId: string) => {
    // Optimistic update
    setNotifications((prev) =>
      prev.map((n) =>
        n.id === notificationId ? { ...n, is_read: true } : n
      )
    );

    await supabase
      .from('notifications')
      .update({ is_read: true })
      .eq('id', notificationId);
  }, []);

  const unreadCount = notifications.filter((n) => !n.is_read).length;

  return { notifications, loading, unreadCount, markAsRead };
}

Expected result: Calling this hook from any component gives you a live-updating list, an unread count, and a mark-as-read function.

Pro tip: The filter parameter on postgres_changes is server-side. It means Supabase only sends events relevant to this specific user, not every row change. This reduces bandwidth significantly on high-volume tables — teams at Lenka Studio have seen this pattern reduce unnecessary client payloads by roughly 80% compared to unfiltered subscriptions.

Step 5: Build the Notification Bell Component

Create src/components/NotificationBell.tsx:

import { useState } from 'react';
import { useNotifications } from '../hooks/useNotifications';

type Props = { userId: string };

export function NotificationBell({ userId }: Props) {
  const { notifications, unreadCount, markAsRead } = useNotifications(userId);
  const [open, setOpen] = useState(false);

  return (
    
{open && (
    {notifications.length === 0 && (
  • No notifications yet.
  • )} {notifications.map((n) => (
  • markAsRead(n.id)} > {n.title} {n.body &&

    {n.body}

    }
  • ))}
)}
); }

When should you skip the dropdown and use toasts instead?

Use a dropdown list for persistent, actionable notifications (order updates, approvals). Use a toast library like Sonner for ephemeral alerts. You can trigger toasts inside the postgres_changes callback in the hook without changing anything else.

Step 6: Test the Full Flow End to End

Open your app in a browser tab while signed in. Then, in the Supabase Table Editor, manually insert a row:

insert into public.notifications (user_id, title, body, type)
values (
  'your-user-uuid-here',
  'Welcome!',
  'Your account is ready.',
  'info'
);

Expected result: Within 200–400ms, the bell badge increments and the new notification appears in the dropdown — without any page refresh. Supabase Realtime latency in Sydney, Singapore, and US East data centres typically sits under 300ms on the free tier as of mid-2026.

Common pitfall: If the event never arrives, check the browser console for a Supabase channel status of CHANNEL_ERROR. This usually means the anon key lacks the realtime scope, which can be granted in Project Settings → API → Realtime.

Step 7: Send Notifications from Your Backend

Real apps trigger notifications from server-side events — a payment completing, a form submission, or a new message. Use Supabase's service role key on the server only: