Feature spec
IntermediateCategory
notifications-alerts
Build with AI
3–5 hours with Lovable or V0
Custom build
1 week custom dev
Running cost
$0/mo up to ~200 DAU · $25/mo Supabase Pro at 1K+ concurrent users
Works on
Everything it takes to ship Real Time Notifications — parts, prompts, and real costs.
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.
What users consider table stakes in 2026
- Notification bell icon with an unread count badge visible in the app header; count updates live as new notifications arrive without any page refresh
- New notifications appear instantly in the dropdown panel — within 1–2 seconds of the triggering event occurring server-side
- Mark as read individually (click a notification) or mark all as read with a single button
- Notification panel shows time-relative timestamps ('2 minutes ago', '1 hour ago') using date-fns or a similar utility
- Unread notifications are visually distinct from read ones (bold text, colored indicator, or background fill)
- Notifications persist across sessions and browser refreshes so users don't miss activity that happened while they were offline
- Clicking a notification navigates directly to the relevant content (the comment, the order, the mention)
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
UIA 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.
Note: Derive the unread count from the local notifications array in component state rather than a separate SELECT COUNT(*) query — keeping a single source of truth prevents the count from drifting out of sync with what is displayed in the panel.
Supabase Realtime Subscription
Servicesupabase.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.
Note: Must live inside a 'use client' component with a useEffect cleanup. Each re-render that creates a new channel without removing the old one registers a duplicate listener — this is the single most common real-time notification bug.
Notifications Table
DataA 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.
Note: The composite index is critical once users accumulate hundreds of notifications. Without it, the unread count query becomes a full table scan per user.
Notification Panel
UIA 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.
Note: Use optimistic UI for mark-as-read: update local state immediately before the Supabase update call resolves. If the update fails, revert the optimistic state change.
Notification Creator (Server Side)
BackendA 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.
Note: Batch inserts for fan-out notifications (e.g., all members of a workspace) should be done in chunks of 100 rows per INSERT to avoid hitting the Edge Function body size limit.
Read State Manager
DataOn 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.
Note: The 'mark all as read' query can be slow without the index on (user_id, is_read) because it scans all user rows to find unread ones. The composite index defined in the data model above makes this fast.
Flutter Realtime Listener
ServiceIn 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.
Note: Unlike the web channel subscription, supabase_flutter's stream() automatically cleans up on widget dispose. Still wrap it in a StreamSubscription variable and call cancel() in dispose() to be safe.
The 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.
1create table public.notifications (2 id uuid primary key default gen_random_uuid(),3 user_id uuid references auth.users(id) on delete cascade not null,4 type text not null check (type in ('system','comment','order','mention','alert')),5 title text not null,6 body text,7 action_url text,8 is_read bool not null default false,9 created_at timestamptz not null default now()10);1112alter table public.notifications enable row level security;1314create policy "Users view own notifications"15 on public.notifications for select16 using (auth.uid() = user_id);1718create policy "Users update own notifications"19 on public.notifications for update20 using (auth.uid() = user_id)21 with check (auth.uid() = user_id);2223create policy "Service role inserts notifications"24 on public.notifications for insert25 with check (auth.role() = 'service_role');2627create index notifications_user_unread_idx28 on public.notifications (user_id, is_read, created_at desc);2930create index notifications_user_recent_idx31 on public.notifications (user_id, created_at desc);Heads up: 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 it — pick your path
Each tool fits this feature differently. Switch tabs to compare fit, time, the exact steps, and a copy-paste prompt.
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.
Step by step
- 1Create a new Lovable project and connect Lovable Cloud to provision Supabase auth and the database
- 2Paste the prompt below — be explicit about 'Supabase Realtime channel subscription' to prevent Lovable from generating a polling fallback instead
- 3Run the SQL schema above in the Supabase SQL editor to create the notifications table with the correct indexes and RLS
- 4In Lovable Cloud tab → Secrets, add SUPABASE_SERVICE_KEY for the notification creator Edge Function to insert notifications with service_role privileges
- 5Publish 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
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 browserWhere this path bites
- 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
Third-party services you'll need
Real-time in-app notifications are almost entirely free. Supabase Realtime is included on all plans, and the open-source date-fns library handles timestamp formatting at no cost.
| Service | What it does | Free tier | Paid from |
|---|---|---|---|
| Supabase Realtime | WebSocket-based change listener on the notifications table; fires a callback the instant a new row is inserted for the logged-in user | Included on all plans; free tier supports up to 200 concurrent connections | Included in Supabase Pro $25/mo; Pro supports up to 500 concurrent connections |
| supabase_flutter package | Flutter SDK for Supabase Realtime subscriptions and database queries; powers the stream() listener in FlutterFlow and Flutter custom builds | Open-source, zero cost | Free |
| date-fns | JavaScript library for time-relative formatting ('2 minutes ago'); formatDistanceToNow() is the standard function for notification timestamps | Open-source, zero cost | Free |
Swipe the table sideways to see pricing.
What it costs to run
Drag through the tiers to see how your monthly bill scales with users — no surprises later.
Estimated monthly running cost
Supabase free tier covers 200 concurrent Realtime connections and notifications table storage. At 100 DAU, concurrent connection count is well within free limits.
Estimates use verified 2026 pricing and assume typical usage per user. Your real bill depends on activity, storage, and third-party plans.
What breaks when AI tools build this
The failures people actually hit on their first build — the symptom, why it happens, and the exact fix.
Bell icon doesn't update in real time — user must refresh to see new notifications
Symptom: 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
Symptom: 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
Symptom: 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
Symptom: 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
When You Need Custom Development
Supabase Realtime with Lovable or V0 covers the standard notification bell pattern. These requirements exceed what that pattern handles:
- Notification fan-out to thousands of users simultaneously — a @channel mention in a 10K-member workspace requires a queue-based architecture (Supabase pgmq, Upstash Redis) to avoid a single INSERT transaction timing out
- Delivery SLA with fallback cascade: if the user is not on the app within 5 minutes, escalate to push notification, then to email — requires coordinating three delivery channels with state tracking per notification
- Per-notification analytics with attribution: tracking not just opens but downstream conversions (did the user complete the action that triggered the notification) for product analytics
- Event-driven notifications triggered by third-party webhooks (Stripe payment completed, GitHub PR merged, Twilio call ended) that must arrive in under 2 seconds and be correlated with the user's active session
RapidDev builds these features for production
Real auth, database, and payments — fixed price, delivered in weeks. You own 100% of the code.
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.
Need this feature production-ready?
RapidDev builds real time notifications into real apps — auth, database, payments — at $13K–$25K.
Book a free consultation30-min call. No commitment.