Skip to main content
RapidDev - Software Development Agency

How to Build SMS notification system with V0

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'll build

  • SMS message log with color-coded delivery status Badges (queued, sent, delivered, failed)
  • Template builder with variable placeholders like {{first_name}} and category tagging
  • Campaign manager with contact segment selection, scheduling, and send progress tracking
  • Twilio integration for sending SMS via API route with delivery status webhook
  • Opt-in/out management with consent timestamps for compliance
  • Inbound message handling via Twilio webhook for STOP/opt-out keywords
Book a free consultation
4.9Clutch rating
600+Happy partners
17+Countries served
190+Team members
Intermediate10 min read1-2 hoursV0 Premium or higherApril 2026RapidDev Engineering Team
TL;DR

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

V0AI Code Generator
Next.jsFull-Stack Framework
Tailwind CSSStyling
shadcn/uiComponent Library
SupabaseDatabase
TwilioSMS Provider

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

1

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.

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

2

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.

app/api/sms/send/route.ts
1import { NextRequest, NextResponse } from 'next/server'
2import { createClient } from '@supabase/supabase-js'
3import twilio from 'twilio'
4
5const supabase = createClient(
6 process.env.SUPABASE_URL!,
7 process.env.SUPABASE_SERVICE_ROLE_KEY!
8)
9
10const twilioClient = twilio(
11 process.env.TWILIO_ACCOUNT_SID!,
12 process.env.TWILIO_AUTH_TOKEN!
13)
14
15export async function POST(req: NextRequest) {
16 const { contact_id, template_id, variables } = await req.json()
17
18 const { data: contact } = await supabase
19 .from('contacts')
20 .select('*')
21 .eq('id', contact_id)
22 .eq('opted_in', true)
23 .single()
24
25 if (!contact) {
26 return NextResponse.json(
27 { error: 'Contact not found or not opted in' },
28 { status: 400 }
29 )
30 }
31
32 const { data: template } = await supabase
33 .from('sms_templates')
34 .select('*')
35 .eq('id', template_id)
36 .single()
37
38 if (!template) {
39 return NextResponse.json({ error: 'Template not found' }, { status: 404 })
40 }
41
42 let body = template.body_template
43 for (const [key, value] of Object.entries(variables || {})) {
44 body = body.replace(new RegExp(`{{${key}}}`, 'g'), value as string)
45 }
46
47 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 })
53
54 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 })
63
64 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.

3

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.

prompt.txt
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 callbacks
5// - Verify X-Twilio-Signature header using twilio.validateRequest()
6// with TWILIO_AUTH_TOKEN, the full request URL, and parsed body params
7// - On delivery status: update sms_messages set status from Twilio's MessageStatus
8// (queued/sent/delivered/failed/undelivered) where provider_message_id = MessageSid
9// - 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 status
13// - Use request.text() to get raw body, then parse as URLSearchParams
14//
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_at
17// - Badge colors: queued=gray, sent=blue, delivered=green, failed=red, undelivered=orange
18// - Filter by status and direction using Select dropdowns
19// - 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.

4

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.

prompt.txt
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 counts
5// - "Create Campaign" Button opens Dialog with:
6// - name Input, template Select (from sms_templates)
7// - Contact segment selection: all opted-in, or filter by criteria
8// - Calendar + Popover DatePicker for scheduling (optional — leave empty for immediate)
9// - Preview of the template body
10// - Campaign detail view:
11// - Progress bar showing sent_count / total contacts
12// - Delivered vs failed ratio
13// - Table of individual message statuses
14// - "Launch Campaign" Button with confirmation Dialog
15// - Server Action launchCampaign() that:
16// - Queries contacts matching segment_criteria
17// - Sends messages in chunks of 100 (to stay within serverless timeout)
18// - Updates campaign sent_count after each chunk
19// - Use shadcn/ui Card, Badge, Button, Dialog, Calendar, Popover, Progress, Table, Select, Toast

Expected 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

app/actions/sms.ts
1'use server'
2
3import { createClient } from '@/lib/supabase/server'
4import { revalidatePath } from 'next/cache'
5
6export 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')
10
11 const bodyTemplate = formData.get('body_template') as string
12 const variableMatches = bodyTemplate.match(/\{\{(\w+)\}\}/g) || []
13 const variables = variableMatches.map((v) => v.replace(/\{\{|\}\}/g, ''))
14
15 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 })
22
23 revalidatePath('/sms/templates')
24}
25
26export async function launchCampaign(campaignId: string) {
27 const supabase = await createClient()
28
29 const { data: campaign } = await supabase
30 .from('campaigns')
31 .select('*, sms_templates(*)')
32 .eq('id', campaignId)
33 .single()
34
35 if (!campaign) throw new Error('Campaign not found')
36
37 const { data: contacts } = await supabase
38 .from('contacts')
39 .select('*')
40 .eq('opted_in', true)
41
42 if (!contacts?.length) throw new Error('No opted-in contacts')
43
44 await supabase
45 .from('campaigns')
46 .update({ status: 'sending' })
47 .eq('id', campaignId)
48
49 const CHUNK_SIZE = 100
50 for (let i = 0; i < contacts.length; i += CHUNK_SIZE) {
51 const chunk = contacts.slice(i, i + CHUNK_SIZE)
52
53 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 )
66
67 await supabase
68 .from('campaigns')
69 .update({ sent_count: Math.min(i + CHUNK_SIZE, contacts.length) })
70 .eq('id', campaignId)
71 }
72
73 await supabase
74 .from('campaigns')
75 .update({ status: 'completed' })
76 .eq('id', campaignId)
77
78 revalidatePath('/sms/campaigns')
79}
80
81export async function optOut(contactId: string) {
82 const supabase = await createClient()
83
84 await supabase
85 .from('contacts')
86 .update({
87 opted_in: false,
88 opted_out_at: new Date().toISOString(),
89 })
90 .eq('id', contactId)
91
92 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.

ChatGPT Prompt

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.

Build Prompt

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.

RapidDev

Talk to an Expert

Our team has built 600+ apps. Get personalized help with your project.

Book a free consultation

Need help building your app?

Our experts have built 600+ apps and can accelerate your development. Book a free consultation — no strings attached.

Book a free consultation

We put the rapid in RapidDev

Need a dedicated strategic tech and growth partner? Discover what RapidDev can do for your business! Book a call with our team to schedule a free, no-obligation consultation. We'll discuss your project and provide a custom quote at no cost.