# How to Add Real Time Notifications to Your App (Copy-Paste Prompts Included)

- Tool: App Features
- Last updated: July 2026

## TL;DR

Real-time in-app notifications need three pieces: a Supabase Realtime subscription on the notifications table filtered to the logged-in user, a notification bell icon with an unread count badge that updates live, and server-side notification creation (Edge Function or Server Action) that inserts rows into the table. With Lovable or V0 you can ship a working notification bell in 3–5 hours for $0/month at small scale — Supabase Realtime is included in the free tier up to 200 concurrent connections. The subscription must live in a client component; placing it in a Server Component is the single most common first-build failure.

## What Real-Time Notifications Actually Are

Real-time in-app notifications are the notification bell in the header that shows an unread count badge and a dropdown of recent activity — new comments, order updates, mentions, replies — without the user needing to refresh the page. They work by keeping an open WebSocket connection between the browser and Supabase Realtime, which fires a callback the instant a new row is inserted into the notifications table for that user. Unlike push notifications (which go to the OS when the app is closed), real-time notifications are for users who are actively using the app. The two systems complement each other: Realtime for active sessions, push for closed apps.

## Anatomy of the Feature

Seven components across four layers. Lovable generates the Realtime subscription and the bell correctly when prompted precisely. The most common failure is the subscription landing in the wrong component type or creating duplicate listeners on re-render.

- **Notification Bell Icon** (ui): A bell icon in the app header with a shadcn/ui Badge overlay showing the unread count. The count is sourced from the Realtime subscription callback — it increments when new notifications arrive and decrements when notifications are marked as read. Flutter equivalent uses the badges package or an App State variable for the count.
- **Supabase Realtime Subscription** (service): supabase.channel('notifications').on('postgres_changes', { event: 'INSERT', schema: 'public', table: 'notifications', filter: `user_id=eq.${userId}` }, callback).subscribe(). The callback receives the new notification row and prepends it to local component state, updating the bell count and panel list in real time.
- **Notifications Table** (data): A Supabase table with columns: id (uuid), user_id (uuid, references auth.users), type (text enum: system/comment/order/mention/alert), title (text), body (text), action_url (text), is_read (bool default false), created_at (timestamptz). Indexed on (user_id, is_read, created_at DESC) for fast unread count queries and ordered panel fetches.
- **Notification Panel** (ui): A dropdown or sheet component showing the last 20 notifications. Each item shows a type-specific icon, title, time-relative label (date-fns formatDistanceToNow), and unread/read state. Tapping an item sets is_read = true and navigates to action_url. A 'Mark all as read' button updates all unread rows for the user.
- **Notification Creator (Server Side)** (backend): A Supabase Edge Function or Next.js Server Action called by other features when triggering events occur (new message received, order status changed, comment reply posted, alert triggered). Inserts a row into the notifications table for the target user_id. For group notifications (e.g., @channel mention), accepts an array of user_ids and performs a batch insert.
- **Read State Manager** (data): On individual notification click: supabase.from('notifications').update({ is_read: true }).eq('id', notificationId). On 'mark all as read': .update({ is_read: true }).eq('user_id', userId).eq('is_read', false). Both calls run with an optimistic UI update in local state before awaiting the server response.
- **Flutter Realtime Listener** (service): In Flutter, supabase_flutter's stream() method on the notifications table drives a StreamBuilder for live UI updates: supabase.from('notifications').stream(primaryKey: ['id']).eq('user_id', userId).order('created_at', ascending: false).limit(20).listen(callback). The App State unread count variable is updated in the callback.

## Data model

One table handles all notification types. Run this in the Supabase SQL editor. The composite index is not optional — it prevents the unread count from becoming a full-table scan as notification history grows.

```sql
create table public.notifications (
  id uuid primary key default gen_random_uuid(),
  user_id uuid references auth.users(id) on delete cascade not null,
  type text not null check (type in ('system','comment','order','mention','alert')),
  title text not null,
  body text,
  action_url text,
  is_read bool not null default false,
  created_at timestamptz not null default now()
);

alter table public.notifications enable row level security;

create policy "Users view 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)
  with check (auth.uid() = user_id);

create policy "Service role inserts notifications"
  on public.notifications for insert
  with check (auth.role() = 'service_role');

create index notifications_user_unread_idx
  on public.notifications (user_id, is_read, created_at desc);

create index notifications_user_recent_idx
  on public.notifications (user_id, created_at desc);
```

