Build a multi-channel notification system in Lovable that delivers in-app, email, SMS, and push notifications from a single fan-out pipeline. Users control their preferences per channel and per event type. Supabase Realtime powers instant in-app delivery while Edge Functions call Resend and Twilio for email and SMS — all without a separate server.
What you're building
A notification system has two distinct layers. The delivery layer handles sending: when an event occurs in your app (a new comment, a payment, a mention), a fan-out function reads the recipient's preferences, filters to enabled channels, and dispatches one task per channel in parallel. The presentation layer handles display: in-app notifications live in a Supabase table and stream to the user's browser via a Realtime postgres_changes subscription on INSERT.
The fan-out pattern works like this: your app calls a single Supabase RPC function notify_user(user_id, event_type, variables). That function looks up the user's preferences for that event type, then for each enabled channel it inserts a row into notification_queue. A separate Edge Function processes the queue — creating the in-app notification row, calling Resend's API for email, and calling Twilio's Messages API for SMS — all in parallel using Promise.all.
Notification templates live in a templates table with a body_template field containing {{variable}} placeholders. The dispatcher performs simple string replacement before sending. This keeps your notification content manageable without hardcoding strings in Edge Functions.
Final result
A complete multi-channel notification system with user preferences, Realtime in-app delivery, email and SMS dispatch, and a preferences settings page.
Tech stack
Prerequisites
- Lovable Pro account with Edge Functions access
- Supabase project with the URL and service role key saved to Cloud tab → Secrets
- Resend account with an API key (free tier handles 3,000 emails/month)
- Twilio account with a phone number, Account SID, and Auth Token
- A working Lovable app with Supabase Auth so you have real user IDs
Build steps
Create the notification schema in Supabase
Set up the four tables that power the system: notifications (the inbox), notification_preferences (per-user settings), notification_templates (content), and notification_queue (pending dispatches). Prompt Lovable to generate migrations and TypeScript types for all four.
1Create a multi-channel notification system schema. Tables:231. notifications: id, user_id (references auth.users), type (text), channel (text: in_app|email|sms|push), title (text), body (text), metadata (jsonb default '{}'), is_read (bool default false), created_at452. notification_preferences: id, user_id (references auth.users), event_type (text), in_app_enabled (bool default true), email_enabled (bool default true), sms_enabled (bool default false), push_enabled (bool default false), UNIQUE(user_id, event_type)673. notification_templates: id, event_type (text unique), subject_template (text), body_template (text), sms_template (text), created_at894. notification_queue: id, user_id, event_type, variables (jsonb), status (text default 'pending': pending|processing|done|failed), error_text (text), created_at, processed_at1011RLS policies:12- notifications: users can SELECT and UPDATE (is_read) their own rows only13- notification_preferences: users can SELECT, INSERT, UPDATE their own rows14- notification_templates: public SELECT, service role only for mutations15- notification_queue: service role only1617Create an index on notifications(user_id, is_read, created_at DESC) for fast unread counts.Pro tip: Ask Lovable to also insert seed rows into notification_templates for two common event types: 'new_comment' and 'payment_received'. This lets you test the full pipeline immediately without manually inserting template rows.
Expected result: All four tables are created with RLS. TypeScript types are generated. The preview shows the app shell with no errors.
Build the Realtime in-app notification tray
Create the bell icon component that subscribes to new notifications via Supabase Realtime. Use a shadcn/ui Popover for the dropdown tray. The subscription fires on INSERT to the notifications table filtered by the current user's ID.
1// src/components/NotificationBell.tsx2import { useEffect, useState } from 'react'3import { Bell } from 'lucide-react'4import { Button } from '@/components/ui/button'5import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'6import { Badge } from '@/components/ui/badge'7import { ScrollArea } from '@/components/ui/scroll-area'8import { supabase } from '@/lib/supabase'9import { useAuth } from '@/hooks/useAuth'1011type Notification = {12 id: string13 title: string14 body: string15 is_read: boolean16 created_at: string17}1819export function NotificationBell() {20 const { user } = useAuth()21 const [notifications, setNotifications] = useState<Notification[]>([])22 const [open, setOpen] = useState(false)2324 useEffect(() => {25 if (!user?.id) return2627 supabase28 .from('notifications')29 .select('*')30 .eq('user_id', user.id)31 .eq('channel', 'in_app')32 .order('created_at', { ascending: false })33 .limit(20)34 .then(({ data }) => setNotifications(data ?? []))3536 const channel = supabase37 .channel(`notifications:${user.id}`)38 .on(39 'postgres_changes',40 { event: 'INSERT', schema: 'public', table: 'notifications', filter: `user_id=eq.${user.id}` },41 (payload) => {42 setNotifications((prev) => [payload.new as Notification, ...prev])43 }44 )45 .subscribe()4647 return () => { supabase.removeChannel(channel) }48 }, [user?.id])4950 const unreadCount = notifications.filter((n) => !n.is_read).length5152 const markAllRead = async () => {53 await supabase.from('notifications').update({ is_read: true }).eq('user_id', user!.id).eq('is_read', false)54 setNotifications((prev) => prev.map((n) => ({ ...n, is_read: true })))55 }5657 return (58 <Popover open={open} onOpenChange={setOpen}>59 <PopoverTrigger asChild>60 <Button variant="ghost" size="icon" className="relative">61 <Bell className="h-5 w-5" />62 {unreadCount > 0 && (63 <Badge className="absolute -top-1 -right-1 h-5 w-5 flex items-center justify-center p-0 text-xs">64 {unreadCount > 9 ? '9+' : unreadCount}65 </Badge>66 )}67 </Button>68 </PopoverTrigger>69 <PopoverContent className="w-80 p-0" align="end">70 <div className="flex items-center justify-between p-3 border-b">71 <span className="font-semibold text-sm">Notifications</span>72 {unreadCount > 0 && <Button variant="ghost" size="sm" onClick={markAllRead}>Mark all read</Button>}73 </div>74 <ScrollArea className="h-80">75 {notifications.length === 0 ? (76 <p className="text-center text-muted-foreground text-sm py-8">No notifications yet</p>77 ) : (78 notifications.map((n) => (79 <div key={n.id} className={`p-3 border-b text-sm ${!n.is_read ? 'bg-muted/50' : ''}`}>80 <p className="font-medium">{n.title}</p>81 <p className="text-muted-foreground mt-0.5">{n.body}</p>82 </div>83 ))84 )}85 </ScrollArea>86 </PopoverContent>87 </Popover>88 )89}Pro tip: Gate the Realtime subscription on user?.id as shown. If user is null (logged out), the effect returns early without creating a channel. This prevents anonymous subscription leaks that waste Supabase Realtime connections.
Expected result: The bell icon appears in the header. When a notification row is inserted into Supabase with channel='in_app' for the logged-in user, it appears in the tray instantly without a page refresh.
Build the fan-out Edge Function dispatcher
Create the Edge Function that reads from notification_queue, resolves templates, and dispatches to each channel in parallel. This function is called by your app when an event occurs, or can be triggered by a Supabase database webhook on INSERT to notification_queue.
1Create a Supabase Edge Function at supabase/functions/dispatch-notification/index.ts.23The function should:41. Accept a POST body: { queue_id: string } or process all 'pending' rows if no ID given52. For each queued notification:6 a. Fetch user preferences for that event_type7 b. Fetch the notification_template for that event_type8 c. Interpolate {{variable}} placeholders in subject_template, body_template, sms_template using the variables jsonb9 d. Build an array of tasks based on enabled channels103. Execute all channel tasks in parallel with Promise.all:11 - in_app_enabled: INSERT into notifications with channel='in_app'12 - email_enabled: POST to Resend API (https://api.resend.com/emails) with the email_template body. Use RESEND_API_KEY from Deno.env.13 - sms_enabled: POST to Twilio Messages API. Use TWILIO_ACCOUNT_SID and TWILIO_AUTH_TOKEN from Deno.env.144. Update notification_queue row: status='done', processed_at=now()155. On any error, set status='failed', error_text=error.message1617Secrets needed in Cloud tab → Secrets: RESEND_API_KEY, TWILIO_ACCOUNT_SID, TWILIO_AUTH_TOKEN, TWILIO_FROM_NUMBER, RESEND_FROM_EMAIL1819For user email/phone, query auth.users via the Supabase admin client (service role key).Pro tip: Add a max_attempts column to notification_queue. If the Edge Function fails, increment the counter. Only retry rows where status='failed' AND max_attempts < 3. This prevents infinite retry loops for permanently broken notifications.
Expected result: Calling the Edge Function with a valid queue_id dispatches notifications to all enabled channels. The queue row status changes to 'done'. In-app notifications appear in the bell tray immediately via Realtime.
Build the notification preferences settings page
Create the user-facing preferences page where users toggle notifications per event type and channel. Use shadcn/ui Table with Toggle switches. Changes upsert into notification_preferences using ON CONFLICT (user_id, event_type) DO UPDATE.
1Build a notification preferences page at src/pages/NotificationPreferences.tsx.23Requirements:4- Fetch all distinct event_type values from notification_templates5- Fetch the current user's notification_preferences rows6- Render a shadcn/ui Table with columns: Event Type (formatted as human-readable), In-App, Email, SMS7- Each cell in the channel columns is a shadcn/ui Switch (checked = enabled)8- Toggling any switch immediately calls: supabase.from('notification_preferences').upsert({ user_id, event_type, [channel]_enabled: newValue }, { onConflict: 'user_id,event_type' })9- Show a Toast (useToast) on save: 'Preference saved'10- If a user has no row for an event_type, default all switches to true (inherit defaults)11- Group rows by category if templates have a category column, otherwise list alphabetically12- Add a 'Save All' button that bulk-upserts all current switch states in one callPro tip: Use optimistic updates: toggle the local state immediately, then fire the upsert in the background. If the upsert fails, revert the toggle and show an error Toast. This makes the UI feel instant even on slow connections.
Expected result: The preferences page shows all event types as rows. Toggling a switch saves to Supabase. Future notifications for disabled channels are skipped by the fan-out dispatcher.
Wire up the notify_user helper and trigger the first notification
Create the client-side helper function and test the full pipeline end-to-end by triggering a notification from a button in your app. Verify the in-app notification appears in the bell tray via Realtime.
1// src/lib/notify.ts2import { supabase } from '@/lib/supabase'34export async function notifyUser(5 userId: string,6 eventType: string,7 variables: Record<string, string> = {}8): Promise<void> {9 const { error } = await supabase.from('notification_queue').insert({10 user_id: userId,11 event_type: eventType,12 variables,13 status: 'pending',14 })1516 if (error) {17 console.error('Failed to queue notification:', error.message)18 return19 }2021 // Trigger the fan-out Edge Function22 await supabase.functions.invoke('dispatch-notification')23}2425// Usage in any component:26// await notifyUser(recipientId, 'new_comment', { commenter: 'Alice', preview: 'Great post!' })Pro tip: For production, replace the direct supabase.functions.invoke call with a Supabase Database Webhook that triggers the Edge Function on INSERT to notification_queue. This decouples your frontend from the dispatch step and ensures notifications fire even from other Edge Functions or backend processes.
Expected result: Calling notifyUser from a button triggers the full pipeline. The in-app notification appears in the bell tray within 1-2 seconds. Check the Cloud tab → Edge Functions logs to verify Resend and Twilio calls succeeded.
Complete code
1import { useEffect, useState, useCallback } from 'react'2import { Bell } from 'lucide-react'3import { Button } from '@/components/ui/button'4import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'5import { Badge } from '@/components/ui/badge'6import { ScrollArea } from '@/components/ui/scroll-area'7import { supabase } from '@/lib/supabase'8import { useAuth } from '@/hooks/useAuth'9import { formatDistanceToNow } from 'date-fns'1011type Notification = {12 id: string13 title: string14 body: string15 is_read: boolean16 created_at: string17}1819export function NotificationBell() {20 const { user } = useAuth()21 const [notifications, setNotifications] = useState<Notification[]>([])22 const [open, setOpen] = useState(false)2324 const fetchNotifications = useCallback(async () => {25 if (!user?.id) return26 const { data } = await supabase27 .from('notifications')28 .select('id, title, body, is_read, created_at')29 .eq('user_id', user.id)30 .eq('channel', 'in_app')31 .order('created_at', { ascending: false })32 .limit(20)33 setNotifications(data ?? [])34 }, [user?.id])3536 useEffect(() => {37 fetchNotifications()38 }, [fetchNotifications])3940 useEffect(() => {41 if (!user?.id) return42 const channel = supabase43 .channel(`notifications:${user.id}`)44 .on(45 'postgres_changes',46 { event: 'INSERT', schema: 'public', table: 'notifications', filter: `user_id=eq.${user.id}` },47 (payload) => {48 setNotifications((prev) => [payload.new as Notification, ...prev.slice(0, 19)])49 }50 )51 .subscribe()52 return () => { supabase.removeChannel(channel) }53 }, [user?.id])5455 const unreadCount = notifications.filter((n) => !n.is_read).length5657 const markAllRead = async () => {58 if (!user?.id) return59 await supabase.from('notifications').update({ is_read: true }).eq('user_id', user.id).eq('is_read', false)60 setNotifications((prev) => prev.map((n) => ({ ...n, is_read: true })))61 }6263 return (64 <Popover open={open} onOpenChange={setOpen}>65 <PopoverTrigger asChild>66 <Button variant="ghost" size="icon" className="relative">67 <Bell className="h-5 w-5" />68 {unreadCount > 0 && (69 <Badge className="absolute -top-1 -right-1 h-5 w-5 flex items-center justify-center p-0 text-xs">70 {unreadCount > 9 ? '9+' : unreadCount}71 </Badge>72 )}73 </Button>74 </PopoverTrigger>75 <PopoverContent className="w-80 p-0" align="end">76 <div className="flex items-center justify-between px-3 py-2 border-b">77 <span className="font-semibold text-sm">Notifications</span>78 {unreadCount > 0 && <Button variant="ghost" size="sm" onClick={markAllRead}>Mark all read</Button>}79 </div>80 <ScrollArea className="h-80">81 {notifications.length === 0 ? (82 <p className="text-center text-muted-foreground text-sm py-8">No notifications yet</p>83 ) : notifications.map((n) => (84 <div key={n.id} className={`px-3 py-2.5 border-b last:border-0 text-sm ${!n.is_read ? 'bg-muted/40' : ''}`}>85 <p className="font-medium leading-snug">{n.title}</p>86 <p className="text-muted-foreground mt-0.5 leading-snug">{n.body}</p>87 <p className="text-xs text-muted-foreground mt-1">{formatDistanceToNow(new Date(n.created_at), { addSuffix: true })}</p>88 </div>89 ))}90 </ScrollArea>91 </PopoverContent>92 </Popover>93 )94}Customization ideas
Push notification support via Web Push API
Add a push_subscriptions table storing browser PushSubscription objects. When the user visits the app, prompt for notification permission and save the subscription. In the fan-out Edge Function, send Web Push payloads using the web-push npm package via esm.sh when push_enabled is true in preferences.
Digest mode — daily email summary
Add a digest_enabled column to notification_preferences. Instead of sending individual emails, accumulate in-app notifications and send a single daily email with a list of missed events. Use a scheduled Supabase Edge Function (cron) to query unread notifications older than 24 hours and batch them into a single Resend call.
Notification action buttons
Add an actions column (jsonb array) to notifications with {label, url} objects. In the NotificationBell Popover, render each action as a small Button below the notification body. Clicking the button navigates to the URL and marks the notification read. Useful for 'View comment' or 'Approve request' CTAs.
Admin notification broadcast
Add an admin page that lets you send a notification to all users or a filtered segment (e.g. all users on the paid plan). The broadcast calls an Edge Function that queries matching user IDs, then inserts rows into notification_queue in batches of 100 to avoid timeouts.
Notification click-through tracking
Add a clicked_at timestamp column to notifications. When a user clicks a notification in the tray, update clicked_at in Supabase. Build a simple analytics view showing open rate and click rate per notification type over the last 30 days.
Common pitfalls
Pitfall: Creating one Realtime channel named 'realtime'
How to avoid: Always include the user ID in the channel name: supabase.channel(`notifications:${user.id}`). This guarantees uniqueness per user session and prevents duplicate message delivery.
Pitfall: Missing removeChannel cleanup in useEffect
How to avoid: Always return a cleanup function: return () => { supabase.removeChannel(channel) }. This fires when the component unmounts or when the effect dependency changes.
Pitfall: Storing email and SMS credentials as VITE_ prefixed secrets
How to avoid: Store RESEND_API_KEY, TWILIO_ACCOUNT_SID, and TWILIO_AUTH_TOKEN without the VITE_ prefix in Cloud tab → Secrets. Access them only inside Edge Functions with Deno.env.get('RESEND_API_KEY').
Pitfall: Calling the fan-out Edge Function synchronously from a button click
How to avoid: Insert the notification_queue row and invoke the Edge Function without awaiting the result: supabase.functions.invoke('dispatch-notification'). Show the user a Toast confirmation immediately. The fan-out runs in the background.
Best practices
- Always namespace Realtime channel names with the user ID to prevent cross-user event delivery and StrictMode duplicate handlers.
- Use a notification_queue table as an async buffer between event creation and delivery. This makes the system resilient to Edge Function failures and enables retries.
- Interpolate template variables server-side in the Edge Function, never client-side. If template logic runs in the browser, users can inspect and manipulate notification content.
- Store the recipient's email and phone number in auth.users metadata and fetch them in the Edge Function using the service role key. Never store PII in the notifications table itself.
- Add a notification_preferences row with default values for each user on signup using a Supabase Auth hook (on auth.users INSERT trigger). This ensures every user has preferences even before visiting the settings page.
- Cap the in-app notification tray at 20 rows with .limit(20). For a full history, add a separate notifications page with pagination. Loading hundreds of rows into a Popover makes the UI sluggish.
- Test the full pipeline end-to-end in Lovable's preview by temporarily inserting a row directly into notification_queue via the Supabase Table Editor and checking the bell tray for delivery.
AI prompts to try
Copy these prompts to build this project faster.
I'm building a multi-channel notification system with Supabase. I have a notification_queue table and a dispatch-notification Edge Function that calls Resend and Twilio. Help me design the template variable interpolation logic in TypeScript. My templates look like: 'Hello {{first_name}}, you got a new comment: {{preview}}'. I want a function that takes a template string and a variables Record<string, string> and returns the interpolated string. Also show me how to handle missing variables gracefully without throwing.
Add a notification history page at /notifications. Fetch all in-app notifications for the current user, sorted by created_at DESC, with cursor-based pagination (20 per page, load more button). Render each notification as a Card with an unread indicator dot, title, body, relative timestamp, and a 'Mark as read' Button. Show a Skeleton loading state during the initial fetch. Filter by read/unread using Tabs at the top.
In Supabase, create a database function notify_user(p_user_id uuid, p_event_type text, p_variables jsonb) that inserts a row into notification_queue and then calls net.http_post to invoke the dispatch-notification Edge Function URL. This way, any PostgreSQL trigger or stored procedure can send a notification without going through the client app. Use SECURITY DEFINER so it can write to notification_queue regardless of RLS.
Frequently asked questions
How do I make sure a notification is only sent once even if the app re-renders?
Insert into notification_queue from a server-side action (Server Action or Edge Function), not directly from a button click handler. Client-side code can fire multiple times due to re-renders, StrictMode double-invoking, or network retries. The notification_queue table is your idempotency buffer — add a unique constraint on (user_id, event_type, created_at::date) if you want to prevent duplicate same-day notifications.
Can I use a free Resend account for this?
Yes. Resend's free plan allows 3,000 emails per month and 100 per day, which is enough for a development build or small app. The API is identical to paid plans. Upgrade when your daily send volume approaches the limit. Make sure to verify your sending domain in the Resend dashboard or emails will go to spam.
Why use a notification_queue table instead of calling Resend directly from my app code?
The queue decouples event creation from delivery. If Resend or Twilio is temporarily unavailable, the queue row persists and can be retried. It also gives you a record of all dispatched notifications and their status, which is valuable for debugging delivery failures and building an admin analytics view.
How do I test Twilio SMS in development without a real phone number?
Twilio provides verified test credentials and a magic test phone number (+15005550006) that accepts messages without actually sending them. Use your test Account SID and Auth Token in Lovable's Secrets during development. The Edge Function responds as if the SMS was sent, but no message is delivered. Switch to live credentials when you deploy to production.
How many Realtime connections does Supabase support?
Supabase Free plan supports 200 concurrent Realtime connections. Pro plan supports 500. If your app has more simultaneous users than the limit, new connections are rejected. For high-traffic apps, consider polling for notifications on a 30-second interval instead of keeping a persistent Realtime connection — it scales to any number of users.
Can I send notifications from one user to another, like a mention?
Yes. Call notifyUser(mentionedUserId, 'mention', { mentioner: currentUser.name, content: excerpt }) from the component that handles the mention action. The fan-out function dispatches to the mentioned user's channels based on their preferences. The mentioning user never receives the notification — user_id in notification_queue is always the recipient.
Where can I get help if the fan-out Edge Function is failing for some users?
Check Cloud tab → Logs in Lovable for Edge Function error messages. Common failures are missing Secrets (check RESEND_API_KEY, TWILIO_ACCOUNT_SID), malformed template variables causing string errors, or user email/phone being null in auth.users. RapidDev can help debug complex notification pipeline issues for production Lovable apps.
How do I handle notification preferences for new event types I add later?
Insert new event type rows into notification_templates. For existing users, the preferences page shows the new row with all channels toggled on by default (the fallback when no preference row exists). If you want all existing users to have an explicit preference row for the new type, write a one-time Supabase SQL migration that inserts default rows: INSERT INTO notification_preferences (user_id, event_type) SELECT id, 'new_event_type' FROM auth.users ON CONFLICT DO NOTHING.
Talk to an Expert
Our team has built 600+ apps. Get personalized help with your project.
Book a free consultation