Build an SMS notification system with V0 using Next.js, Supabase, and Twilio that sends transactional and marketing messages with real-time delivery tracking, opt-in/out compliance management, customizable template variables, and campaign scheduling — all in about 1-2 hours without local setup.
What you're building
Businesses need to send order confirmations, appointment reminders, and promotional messages via SMS. A proper SMS system goes beyond just sending messages — it requires delivery status tracking, opt-in/out compliance, reusable templates with variable substitution, and campaign management for bulk sends.
V0 generates the message log, template builder, campaign manager, and Twilio integration from prompts. The key architectural decision is handling Twilio webhook authentication — incoming delivery status callbacks and inbound messages must verify the X-Twilio-Signature header using twilio.validateRequest() with the raw URL and body parameters.
The architecture uses Next.js App Router with Server Components for the message log and campaign list, API routes for Twilio send and webhook endpoints, Server Actions for template and campaign management, and Supabase for storing contacts, messages, templates, and campaigns.
Final result
An SMS notification system with message sending via Twilio, delivery tracking, reusable templates with variables, campaign scheduling with bulk sends, opt-in/out management, and inbound message handling.
Tech stack
Prerequisites
- A V0 account (Premium recommended for the multiple pages)
- A Supabase project (free tier works — connect via V0's Connect panel)
- A Twilio account with a phone number (free trial includes $15 credit)
Build steps
Set up the project and SMS schema
Open V0 and create a new project. Use the Connect panel to add Supabase. Create the schema for contacts with opt-in tracking, SMS messages with delivery status, templates with variables, and campaigns.
1// Paste this prompt into V0's AI chat:2// Build an SMS notification system. Create a Supabase schema with:3// 1. contacts: id (uuid PK), user_id (uuid), phone (text UNIQUE), country_code (text), first_name (text), opted_in (boolean DEFAULT false), opted_in_at (timestamptz), opted_out_at (timestamptz), created_at (timestamptz)4// 2. sms_messages: id (uuid PK), contact_id (uuid FK), template_id (uuid FK nullable), direction (text CHECK outbound/inbound), body (text), status (text CHECK queued/sent/delivered/failed/undelivered), provider_message_id (text), error_code (text), sent_at (timestamptz), delivered_at (timestamptz), created_at (timestamptz)5// 3. sms_templates: id (uuid PK), owner_id (uuid FK), name (text), body_template (text), variables (text[]), category (text CHECK transactional/marketing), created_at (timestamptz)6// 4. campaigns: id (uuid PK), owner_id (uuid FK), name (text), template_id (uuid FK), segment_criteria (jsonb), status (text CHECK draft/scheduled/sending/completed), scheduled_at (timestamptz), sent_count (integer DEFAULT 0), delivered_count (integer DEFAULT 0), created_at (timestamptz)7// RLS: users can only manage their own contacts, templates, and campaigns.8// Generate SQL migration and TypeScript types.Pro tip: Store Twilio credentials in V0's Vars tab as TWILIO_ACCOUNT_SID, TWILIO_AUTH_TOKEN, and TWILIO_PHONE_NUMBER — all without NEXT_PUBLIC_ prefix since they must only be accessed server-side.
Expected result: Supabase is connected with contacts, messages, templates, and campaigns tables created. RLS policies restrict access to the owner's data.
Build the SMS sending API and template system
Create the API route that sends SMS via Twilio with template variable substitution, and the template builder page for creating reusable message templates.
1import { NextRequest, NextResponse } from 'next/server'2import { createClient } from '@supabase/supabase-js'3import twilio from 'twilio'45const supabase = createClient(6 process.env.SUPABASE_URL!,7 process.env.SUPABASE_SERVICE_ROLE_KEY!8)910const twilioClient = twilio(11 process.env.TWILIO_ACCOUNT_SID!,12 process.env.TWILIO_AUTH_TOKEN!13)1415export async function POST(req: NextRequest) {16 const { contact_id, template_id, variables } = await req.json()1718 const { data: contact } = await supabase19 .from('contacts')20 .select('*')21 .eq('id', contact_id)22 .eq('opted_in', true)23 .single()2425 if (!contact) {26 return NextResponse.json(27 { error: 'Contact not found or not opted in' },28 { status: 400 }29 )30 }3132 const { data: template } = await supabase33 .from('sms_templates')34 .select('*')35 .eq('id', template_id)36 .single()3738 if (!template) {39 return NextResponse.json({ error: 'Template not found' }, { status: 404 })40 }4142 let body = template.body_template43 for (const [key, value] of Object.entries(variables || {})) {44 body = body.replace(new RegExp(`{{${key}}}`, 'g'), value as string)45 }4647 const message = await twilioClient.messages.create({48 body,49 to: `${contact.country_code}${contact.phone}`,50 from: process.env.TWILIO_PHONE_NUMBER!,51 statusCallback: `${process.env.NEXT_PUBLIC_APP_URL}/api/webhooks/twilio`,52 })5354 await supabase.from('sms_messages').insert({55 contact_id,56 template_id,57 direction: 'outbound',58 body,59 status: 'queued',60 provider_message_id: message.sid,61 sent_at: new Date().toISOString(),62 })6364 return NextResponse.json({ success: true, message_sid: message.sid })65}Expected result: An API route that sends SMS via Twilio with template variable substitution. Only sends to contacts who have opted in.
Create the Twilio webhook handler for delivery tracking
Build the webhook endpoint that receives delivery status updates and inbound messages from Twilio. Verify the X-Twilio-Signature header for security.
1// Paste this prompt into V0's AI chat:2// Build a Twilio webhook handler at app/api/webhooks/twilio/route.ts.3// Requirements:4// - POST handler that receives Twilio delivery status callbacks5// - Verify X-Twilio-Signature header using twilio.validateRequest()6// with TWILIO_AUTH_TOKEN, the full request URL, and parsed body params7// - On delivery status: update sms_messages set status from Twilio's MessageStatus8// (queued/sent/delivered/failed/undelivered) where provider_message_id = MessageSid9// - On inbound message (direction check): if body contains STOP/UNSUBSCRIBE,10// update contacts set opted_in = false, opted_out_at = now()11// Otherwise insert into sms_messages with direction = 'inbound'12// - Return TwiML response (empty <Response/>) with 200 status13// - Use request.text() to get raw body, then parse as URLSearchParams14//15// Also build the message log page at app/sms/page.tsx:16// - Table of messages with columns: phone, body preview, status Badge, direction, sent_at17// - Badge colors: queued=gray, sent=blue, delivered=green, failed=red, undelivered=orange18// - Filter by status and direction using Select dropdowns19// - Use shadcn/ui Table, Badge, Select, Card for summary metrics (total sent, delivered %, failed count)Pro tip: Twilio webhook verification requires the FULL production URL (including https://). During development, use the Twilio CLI 'twilio phone-numbers:update' to point the webhook to a tunnel. After deploying to Vercel, update the statusCallback URL.
Expected result: A webhook handler that verifies Twilio signatures, updates delivery status in the database, handles opt-out keywords, and a message log page with color-coded status Badges.
Build the campaign manager with scheduling and bulk sends
Create the campaign page for managing bulk SMS sends. Campaigns select a template and contact segment, can be scheduled for future delivery, and show real-time sending progress.
1// Paste this prompt into V0's AI chat:2// Build a campaign manager at app/sms/campaigns/page.tsx.3// Requirements:4// - List of campaigns as Cards showing name, template, status Badge, sent/delivered counts5// - "Create Campaign" Button opens Dialog with:6// - name Input, template Select (from sms_templates)7// - Contact segment selection: all opted-in, or filter by criteria8// - Calendar + Popover DatePicker for scheduling (optional — leave empty for immediate)9// - Preview of the template body10// - Campaign detail view:11// - Progress bar showing sent_count / total contacts12// - Delivered vs failed ratio13// - Table of individual message statuses14// - "Launch Campaign" Button with confirmation Dialog15// - Server Action launchCampaign() that:16// - Queries contacts matching segment_criteria17// - Sends messages in chunks of 100 (to stay within serverless timeout)18// - Updates campaign sent_count after each chunk19// - Use shadcn/ui Card, Badge, Button, Dialog, Calendar, Popover, Progress, Table, Select, ToastExpected result: A campaign manager with create/schedule/launch flow, contact segment selection, bulk sending in chunks, and real-time progress tracking with delivery stats.
Complete code
1'use server'23import { createClient } from '@/lib/supabase/server'4import { revalidatePath } from 'next/cache'56export async function createTemplate(formData: FormData) {7 const supabase = await createClient()8 const { data: { user } } = await supabase.auth.getUser()9 if (!user) throw new Error('Must be logged in')1011 const bodyTemplate = formData.get('body_template') as string12 const variableMatches = bodyTemplate.match(/\{\{(\w+)\}\}/g) || []13 const variables = variableMatches.map((v) => v.replace(/\{\{|\}\}/g, ''))1415 await supabase.from('sms_templates').insert({16 owner_id: user.id,17 name: formData.get('name') as string,18 body_template: bodyTemplate,19 variables,20 category: formData.get('category') as string,21 })2223 revalidatePath('/sms/templates')24}2526export async function launchCampaign(campaignId: string) {27 const supabase = await createClient()2829 const { data: campaign } = await supabase30 .from('campaigns')31 .select('*, sms_templates(*)')32 .eq('id', campaignId)33 .single()3435 if (!campaign) throw new Error('Campaign not found')3637 const { data: contacts } = await supabase38 .from('contacts')39 .select('*')40 .eq('opted_in', true)4142 if (!contacts?.length) throw new Error('No opted-in contacts')4344 await supabase45 .from('campaigns')46 .update({ status: 'sending' })47 .eq('id', campaignId)4849 const CHUNK_SIZE = 10050 for (let i = 0; i < contacts.length; i += CHUNK_SIZE) {51 const chunk = contacts.slice(i, i + CHUNK_SIZE)5253 await Promise.all(54 chunk.map((contact) =>55 fetch(`${process.env.NEXT_PUBLIC_APP_URL}/api/sms/send`, {56 method: 'POST',57 headers: { 'Content-Type': 'application/json' },58 body: JSON.stringify({59 contact_id: contact.id,60 template_id: campaign.sms_templates.id,61 variables: { first_name: contact.first_name },62 }),63 })64 )65 )6667 await supabase68 .from('campaigns')69 .update({ sent_count: Math.min(i + CHUNK_SIZE, contacts.length) })70 .eq('id', campaignId)71 }7273 await supabase74 .from('campaigns')75 .update({ status: 'completed' })76 .eq('id', campaignId)7778 revalidatePath('/sms/campaigns')79}8081export async function optOut(contactId: string) {82 const supabase = await createClient()8384 await supabase85 .from('contacts')86 .update({87 opted_in: false,88 opted_out_at: new Date().toISOString(),89 })90 .eq('id', contactId)9192 revalidatePath('/sms')93}Customization ideas
Add two-way conversational SMS
Build a chat-like interface that displays full conversation threads between your system and individual contacts, with the ability to send manual replies.
Build smart scheduling
Use contact timezone data to schedule campaigns so messages arrive during business hours in each recipient's local time, maximizing open rates.
Add A/B testing for templates
Split campaign recipients into groups, send different template variants, and compare delivery and response rates to optimize messaging.
Integrate with order system
Trigger transactional SMS automatically when order status changes (confirmed, shipped, delivered) using Supabase database triggers on an orders table.
Common pitfalls
Pitfall: Sending SMS to contacts who have not opted in
How to avoid: Always filter by opted_in = true before sending. The contacts table tracks opt-in timestamps for compliance records. Handle STOP keywords in the Twilio webhook to immediately set opted_in to false.
Pitfall: Not verifying the X-Twilio-Signature header on webhooks
How to avoid: Use twilio.validateRequest(authToken, signature, url, params) in the webhook handler. The URL must be the full production URL including https://.
Pitfall: Sending the entire campaign in a single serverless function invocation
How to avoid: Process contacts in chunks of 100, sending each chunk with Promise.all. Set maxDuration = 60 on the route if needed, and update the campaign's sent_count after each chunk so progress is visible.
Best practices
- Store TWILIO_ACCOUNT_SID, TWILIO_AUTH_TOKEN, and TWILIO_PHONE_NUMBER in V0's Vars tab without NEXT_PUBLIC_ prefix — they must be server-only
- Always check opted_in = true before sending any SMS to comply with TCPA regulations
- Verify X-Twilio-Signature on all webhook requests using twilio.validateRequest()
- Process bulk campaign sends in chunks of 100 to stay within serverless timeout limits
- Use V0's Design Mode (Option+D) to adjust Badge colors for delivery statuses without spending credits
- Auto-extract template variables from {{variable}} syntax when creating templates to keep the variables array accurate
AI prompts to try
Copy these prompts to build this project faster.
I'm building an SMS notification system with Next.js and Twilio. Write a webhook handler for app/api/webhooks/twilio/route.ts that receives delivery status callbacks and inbound messages. It should verify the X-Twilio-Signature header using twilio.validateRequest(), update message delivery status in a Supabase sms_messages table, and handle STOP keyword opt-outs by updating the contacts table. Use request.text() to get the raw body and parse as URLSearchParams. Return a TwiML empty Response.
Create a campaign summary Card component. Accept campaign data (name, status, template_name, sent_count, delivered_count, total_contacts, scheduled_at). Show the campaign name as heading, template name Badge, status Badge (color-coded: draft=gray, scheduled=blue, sending=yellow, completed=green), Progress bar for sent_count/total_contacts, and delivered/failed ratio. Use shadcn/ui Card, Badge, and Progress.
Frequently asked questions
Can I build this on V0's free tier?
V0 Free provides enough credits for the basic build. Premium ($20/month) is recommended because this project has multiple pages (message log, templates, campaigns, webhook) that benefit from prompt queuing.
How much does Twilio cost for SMS?
Twilio charges approximately $0.0079/message for US SMS. A free trial account includes $15 in credit and a trial phone number. You must verify recipient numbers during trial mode.
How do I handle Twilio webhook authentication?
Use twilio.validateRequest() in the webhook handler with your auth token, the X-Twilio-Signature header, the full production URL, and the parsed body parameters. This ensures only Twilio can send data to your endpoint.
How do I send bulk campaigns without timing out?
Process contacts in chunks of 100 using Promise.all for parallel sending within each chunk. Update the campaign's sent_count after each chunk. Set export const maxDuration = 60 on the route for longer campaigns.
How do I deploy the SMS system?
Click Share then Publish to Production in V0. After deploying, register the Twilio webhook URL (https://yourdomain.vercel.app/api/webhooks/twilio) in the Twilio Console under your phone number's messaging configuration.
Can RapidDev help build a custom SMS platform?
Yes. RapidDev has built 600+ apps including SMS platforms with multi-provider failover, smart scheduling, and compliance management. Book a free consultation to discuss your needs.
Talk to an Expert
Our team has built 600+ apps. Get personalized help with your project.
Book a free consultation