The notifications table uses service_role for INSERT so that only server-side Edge Functions or Server Actions can create notifications — users cannot insert notifications for other users even if they bypass the client. Auto-delete old notifications with a pg_cron job (Supabase Pro): SELECT cron.schedule('cleanup-old-notifications', '0 2 * * *', $$DELETE FROM public.notifications WHERE created_at < NOW() - INTERVAL '90 days'$$);

## Build paths

### Lovable — fit 5/10, 3–4 hours

Best path: Lovable's Supabase integration has first-class Realtime support and the AI wires the postgres_changes subscription to a bell component correctly when the prompt is specific. The Shared Supabase Connector handles auth and RLS automatically.

1. Create a new Lovable project and connect Lovable Cloud to provision Supabase auth and the database
2. Paste the prompt below — be explicit about 'Supabase Realtime channel subscription' to prevent Lovable from generating a polling fallback instead
3. Run the SQL schema above in the Supabase SQL editor to create the notifications table with the correct indexes and RLS
4. In Lovable Cloud tab → Secrets, add SUPABASE_SERVICE_KEY for the notification creator Edge Function to insert notifications with service_role privileges
5. Publish and test the Realtime subscription on the published URL — open two browser tabs as the same user, trigger a notification from one, and verify the bell updates in the other within 2 seconds

Starter prompt:

```
Build a real-time in-app notification bell feature using Supabase Realtime.

Database table already exists: notifications (id uuid primary key, user_id uuid references auth.users, type text check in (system, comment, order, mention, alert), title text, body text, action_url text, is_read bool default false, created_at timestamptz). RLS: users SELECT and UPDATE own rows; service_role INSERT.

Build these components:

1. NotificationBell: a 'use client' React component that goes in the app header. On mount, fetch the last 20 notifications from Supabase ordered by created_at desc. Subscribe to a Supabase Realtime channel using supabase.channel('notifications-{userId}').on('postgres_changes', { event: 'INSERT', schema: 'public', table: 'notifications', filter: `user_id=eq.${userId}` }, (payload) => { prepend payload.new to the notifications list in state; increment unread count }). Subscribe() the channel. In useEffect cleanup, call supabase.removeChannel(channel) — this is critical to prevent duplicate subscriptions. Show a bell icon with a shadcn/ui Badge overlay showing the unread count (count of notifications where is_read = false). Badge should not render when count is 0.

2. NotificationPanel: a shadcn/ui Popover or Sheet triggered by the bell. List the last 20 notifications. Each item shows: a type icon (use Lucide React icons: Bell for system, MessageSquare for comment, ShoppingBag for order, AtSign for mention, AlertCircle for alert), title, formatDistanceToNow(created_at) from date-fns, and a visual difference between is_read false (bold, slight background) and is_read true (normal weight). Clicking an item: optimistically set is_read = true in local state, await supabase.update({ is_read: true }).eq('id', id), then navigate to action_url using the router. Add a 'Mark all as read' button that calls supabase.update({ is_read: true }).eq('user_id', userId).eq('is_read', false) and optimistically marks all local items as read.

3. NotificationCreator Edge Function (named 'create-notification'): accepts { user_id, type, title, body, action_url } via POST with a service role auth header; validates type is in the allowed enum; inserts into notifications table using the service key. This is called by other features (order updates, comment replies) to create notifications.

Edge cases:
- If user has 500+ notifications, paginate: load first 20, show 'Load more' button that fetches the next 20
- Auto-delete notifications older than 90 days via a pg_cron cleanup job
- Subscription cleanup on component unmount is mandatory — duplicate channels cause duplicate event callbacks
- Use a stable channel name including userId: 'notifications-{userId}' to prevent channel collisions between users sharing the same browser
```

Limitations:

- Lovable may generate polling (setInterval with a Supabase SELECT) on first pass if the prompt doesn't specify 'Supabase Realtime channel subscription' — regenerate with the explicit term if you see a polling implementation
- The Realtime subscription created without cleanup on component unmount causes duplicate callbacks on every navigation — always verify the useEffect cleanup in the generated code
- Supabase Realtime free tier supports 200 concurrent connections — plan for Supabase Pro ($25/mo) once DAU exceeds ~150 concurrent users

### V0 — fit 4/10, 3–5 hours

V0 generates a clean Next.js notification bell with shadcn/ui Popover and the Supabase Realtime client-side subscription. The subscription must be in a 'use client' component — V0 sometimes wraps it in a Server Component which causes a runtime error.

