# How to Build SMS notification system with V0

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

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

## Before you start

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

## Step-by-step guide

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

```
// Paste this prompt into V0's AI chat:
// Build an SMS notification system. Create a Supabase schema with:
// 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)
// 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)
// 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)
// 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)
// RLS: users can only manage their own contacts, templates, and campaigns.
// 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.

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

const supabase = createClient(
  process.env.SUPABASE_URL!,
  process.env.SUPABASE_SERVICE_ROLE_KEY!
)

const twilioClient = twilio(
  process.env.TWILIO_ACCOUNT_SID!,
  process.env.TWILIO_AUTH_TOKEN!
)

export async function POST(req: NextRequest) {
  const { contact_id, template_id, variables } = await req.json()

  const { data: contact } = await supabase
    .from('contacts')
    .select('*')
    .eq('id', contact_id)
    .eq('opted_in', true)
    .single()

  if (!contact) {
    return NextResponse.json(
      { error: 'Contact not found or not opted in' },
      { status: 400 }
    )
  }

  const { data: template } = await supabase
    .from('sms_templates')
    .select('*')
    .eq('id', template_id)
    .single()

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

  let body = template.body_template
  for (const [key, value] of Object.entries(variables || {})) {
    body = body.replace(new RegExp(`{{${key}}}`, 'g'), value as string)
  }

  const message = await twilioClient.messages.create({
    body,
    to: `${contact.country_code}${contact.phone}`,
    from: process.env.TWILIO_PHONE_NUMBER!,
    statusCallback: `${process.env.NEXT_PUBLIC_APP_URL}/api/webhooks/twilio`,
  })

  await supabase.from('sms_messages').insert({
    contact_id,
    template_id,
    direction: 'outbound',
    body,
    status: 'queued',
    provider_message_id: message.sid,
    sent_at: new Date().toISOString(),
  })

  return NextResponse.json({ success: true, message_sid: message.sid })
}
```

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

```
// Paste this prompt into V0's AI chat:
// Build a Twilio webhook handler at app/api/webhooks/twilio/route.ts.
// Requirements:
// - POST handler that receives Twilio delivery status callbacks
// - Verify X-Twilio-Signature header using twilio.validateRequest()
//   with TWILIO_AUTH_TOKEN, the full request URL, and parsed body params
// - On delivery status: update sms_messages set status from Twilio's MessageStatus
//   (queued/sent/delivered/failed/undelivered) where provider_message_id = MessageSid
// - On inbound message (direction check): if body contains STOP/UNSUBSCRIBE,
//   update contacts set opted_in = false, opted_out_at = now()
//   Otherwise insert into sms_messages with direction = 'inbound'
// - Return TwiML response (empty <Response/>) with 200 status
// - Use request.text() to get raw body, then parse as URLSearchParams
//
// Also build the message log page at app/sms/page.tsx:
// - Table of messages with columns: phone, body preview, status Badge, direction, sent_at
// - Badge colors: queued=gray, sent=blue, delivered=green, failed=red, undelivered=orange
// - Filter by status and direction using Select dropdowns
// - 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.

```
// Paste this prompt into V0's AI chat:
// Build a campaign manager at app/sms/campaigns/page.tsx.
// Requirements:
// - List of campaigns as Cards showing name, template, status Badge, sent/delivered counts
// - "Create Campaign" Button opens Dialog with:
//   - name Input, template Select (from sms_templates)
//   - Contact segment selection: all opted-in, or filter by criteria
//   - Calendar + Popover DatePicker for scheduling (optional — leave empty for immediate)
//   - Preview of the template body
// - Campaign detail view:
//   - Progress bar showing sent_count / total contacts
//   - Delivered vs failed ratio
//   - Table of individual message statuses
// - "Launch Campaign" Button with confirmation Dialog
// - Server Action launchCampaign() that:
//   - Queries contacts matching segment_criteria
//   - Sends messages in chunks of 100 (to stay within serverless timeout)
//   - Updates campaign sent_count after each chunk
// - 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 example

File: `app/actions/sms.ts`

```typescript
'use server'

import { createClient } from '@/lib/supabase/server'
import { revalidatePath } from 'next/cache'

export async function createTemplate(formData: FormData) {
  const supabase = await createClient()
  const { data: { user } } = await supabase.auth.getUser()
  if (!user) throw new Error('Must be logged in')

  const bodyTemplate = formData.get('body_template') as string
  const variableMatches = bodyTemplate.match(/\{\{(\w+)\}\}/g) || []
  const variables = variableMatches.map((v) => v.replace(/\{\{|\}\}/g, ''))

  await supabase.from('sms_templates').insert({
    owner_id: user.id,
    name: formData.get('name') as string,
    body_template: bodyTemplate,
    variables,
    category: formData.get('category') as string,
  })

  revalidatePath('/sms/templates')
}

export async function launchCampaign(campaignId: string) {
  const supabase = await createClient()

  const { data: campaign } = await supabase
    .from('campaigns')
    .select('*, sms_templates(*)')
    .eq('id', campaignId)
    .single()

  if (!campaign) throw new Error('Campaign not found')

  const { data: contacts } = await supabase
    .from('contacts')
    .select('*')
    .eq('opted_in', true)

  if (!contacts?.length) throw new Error('No opted-in contacts')

  await supabase
    .from('campaigns')
    .update({ status: 'sending' })
    .eq('id', campaignId)

  const CHUNK_SIZE = 100
  for (let i = 0; i < contacts.length; i += CHUNK_SIZE) {
    const chunk = contacts.slice(i, i + CHUNK_SIZE)

    await Promise.all(
      chunk.map((contact) =>
        fetch(`${process.env.NEXT_PUBLIC_APP_URL}/api/sms/send`, {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({
            contact_id: contact.id,
            template_id: campaign.sms_templates.id,
            variables: { first_name: contact.first_name },
          }),
        })
      )
    )

    await supabase
      .from('campaigns')
      .update({ sent_count: Math.min(i + CHUNK_SIZE, contacts.length) })
      .eq('id', campaignId)
  }

  await supabase
    .from('campaigns')
    .update({ status: 'completed' })
    .eq('id', campaignId)

  revalidatePath('/sms/campaigns')
}

export async function optOut(contactId: string) {
  const supabase = await createClient()

  await supabase
    .from('contacts')
    .update({
      opted_in: false,
      opted_out_at: new Date().toISOString(),
    })
    .eq('id', contactId)

  revalidatePath('/sms')
}
```

## Common mistakes

- **Sending SMS to contacts who have not opted in** — TCPA regulations require explicit consent before sending marketing messages. Violations can result in fines of $500-$1,500 per unsolicited message. Fix: 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.
- **Not verifying the X-Twilio-Signature header on webhooks** — Without signature verification, anyone can send fake delivery status updates or inbound messages to your webhook endpoint, corrupting your data. Fix: Use twilio.validateRequest(authToken, signature, url, params) in the webhook handler. The URL must be the full production URL including https://.
- **Sending the entire campaign in a single serverless function invocation** — Vercel serverless functions have a default 10-second timeout. Sending thousands of messages sequentially will exceed this limit and fail partway through. Fix: 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

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

---

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