# How to Build Notification system with V0

- Tool: v0
- Difficulty: Advanced
- Compatibility: V0 Premium or higher
- Last updated: April 2026

## TL;DR

Build a multi-channel notification hub with V0 using Next.js, Supabase Realtime, Resend, web-push, and shadcn/ui. Delivers in-app, email, and push notifications with user preference controls, template rendering, and delivery tracking — all dispatched via Promise.allSettled for graceful partial failures. Takes about 2-4 hours.

## Before you start

- A V0 account (Premium or higher — this is a complex build)
- A Supabase project with Realtime enabled (connect via V0's Connect panel)
- A Resend account for email delivery (free tier: 100 emails/day)
- VAPID keys for web push notifications (generate with web-push library)

## Step-by-step guide

### 1. Set up the database schema for notifications and preferences

Create the Supabase schema for notification templates, notifications, user preferences, and push subscriptions.

```
// Paste this prompt into V0's AI chat:
// Build a notification system. Create a Supabase schema:
// 1. notification_templates: id (uuid PK), slug (text UNIQUE), title_template (text), body_template (text), channels (text[] DEFAULT '{in_app}'), category (text)
// 2. notifications: id (uuid PK), user_id (uuid FK to auth.users), template_id (uuid FK to notification_templates), title (text), body (text), data (jsonb), channels_sent (text[]), is_read (boolean DEFAULT false), read_at (timestamptz), created_at (timestamptz)
// 3. notification_preferences: user_id (uuid FK to auth.users), category (text), in_app (boolean DEFAULT true), email (boolean DEFAULT true), push (boolean DEFAULT false), PRIMARY KEY (user_id, category)
// 4. push_subscriptions: id (uuid PK), user_id (uuid FK to auth.users), endpoint (text), keys (jsonb), created_at (timestamptz)
// Enable Realtime on the notifications table. Seed templates for common events.
// Add RLS: users see only their own notifications and preferences.
```

> Pro tip: Enable Realtime on the notifications table in Supabase Dashboard > Database > Replication for instant in-app delivery.

**Expected result:** All tables are created with Realtime enabled on notifications. Templates are seeded for common notification events.

### 2. Build the multi-channel dispatch API

Create the send endpoint that renders templates, checks user preferences, and dispatches to all enabled channels. Uses Promise.allSettled to handle partial failures gracefully.

```
import { createClient } from '@supabase/supabase-js'
import { NextRequest, NextResponse } from 'next/server'
import { Resend } from 'resend'
import webpush from 'web-push'

const supabase = createClient(process.env.SUPABASE_URL!, process.env.SUPABASE_SERVICE_ROLE_KEY!)
const resend = new Resend(process.env.RESEND_API_KEY)

webpush.setVapidDetails('mailto:admin@yourdomain.com', process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY!, process.env.VAPID_PRIVATE_KEY!)

function renderTemplate(template: string, data: Record<string, string>): string {
  return template.replace(/\{\{(\w+)\}\}/g, (_, key) => data[key] || '')
}

export async function POST(req: NextRequest) {
  const { user_id, template_slug, data } = await req.json()

  const { data: template } = await supabase.from('notification_templates').select('*').eq('slug', template_slug).single()
  if (!template) return NextResponse.json({ error: 'Template not found' }, { status: 404 })

  const title = renderTemplate(template.title_template, data)
  const body = renderTemplate(template.body_template, data)

  const { data: prefs } = await supabase.from('notification_preferences').select('*').eq('user_id', user_id).eq('category', template.category).single()
  const userPrefs = prefs || { in_app: true, email: true, push: false }

  const channels: string[] = []
  const dispatches: Promise<any>[] = []

  if (userPrefs.in_app) {
    channels.push('in_app')
    dispatches.push(supabase.from('notifications').insert({ user_id, template_id: template.id, title, body, data, channels_sent: channels }))
  }

  if (userPrefs.email) {
    channels.push('email')
    const { data: user } = await supabase.auth.admin.getUserById(user_id)
    if (user?.user?.email) {
      dispatches.push(resend.emails.send({ from: 'notifications@yourdomain.com', to: user.user.email, subject: title, html: `<p>${body}</p>` }))
    }
  }

  if (userPrefs.push) {
    channels.push('push')
    const { data: subs } = await supabase.from('push_subscriptions').select('*').eq('user_id', user_id)
    for (const sub of subs || []) {
      dispatches.push(webpush.sendNotification({ endpoint: sub.endpoint, keys: sub.keys }, JSON.stringify({ title, body })))
    }
  }

  const results = await Promise.allSettled(dispatches)
  return NextResponse.json({ channels, results: results.map(r => r.status) })
}
```

**Expected result:** The API dispatches to all enabled channels based on user preferences. Partial failures do not block other channels.

### 3. Build the notification bell with real-time updates

Create the notification bell component that shows unread count, streams new notifications via Supabase Realtime, and displays them in a Popover dropdown.

```
'use client'

import { useEffect, useState } from 'react'
import { createBrowserClient } from '@supabase/ssr'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Bell } from 'lucide-react'

interface Notification { id: string; title: string; body: string; is_read: boolean; created_at: string }

export function NotificationBell({ userId, initial }: { userId: string; initial: Notification[] }) {
  const [notifications, setNotifications] = useState(initial)
  const unread = notifications.filter(n => !n.is_read).length
  const supabase = createBrowserClient(process.env.NEXT_PUBLIC_SUPABASE_URL!, process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!)

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

  return (
    <Popover>
      <PopoverTrigger asChild>
        <Button variant="ghost" size="icon" className="relative">
          <Bell className="h-5 w-5" />
          {unread > 0 && <Badge className="absolute -top-1 -right-1 h-5 w-5 p-0 flex items-center justify-center">{unread}</Badge>}
        </Button>
      </PopoverTrigger>
      <PopoverContent className="w-80">
        <ScrollArea className="h-80">
          {notifications.map(n => (
            <div key={n.id} className={`p-3 border-b ${n.is_read ? '' : 'bg-muted/50'}`}>
              <p className="text-sm font-medium">{n.title}</p>
              <p className="text-xs text-muted-foreground">{n.body}</p>
            </div>
          ))}
        </ScrollArea>
      </PopoverContent>
    </Popover>
  )
}
```

**Expected result:** The bell shows unread count and streams new notifications in real-time. Clicking opens a dropdown with the notification list.

### 4. Build preference settings and deploy

Create the preferences page where users toggle notifications per category and channel. Add push subscription registration and deploy.

```
// Paste this prompt into V0's AI chat:
// Create notification settings at app/settings/notifications/page.tsx.
// Requirements:
// - Fetch all notification categories from templates (distinct categories)
// - For each category, show a row with: category name, three Switch toggles (In-App, Email, Push)
// - Pre-fill with current user preferences from notification_preferences
// - Save Button uses Server Action to upsert preferences per category
// - Push toggle: if enabling push for the first time, trigger browser permission request and register service worker
// - Store push subscription in push_subscriptions table via /api/notifications/push/subscribe
// - Use shadcn/ui Switch, Table layout, Separator between categories, Toast for save confirmation
// Also configure NEXT_PUBLIC_VAPID_PUBLIC_KEY in Vars for service worker registration and VAPID_PRIVATE_KEY without prefix for server-side push.
```

> Pro tip: Set RESEND_API_KEY and VAPID_PRIVATE_KEY in V0's Vars tab without NEXT_PUBLIC_ prefix. Set NEXT_PUBLIC_VAPID_PUBLIC_KEY with the prefix for the service worker.

**Expected result:** Users can toggle notification channels per category. Push subscription is registered when enabled. The app is deployed.

## Complete code example

File: `app/api/notifications/send/route.ts`

```typescript
import { createClient } from '@supabase/supabase-js'
import { NextRequest, NextResponse } from 'next/server'
import { Resend } from 'resend'

const supabase = createClient(
  process.env.SUPABASE_URL!,
  process.env.SUPABASE_SERVICE_ROLE_KEY!
)
const resend = new Resend(process.env.RESEND_API_KEY)

function render(tpl: string, data: Record<string, string>) {
  return tpl.replace(/\{\{(\w+)\}\}/g, (_, k) => data[k] || '')
}

export async function POST(req: NextRequest) {
  const { user_id, template_slug, data } = await req.json()

  const { data: tpl } = await supabase
    .from('notification_templates')
    .select('*')
    .eq('slug', template_slug)
    .single()

  if (!tpl) {
    return NextResponse.json({ error: 'Template not found' }, { status: 404 })
  }

  const title = render(tpl.title_template, data)
  const body = render(tpl.body_template, data)

  const { data: prefs } = await supabase
    .from('notification_preferences')
    .select('*')
    .eq('user_id', user_id)
    .eq('category', tpl.category)
    .single()

  const p = prefs || { in_app: true, email: true, push: false }
  const sent: string[] = []

  if (p.in_app) {
    sent.push('in_app')
    await supabase.from('notifications').insert({
      user_id, template_id: tpl.id,
      title, body, data, channels_sent: sent,
    })
  }

  if (p.email) {
    sent.push('email')
    const { data: u } = await supabase.auth.admin.getUserById(user_id)
    if (u?.user?.email) {
      await resend.emails.send({
        from: 'noreply@yourdomain.com',
        to: u.user.email,
        subject: title,
        html: `<p>${body}</p>`,
      })
    }
  }

  return NextResponse.json({ success: true, channels: sent })
}
```

## Common mistakes

- **Using Promise.all instead of Promise.allSettled for multi-channel dispatch** — Promise.all rejects if any channel fails. If email delivery fails, the in-app notification would also be lost. Fix: Use Promise.allSettled which resolves with results for all promises regardless of individual failures. Log failed channels for retry.
- **Not cleaning up Realtime subscriptions on component unmount** — Leaked subscriptions cause duplicate notifications and memory issues as users navigate between pages. Fix: Return a cleanup function from useEffect that calls supabase.removeChannel(channel) to properly unsubscribe.
- **Storing VAPID_PRIVATE_KEY with NEXT_PUBLIC_ prefix** — The private VAPID key is used to sign push messages. Exposing it allows anyone to send push notifications as your app. Fix: Store VAPID_PRIVATE_KEY without prefix in V0's Vars tab. Only NEXT_PUBLIC_VAPID_PUBLIC_KEY should have the prefix.

## Best practices

- Use Promise.allSettled for multi-channel dispatch to handle partial failures gracefully
- Clean up Supabase Realtime subscriptions in useEffect cleanup to prevent memory leaks
- Store templates as JSONB with variable placeholders ({{name}}, {{action}}) for reusable notification content
- Set RESEND_API_KEY and VAPID_PRIVATE_KEY without NEXT_PUBLIC_ prefix in V0's Vars tab
- Use Supabase Realtime for instant in-app notification delivery without polling
- Let users control preferences per category and channel with granular Switch toggles
- Use V0's Design Mode (Option+D) to adjust the notification bell Popover width and notification card styling

## Frequently asked questions

### How do real-time in-app notifications work?

When the send API inserts a notification into the database, Supabase Realtime detects the change and pushes it to all connected clients subscribed to that user's notifications channel. The bell component updates instantly without polling.

### What happens if one notification channel fails?

Promise.allSettled ensures all channels are attempted regardless of individual failures. If email fails but in-app succeeds, the user still gets the in-app notification. Failed channels are logged for debugging.

### How do push notifications work?

When a user enables push in preferences, the browser requests permission and registers a service worker. The push subscription (endpoint + keys) is stored in Supabase. When sending, the server uses the web-push library with VAPID keys to deliver the notification to the browser.

### Do I need a paid V0 plan?

Yes, Premium ($20/month) at minimum. The notification system has many components (bell, preferences, dispatch API, push setup) requiring multiple prompts.

### Can users disable notifications for specific categories?

Yes. The preferences page shows Switch toggles per category (e.g., billing, marketing, security) and per channel (in-app, email, push). Users can fine-tune exactly what they receive and how.

### How do I deploy the notification system?

Click Share in V0, then Publish to Production. Set RESEND_API_KEY, VAPID_PRIVATE_KEY (no prefix), and NEXT_PUBLIC_VAPID_PUBLIC_KEY in the Vars tab. Enable Realtime on the notifications table in Supabase Dashboard.

### Can RapidDev help build a custom notification system?

Yes. RapidDev has built over 600 apps including notification systems with multi-channel delivery, digest scheduling, and analytics dashboards. Book a free consultation to discuss your notification requirements.

---

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