1. Prompt V0 with the spec below; explicitly specify that the Realtime subscription must go in a 'use client' component
2. In the V0 Vars panel, add NEXT_PUBLIC_SUPABASE_URL and NEXT_PUBLIC_SUPABASE_ANON_KEY for the client-side Supabase connection
3. Run the SQL schema above in the Supabase SQL editor
4. Add SUPABASE_SERVICE_KEY to Vercel environment variables (not NEXT_PUBLIC) for the Server Action that creates notifications
5. Deploy to Vercel and test the Realtime subscription: open two browser tabs, log in as the same user, POST to the create-notification API from one tab, and verify the bell updates in the other

Starter prompt:

```
Build a real-time notification bell in Next.js App Router using Supabase Realtime and shadcn/ui.

Database table: notifications (id uuid, user_id uuid references auth.users, type text check in (system, comment, order, mention, alert), title text, body text, action_url text, is_read bool default false, created_at timestamptz). RLS: users select/update own rows; service_role insert.

Components:

1. NotificationBell ('use client'): place in the root layout header next to the user menu. On mount: fetch last 20 notifications via supabase.from('notifications').select().eq('user_id', session.user.id).order('created_at', { ascending: false }).limit(20). Subscribe to Supabase Realtime: const channel = supabase.channel(`notifications-${userId}`).on('postgres_changes', { event: 'INSERT', schema: 'public', table: 'notifications', filter: `user_id=eq.${userId}` }, (payload) => prepend payload.new to notifications state).subscribe(). Return () => supabase.removeChannel(channel) in useEffect cleanup — this is mandatory. Show Bell icon from Lucide React; overlay a Badge from shadcn/ui with the count of unread items; hide Badge when count is 0.

2. NotificationPanel (client): shadcn/ui Popover triggered by NotificationBell. List notifications. Each row: Lucide icon by type (Bell/MessageSquare/ShoppingBag/AtSign/AlertCircle), bold title if unread, formatDistanceToNow(new Date(notification.created_at), { addSuffix: true }) from date-fns, subtle unread background. Click: optimistic is_read = true update in state, await supabase.from('notifications').update({ is_read: true }).eq('id', id), push(action_url). 'Mark all as read' button: optimistic full update, then supabase.from('notifications').update({ is_read: true }).eq('user_id', userId).eq('is_read', false). Show 'Load more' after 20 items.

3. createNotification Server Action: accepts { userId, type, title, body, actionUrl }; uses SUPABASE_SERVICE_KEY (never NEXT_PUBLIC) to insert via service role; validate type against the allowed enum before insert.

Do NOT put the Realtime subscription in a Server Component — it will throw 'cannot use Supabase Realtime in a server context'.
```

Limitations:

- V0 sometimes wraps the Realtime subscription in a Server Component on first generation — check that the notification bell component has 'use client' at the top; if not, add a regeneration prompt: 'Move the Realtime subscription and bell icon into a 'use client' component'
- Supabase Realtime subscription in Next.js App Router requires careful client-component isolation; the supabase client instance must be created inside the 'use client' component, not imported from a shared server client
- The Popover position in the header may need manual adjustment for mobile viewport widths — V0 positions Popovers for desktop by default

### Flutterflow — fit 4/10, 4–5 hours

Supabase Realtime via supabase_flutter's stream() method drives a Flutter StreamBuilder for live notification updates. FlutterFlow supports Custom Widgets for the notification panel and App State for the unread count badge.

1. Add the supabase_flutter package in FlutterFlow Settings → Pub Dependencies; connect your Supabase project in Settings → Supabase
2. Create an App State variable named unreadNotificationCount (integer, default 0) that will drive the badge on the bell icon
3. Add a Custom Widget named NotificationBell that uses supabase_flutter's stream() to subscribe to the notifications table filtered by userId: supabase.from('notifications').stream(primaryKey: ['id']).eq('user_id', currentUser!.uid).order('created_at', ascending: false).limit(20).listen((data) { ... }); update the App State variable in the listen callback
4. Build the notification panel as a bottom sheet page; use a Supabase Query with filters (user_id eq current user, order by created_at desc, limit 20) as the data source for the list
5. Add a Custom Dispose Action to the page or widget that hosts the Realtime stream to cancel the StreamSubscription when the page is popped

Limitations:

