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.
What you're building
SaaS products need notifications across multiple channels — in-app for active users, email for important updates, and push for time-sensitive alerts. Users must control which notifications they receive and how.
V0 generates the notification bell UI, preferences page, and dispatch API from prompts. Supabase Realtime powers instant in-app delivery, Resend handles email, and the web-push library sends browser push notifications.
The architecture uses a centralized send API that renders templates, checks user preferences, and dispatches to all enabled channels using Promise.allSettled for graceful handling of partial failures. Supabase Realtime shows new notifications instantly in the bell dropdown.
Final result
A notification system with multi-channel delivery (in-app, email, push), user preference controls, template rendering, and real-time in-app updates.
Tech stack
Prerequisites
- 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)
Build steps
Set up the database schema for notifications and preferences
Create the Supabase schema for notification templates, notifications, user preferences, and push subscriptions.
1// Paste this prompt into V0's AI chat:2// Build a notification system. Create a Supabase schema:3// 1. notification_templates: id (uuid PK), slug (text UNIQUE), title_template (text), body_template (text), channels (text[] DEFAULT '{in_app}'), category (text)4// 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)5// 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)6// 4. push_subscriptions: id (uuid PK), user_id (uuid FK to auth.users), endpoint (text), keys (jsonb), created_at (timestamptz)7// Enable Realtime on the notifications table. Seed templates for common events.8// 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.
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.
1import { createClient } from '@supabase/supabase-js'2import { NextRequest, NextResponse } from 'next/server'3import { Resend } from 'resend'4import webpush from 'web-push'56const supabase = createClient(process.env.SUPABASE_URL!, process.env.SUPABASE_SERVICE_ROLE_KEY!)7const resend = new Resend(process.env.RESEND_API_KEY)89webpush.setVapidDetails('mailto:admin@yourdomain.com', process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY!, process.env.VAPID_PRIVATE_KEY!)1011function renderTemplate(template: string, data: Record<string, string>): string {12 return template.replace(/\{\{(\w+)\}\}/g, (_, key) => data[key] || '')13}1415export async function POST(req: NextRequest) {16 const { user_id, template_slug, data } = await req.json()1718 const { data: template } = await supabase.from('notification_templates').select('*').eq('slug', template_slug).single()19 if (!template) return NextResponse.json({ error: 'Template not found' }, { status: 404 })2021 const title = renderTemplate(template.title_template, data)22 const body = renderTemplate(template.body_template, data)2324 const { data: prefs } = await supabase.from('notification_preferences').select('*').eq('user_id', user_id).eq('category', template.category).single()25 const userPrefs = prefs || { in_app: true, email: true, push: false }2627 const channels: string[] = []28 const dispatches: Promise<any>[] = []2930 if (userPrefs.in_app) {31 channels.push('in_app')32 dispatches.push(supabase.from('notifications').insert({ user_id, template_id: template.id, title, body, data, channels_sent: channels }))33 }3435 if (userPrefs.email) {36 channels.push('email')37 const { data: user } = await supabase.auth.admin.getUserById(user_id)38 if (user?.user?.email) {39 dispatches.push(resend.emails.send({ from: 'notifications@yourdomain.com', to: user.user.email, subject: title, html: `<p>${body}</p>` }))40 }41 }4243 if (userPrefs.push) {44 channels.push('push')45 const { data: subs } = await supabase.from('push_subscriptions').select('*').eq('user_id', user_id)46 for (const sub of subs || []) {47 dispatches.push(webpush.sendNotification({ endpoint: sub.endpoint, keys: sub.keys }, JSON.stringify({ title, body })))48 }49 }5051 const results = await Promise.allSettled(dispatches)52 return NextResponse.json({ channels, results: results.map(r => r.status) })53}Expected result: The API dispatches to all enabled channels based on user preferences. Partial failures do not block other channels.
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.
1'use client'23import { useEffect, useState } from 'react'4import { createBrowserClient } from '@supabase/ssr'5import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'6import { Button } from '@/components/ui/button'7import { Badge } from '@/components/ui/badge'8import { ScrollArea } from '@/components/ui/scroll-area'9import { Bell } from 'lucide-react'1011interface Notification { id: string; title: string; body: string; is_read: boolean; created_at: string }1213export function NotificationBell({ userId, initial }: { userId: string; initial: Notification[] }) {14 const [notifications, setNotifications] = useState(initial)15 const unread = notifications.filter(n => !n.is_read).length16 const supabase = createBrowserClient(process.env.NEXT_PUBLIC_SUPABASE_URL!, process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!)1718 useEffect(() => {19 const channel = supabase.channel(`notifications:${userId}`)20 .on('postgres_changes', { event: 'INSERT', schema: 'public', table: 'notifications', filter: `user_id=eq.${userId}` }, (payload) => {21 setNotifications(prev => [payload.new as Notification, ...prev])22 }).subscribe()23 return () => { supabase.removeChannel(channel) }24 }, [userId, supabase])2526 return (27 <Popover>28 <PopoverTrigger asChild>29 <Button variant="ghost" size="icon" className="relative">30 <Bell className="h-5 w-5" />31 {unread > 0 && <Badge className="absolute -top-1 -right-1 h-5 w-5 p-0 flex items-center justify-center">{unread}</Badge>}32 </Button>33 </PopoverTrigger>34 <PopoverContent className="w-80">35 <ScrollArea className="h-80">36 {notifications.map(n => (37 <div key={n.id} className={`p-3 border-b ${n.is_read ? '' : 'bg-muted/50'}`}>38 <p className="text-sm font-medium">{n.title}</p>39 <p className="text-xs text-muted-foreground">{n.body}</p>40 </div>41 ))}42 </ScrollArea>43 </PopoverContent>44 </Popover>45 )46}Expected result: The bell shows unread count and streams new notifications in real-time. Clicking opens a dropdown with the notification list.
Build preference settings and deploy
Create the preferences page where users toggle notifications per category and channel. Add push subscription registration and deploy.
1// Paste this prompt into V0's AI chat:2// Create notification settings at app/settings/notifications/page.tsx.3// Requirements:4// - Fetch all notification categories from templates (distinct categories)5// - For each category, show a row with: category name, three Switch toggles (In-App, Email, Push)6// - Pre-fill with current user preferences from notification_preferences7// - Save Button uses Server Action to upsert preferences per category8// - Push toggle: if enabling push for the first time, trigger browser permission request and register service worker9// - Store push subscription in push_subscriptions table via /api/notifications/push/subscribe10// - Use shadcn/ui Switch, Table layout, Separator between categories, Toast for save confirmation11// 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
1import { createClient } from '@supabase/supabase-js'2import { NextRequest, NextResponse } from 'next/server'3import { Resend } from 'resend'45const supabase = createClient(6 process.env.SUPABASE_URL!,7 process.env.SUPABASE_SERVICE_ROLE_KEY!8)9const resend = new Resend(process.env.RESEND_API_KEY)1011function render(tpl: string, data: Record<string, string>) {12 return tpl.replace(/\{\{(\w+)\}\}/g, (_, k) => data[k] || '')13}1415export async function POST(req: NextRequest) {16 const { user_id, template_slug, data } = await req.json()1718 const { data: tpl } = await supabase19 .from('notification_templates')20 .select('*')21 .eq('slug', template_slug)22 .single()2324 if (!tpl) {25 return NextResponse.json({ error: 'Template not found' }, { status: 404 })26 }2728 const title = render(tpl.title_template, data)29 const body = render(tpl.body_template, data)3031 const { data: prefs } = await supabase32 .from('notification_preferences')33 .select('*')34 .eq('user_id', user_id)35 .eq('category', tpl.category)36 .single()3738 const p = prefs || { in_app: true, email: true, push: false }39 const sent: string[] = []4041 if (p.in_app) {42 sent.push('in_app')43 await supabase.from('notifications').insert({44 user_id, template_id: tpl.id,45 title, body, data, channels_sent: sent,46 })47 }4849 if (p.email) {50 sent.push('email')51 const { data: u } = await supabase.auth.admin.getUserById(user_id)52 if (u?.user?.email) {53 await resend.emails.send({54 from: 'noreply@yourdomain.com',55 to: u.user.email,56 subject: title,57 html: `<p>${body}</p>`,58 })59 }60 }6162 return NextResponse.json({ success: true, channels: sent })63}Customization ideas
Notification digest emails
Send a daily or weekly digest email summarizing unread notifications instead of individual emails for each event.
Slack channel integration
Add Slack as a notification channel by posting to a Slack webhook URL when critical notifications are triggered.
Notification center page
Build a full-page notification history with filters by category, channel, and read status, beyond the bell dropdown.
Batch notification sending
Add a bulk send API that dispatches the same notification to multiple users at once, useful for product announcements.
Common pitfalls
Pitfall: Using Promise.all instead of Promise.allSettled for multi-channel dispatch
How to avoid: Use Promise.allSettled which resolves with results for all promises regardless of individual failures. Log failed channels for retry.
Pitfall: Not cleaning up Realtime subscriptions on component unmount
How to avoid: Return a cleanup function from useEffect that calls supabase.removeChannel(channel) to properly unsubscribe.
Pitfall: Storing VAPID_PRIVATE_KEY with NEXT_PUBLIC_ prefix
How to avoid: 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
AI prompts to try
Copy these prompts to build this project faster.
I'm building a multi-channel notification system with Next.js, Supabase, and Resend. I need an API route that takes a user_id, template_slug, and data object. It should render the template by replacing {{variable}} placeholders, check user preferences, and dispatch to enabled channels (in-app via Supabase insert, email via Resend, push via web-push). Use Promise.allSettled for dispatch. Please write the route.
Create a notification template rendering engine. Templates use {{variable}} syntax in title and body. The render function receives a template string and a data object, replacing all {{key}} with corresponding values. Then build the dispatch function that checks notification_preferences for the user and category, and sends to enabled channels using Promise.allSettled.
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.
Talk to an Expert
Our team has built 600+ apps. Get personalized help with your project.
Book a free consultation