# How to Build a Notification System with Lovable

- Tool: How to Build with Lovable
- Difficulty: Advanced
- Compatibility: Lovable Pro or higher
- Last updated: April 2026

## TL;DR

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.

## Before you start

- 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

## Step-by-step guide

### 1. 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.

```
Create a multi-channel notification system schema. Tables:

1. 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_at

2. 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)

3. notification_templates: id, event_type (text unique), subject_template (text), body_template (text), sms_template (text), created_at

4. notification_queue: id, user_id, event_type, variables (jsonb), status (text default 'pending': pending|processing|done|failed), error_text (text), created_at, processed_at

RLS policies:
- notifications: users can SELECT and UPDATE (is_read) their own rows only
- notification_preferences: users can SELECT, INSERT, UPDATE their own rows
- notification_templates: public SELECT, service role only for mutations
- notification_queue: service role only

Create 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.

### 2. 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.

```
// src/components/NotificationBell.tsx
import { useEffect, useState } from 'react'
import { Bell } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { Badge } from '@/components/ui/badge'
import { ScrollArea } from '@/components/ui/scroll-area'
import { supabase } from '@/lib/supabase'
import { useAuth } from '@/hooks/useAuth'

type Notification = {
  id: string
  title: string
  body: string
  is_read: boolean
  created_at: string
}

export function NotificationBell() {
  const { user } = useAuth()
  const [notifications, setNotifications] = useState<Notification[]>([])
  const [open, setOpen] = useState(false)

  useEffect(() => {
    if (!user?.id) return

    supabase
      .from('notifications')
      .select('*')
      .eq('user_id', user.id)
      .eq('channel', 'in_app')
      .order('created_at', { ascending: false })
      .limit(20)
      .then(({ data }) => setNotifications(data ?? []))

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

    return () => { supabase.removeChannel(channel) }
  }, [user?.id])

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

  const markAllRead = async () => {
    await supabase.from('notifications').update({ is_read: true }).eq('user_id', user!.id).eq('is_read', false)
    setNotifications((prev) => prev.map((n) => ({ ...n, is_read: true })))
  }

  return (
    <Popover open={open} onOpenChange={setOpen}>
      <PopoverTrigger asChild>
        <Button variant="ghost" size="icon" className="relative">
          <Bell className="h-5 w-5" />
          {unreadCount > 0 && (
            <Badge className="absolute -top-1 -right-1 h-5 w-5 flex items-center justify-center p-0 text-xs">
              {unreadCount > 9 ? '9+' : unreadCount}
            </Badge>
          )}
        </Button>
      </PopoverTrigger>
      <PopoverContent className="w-80 p-0" align="end">
        <div className="flex items-center justify-between p-3 border-b">
          <span className="font-semibold text-sm">Notifications</span>
          {unreadCount > 0 && <Button variant="ghost" size="sm" onClick={markAllRead}>Mark all read</Button>}
        </div>
        <ScrollArea className="h-80">
          {notifications.length === 0 ? (
            <p className="text-center text-muted-foreground text-sm py-8">No notifications yet</p>
          ) : (
            notifications.map((n) => (
              <div key={n.id} className={`p-3 border-b text-sm ${!n.is_read ? 'bg-muted/50' : ''}`}>
                <p className="font-medium">{n.title}</p>
                <p className="text-muted-foreground mt-0.5">{n.body}</p>
              </div>
            ))
          )}
        </ScrollArea>
      </PopoverContent>
    </Popover>
  )
}
```

> 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.

### 3. 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.

```
Create a Supabase Edge Function at supabase/functions/dispatch-notification/index.ts.

The function should:
1. Accept a POST body: { queue_id: string } or process all 'pending' rows if no ID given
2. For each queued notification:
   a. Fetch user preferences for that event_type
   b. Fetch the notification_template for that event_type
   c. Interpolate {{variable}} placeholders in subject_template, body_template, sms_template using the variables jsonb
   d. Build an array of tasks based on enabled channels