- FlutterFlow's visual builder cannot set up Supabase Realtime channel subscriptions natively — a Custom Action or Custom Widget is required for the subscription itself
- Subscription cleanup on page navigation must be handled in a Custom Dispose Action; without it, multiple subscriptions accumulate as users navigate through the app
- App State updates from a background stream trigger a full widget tree rebuild — scope the App State variable carefully to avoid rebuilding unrelated widgets on every notification

### Custom — fit 3/10, 1 week

Custom development is warranted for notification fan-out to thousands of users simultaneously, delivery SLA guarantees with cascade fallback, or event-driven architectures where notifications are triggered by third-party webhooks (Stripe, GitHub, Twilio).

1. Queue-based fan-out for group notifications: use Supabase pgmq (or Upstash Redis) to enqueue notification jobs; a worker processes the queue and inserts rows in batches rather than trying to insert 10K rows in a single transaction
2. Delivery cascade: Supabase Realtime for active users → push notification for inactive users → email digest for users who haven't opened the app in 7 days — each tier checked before falling back
3. Third-party webhook integration: Stripe payment_intent.succeeded → Edge Function → insert notification row → Realtime delivers instantly to the user who is on the payment confirmation page
4. Per-notification analytics: track notification_impressions (appeared in panel), notification_opens (clicked), and downstream_conversions (completed an action within 5 minutes of clicking)

Limitations:

- Queue-based fan-out adds architectural complexity — Supabase pgmq is still relatively new and has fewer community examples than established queue systems
- Custom delivery cascade requires coordinating three separate delivery systems (Realtime, FCM, email) with fallback logic that is difficult to test comprehensively

## Gotchas

- **Bell icon doesn't update in real time — user must refresh to see new notifications** — The Supabase Realtime subscription was placed in a Server Component instead of a client component, or the channel filter doesn't match the logged-in user's UUID. In Next.js App Router, Server Components cannot maintain WebSocket connections — the subscription silently never registers. If the filter string uses a variable that hasn't been resolved yet (e.g., userId is undefined at subscription creation time), the subscription registers but never receives any events. Fix: Move the Realtime subscription into a component marked 'use client'. Pass user_id from the server-side session into the client component as a prop — do not call auth.getUser() inside the 'use client' component because the session may not be available. Verify the filter string at subscription time: console.log the filter before calling subscribe() to confirm user_id=eq.{actual-uuid} matches a real UUID, not undefined or null.
- **The same notification appears twice in the panel** — Each time the component re-renders (route navigation, state update, parent re-render), a new channel is registered with Supabase Realtime without removing the previous one. After three navigations away and back to a page containing the NotificationBell, three separate channel listeners fire on every INSERT — resulting in the new notification being prepended to local state three times. Fix: Create the channel inside a useEffect with an empty dependency array ([]) so it runs only once on mount. Return a cleanup function from the useEffect: return () => supabase.removeChannel(channel). This ensures the channel is removed when the component unmounts and a fresh one is created when it mounts again. In the generated code, verify the cleanup function exists — AI tools frequently omit it.
- **Unread count shows wrong number after marking notifications as read** — The mark-as-read update call to Supabase is awaited, but the local count variable is recalculated from a cached array that still includes the just-marked notification as unread. Alternatively, the Realtime subscription fires an UPDATE event for the is_read change, which the callback interprets as a new notification and increments the count rather than leaving it unchanged. Fix: Derive the unread count by filtering the local notifications array directly: const unreadCount = notifications.filter(n => !n.is_read).length. After the mark-as-read update, update the is_read field on the matching item in the local array using map(). This keeps the count in sync with the displayed items without a separate counter that can drift. For the Realtime subscription, only listen for INSERT events — UPDATE events from is_read changes don't need to trigger a refetch.
- **Flutter app stops receiving notifications after the phone locks for 10+ minutes** — iOS and Android apply background process restrictions that close WebSocket connections when the app is backgrounded for an extended period. The supabase_flutter Realtime channel becomes inactive while the phone is locked. When the user unlocks and foregrounds the app, the channel is technically still subscribed but no longer receives new events — new notifications inserted while the phone was locked appear only when the user manually pulls to refresh. Fix: Implement a reconnection strategy in the supabase_flutter client configuration. More reliably, listen to the AppLifecycleState.resumed event in your Flutter app: when the app comes to the foreground, cancel the existing stream subscription and create a new one to force a fresh WebSocket connection. Combine this with FCM push notifications (the push-notifications feature) to wake the app and trigger re-subscription when notifications arrive while the app is backgrounded.

## Best practices

- Put the Realtime subscription in a dedicated NotificationProvider component at the root of your app tree — this ensures a single subscription per user session instead of one per page that mounts the bell
- Always include the useEffect cleanup function that calls supabase.removeChannel(channel) — omitting it is the single most common real-time notification bug and causes exponential duplication of events
- Use a stable channel name that includes the user ID ('notifications-{userId}') to prevent channel collisions in multi-user testing scenarios
- Derive the unread count by filtering the local notifications array, not with a separate counter variable — a single source of truth prevents count/display drift
- Insert notifications server-side with service_role only — never expose a public INSERT endpoint that would let any authenticated user create notifications for other users
- Add a cleanup pg_cron job to delete notifications older than 90 days (Supabase Pro) — a user who stays active for years accumulates thousands of notification rows that slow every query
- Type your notification types as a string enum in the database CHECK constraint — adding an unknown type silently fails at the DB level, which is much better than the UI crashing trying to render an icon for an unexpected type

## Frequently asked questions

### What's the difference between real-time notifications and push notifications?

Real-time notifications are delivered inside the app via a WebSocket connection while the user is actively using it — the bell icon updates live without a page reload. Push notifications are delivered by the operating system to the device's notification center when the app is closed. They are complementary: real-time for active users, push for inactive users. Most apps use both: Supabase Realtime fires for whoever is online, and a separate push send step wakes users who aren't.

### How many concurrent users can Supabase Realtime handle?

The Supabase free tier supports 200 concurrent Realtime connections. Supabase Pro ($25/mo) supports 500 concurrent connections. Concurrent connections count per open browser tab, not per user — one user with three tabs open uses three connection slots. Above 500 concurrent connections, you need Supabase compute add-ons or a dedicated Realtime configuration. For extremely high connection counts (10K+), consider Ably or Pusher as dedicated Realtime services.

### Do real-time notifications work on mobile browsers?

Yes — Supabase Realtime uses WebSocket connections which work in all modern mobile browsers (iOS Safari, Chrome Android). The limitation is that the connection closes when the browser is backgrounded on mobile, so new notifications won't appear until the user returns to the tab. For notifications that must arrive when the browser is closed, add FCM push notifications as a companion delivery channel.

### How do I notify multiple users at once, like a group chat mention?

Insert one notification row per recipient user_id. For a group with 50 members, that's 50 INSERT statements or one batch INSERT with 50 rows — Supabase handles this efficiently. For groups with thousands of members, use a queue-based architecture: insert a fan-out job into Supabase pgmq or Upstash Redis, then a worker processes it and inserts notification rows in batches of 100. The Realtime subscription on each connected user's browser automatically picks up their row the moment it's inserted.

### What happens to notifications when the user is offline?

Notifications are stored in the notifications table permanently (until cleaned up by your pg_cron job). When the user comes back online and opens the app, the initial fetch on component mount loads the last 20 notifications from the database — they see everything they missed. The Realtime subscription is for real-time delivery of new notifications while the user is active; the database is the source of truth for persistent history.

### How do I prevent notification spam from high-activity feeds?

Implement notification batching at the creator layer: instead of inserting a notification row for every single comment in a thread, batch them on a 5-minute window — one notification per thread per 5 minutes that says '3 new replies to your post'. Store a last_notified_at per (user_id, entity_id) pair in a notification_digests table; the creator checks this before inserting to decide whether to send or wait for the batch window to close.

### Can I add sounds to in-app notifications?

Yes. When a new notification arrives in the Realtime callback, play a brief audio file using the Web Audio API: const audio = new Audio('/notification-sound.mp3'); audio.play(). Add a user preference toggle in notification settings to enable or disable sound. Respect the user's system volume and check if the page has focus before playing (document.hasFocus()) — playing notification sounds when the user is on a different tab is considered intrusive.

### How long should I keep old notifications in the database?

90 days is the standard default — long enough to cover any practical 'I missed something' scenario, short enough to keep the notifications table lean. Set this as a pg_cron cleanup job on Supabase Pro that runs nightly at 02:00 UTC: DELETE FROM notifications WHERE created_at < NOW() - INTERVAL '90 days'. For compliance-sensitive apps (fintech, healthcare), you may need to archive rather than delete — copy rows to a cold-storage table or external data warehouse before deleting from the active table.

---

Source: https://www.rapidevelopers.com/app-features/real-time-notifications
© RapidDev — https://www.rapidevelopers.com/app-features/real-time-notifications