3. Execute all channel tasks in parallel with Promise.all:
   - in_app_enabled: INSERT into notifications with channel='in_app'
   - email_enabled: POST to Resend API (https://api.resend.com/emails) with the email_template body. Use RESEND_API_KEY from Deno.env.
   - sms_enabled: POST to Twilio Messages API. Use TWILIO_ACCOUNT_SID and TWILIO_AUTH_TOKEN from Deno.env.
4. Update notification_queue row: status='done', processed_at=now()
5. On any error, set status='failed', error_text=error.message

Secrets needed in Cloud tab → Secrets: RESEND_API_KEY, TWILIO_ACCOUNT_SID, TWILIO_AUTH_TOKEN, TWILIO_FROM_NUMBER, RESEND_FROM_EMAIL

For 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.

### 4. 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.

```
Build a notification preferences page at src/pages/NotificationPreferences.tsx.

Requirements:
- Fetch all distinct event_type values from notification_templates
- Fetch the current user's notification_preferences rows
- Render a shadcn/ui Table with columns: Event Type (formatted as human-readable), In-App, Email, SMS
- Each cell in the channel columns is a shadcn/ui Switch (checked = enabled)
- Toggling any switch immediately calls: supabase.from('notification_preferences').upsert({ user_id, event_type, [channel]_enabled: newValue }, { onConflict: 'user_id,event_type' })
- Show a Toast (useToast) on save: 'Preference saved'
- If a user has no row for an event_type, default all switches to true (inherit defaults)
- Group rows by category if templates have a category column, otherwise list alphabetically
- Add a 'Save All' button that bulk-upserts all current switch states in one call
```

> Pro 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.

### 5. 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.

```
// src/lib/notify.ts
import { supabase } from '@/lib/supabase'

export async function notifyUser(
  userId: string,
  eventType: string,
  variables: Record<string, string> = {}
): Promise<void> {
  const { error } = await supabase.from('notification_queue').insert({
    user_id: userId,
    event_type: eventType,
    variables,
    status: 'pending',
  })

  if (error) {
    console.error('Failed to queue notification:', error.message)
    return
  }

  // Trigger the fan-out Edge Function
  await supabase.functions.invoke('dispatch-notification')
}

// Usage in any component:
// 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 example

File: `src/components/NotificationBell.tsx`

```typescript
import { useEffect, useState, useCallback } from 'react'
import { Bell } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { Badge } from '@/components/ui/badge'
import { ScrollArea } from '@/components/ui/scroll-area'
import { supabase } from '@/lib/supabase'
import { useAuth } from '@/hooks/useAuth'
import { formatDistanceToNow } from 'date-fns'

type Notification = {
  id: string
  title: string
  body: string
  is_read: boolean
  created_at: string
}

export function NotificationBell() {
  const { user } = useAuth()
  const [notifications, setNotifications] = useState<Notification[]>([])
  const [open, setOpen] = useState(false)

  const fetchNotifications = useCallback(async () => {
    if (!user?.id) return
    const { data } = await supabase
      .from('notifications')
      .select('id, title, body, is_read, created_at')
      .eq('user_id', user.id)
      .eq('channel', 'in_app')
      .order('created_at', { ascending: false })
      .limit(20)
    setNotifications(data ?? [])
  }, [user?.id])

  useEffect(() => {
    fetchNotifications()
  }, [fetchNotifications])

  useEffect(() => {
    if (!user?.id) return
    const channel = supabase
      .channel(`notifications:${user.id}`)
      .on(
        'postgres_changes',
        { event: 'INSERT', schema: 'public', table: 'notifications', filter: `user_id=eq.${user.id}` },
        (payload) => {
          setNotifications((prev) => [payload.new as Notification, ...prev.slice(0, 19)])
        }
      )
      .subscribe()
    return () => { supabase.removeChannel(channel) }
  }, [user?.id])

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

  const markAllRead = async () => {
    if (!user?.id) return
    await supabase.from('notifications').update({ is_read: true }).eq('user_id', user.id).eq('is_read', false)
    setNotifications((prev) => prev.map((n) => ({ ...n, is_read: true })))
  }

  return (
    <Popover open={open} onOpenChange={setOpen}>
      <PopoverTrigger asChild>
        <Button variant="ghost" size="icon" className="relative">
          <Bell className="h-5 w-5" />
          {unreadCount > 0 && (
            <Badge className="absolute -top-1 -right-1 h-5 w-5 flex items-center justify-center p-0 text-xs">
              {unreadCount > 9 ? '9+' : unreadCount}
            </Badge>
          )}
        </Button>
      </PopoverTrigger>
      <PopoverContent className="w-80 p-0" align="end">
        <div className="flex items-center justify-between px-3 py-2 border-b">
          <span className="font-semibold text-sm">Notifications</span>
          {unreadCount > 0 && <Button variant="ghost" size="sm" onClick={markAllRead}>Mark all read</Button>}
        </div>
        <ScrollArea className="h-80">
          {notifications.length === 0 ? (
            <p className="text-center text-muted-foreground text-sm py-8">No notifications yet</p>
          ) : notifications.map((n) => (
            <div key={n.id} className={`px-3 py-2.5 border-b last:border-0 text-sm ${!n.is_read ? 'bg-muted/40' : ''}`}>
              <p className="font-medium leading-snug">{n.title}</p>
              <p className="text-muted-foreground mt-0.5 leading-snug">{n.body}</p>
              <p className="text-xs text-muted-foreground mt-1">{formatDistanceToNow(new Date(n.created_at), { addSuffix: true })}</p>
            </div>
          ))}
        </ScrollArea>
      </PopoverContent>
    </Popover>
  )
}
```

## Common mistakes

- **Creating one Realtime channel named 'realtime'** — Supabase forbids the channel name 'realtime'. A channel with no unique identifier will also cause conflicts if the same component mounts more than once (such as in React StrictMode), creating duplicate event handlers. Fix: 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.
- **Missing removeChannel cleanup in useEffect** — Without cleanup, each time the component re-renders with a new user ID, a new channel is created and the old one leaks. Over time this exhausts the Supabase Realtime connection limit and causes silent delivery failures. Fix: Always return a cleanup function: return () => { supabase.removeChannel(channel) }. This fires when the component unmounts or when the effect dependency changes.
- **Storing email and SMS credentials as VITE_ prefixed secrets** — VITE_ variables are compiled into the JavaScript bundle at build time. Anyone who views your app's source can extract them, leading to credential exposure and unexpected Resend/Twilio charges. Fix: 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').
- **Calling the fan-out Edge Function synchronously from a button click** — Edge Functions can take several seconds when dispatching to multiple external APIs. Blocking the UI thread while waiting makes the app feel unresponsive. Fix: 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.

## 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.

---

Source: https://www.rapidevelopers.com/how-to-build-lovable/notification-system
© RapidDev — https://www.rapidevelopers.com/how-to-build-lovable/notification-system
