Build an email automation system with V0 using Next.js, Resend for email delivery, Supabase for subscriber and campaign management, and Vercel Cron for drip sequences. You'll create a subscriber list, campaign editor, visual sequence builder, and open-tracking analytics — all in about 2-4 hours without touching a terminal.
What you're building
Email marketing remains one of the highest-ROI channels for businesses. Whether you need automated welcome sequences, product launch campaigns, or drip nurture flows, an email automation system lets you send the right message at the right time without manual effort.
V0 accelerates this by generating the subscriber management UI, campaign editor, and sequence builder from prompts. Use prompt queuing to build all three interfaces in one session. Resend handles reliable email delivery with built-in tracking, and Vercel Cron jobs automate the drip sequence processing.
The architecture uses Next.js App Router with Server Components for the dashboard and subscriber list, client components for the campaign editor, API routes for email sending and webhook processing, Supabase for all data storage, and Vercel Cron for scheduled sequence step execution.
Final result
A complete email automation platform with subscriber management, campaign sending, automated drip sequences, open tracking, and delivery analytics.
Tech stack
Prerequisites
- A V0 account (Premium plan recommended for the complex multi-page build)
- A Supabase project (free tier works — connect via V0's Connect panel)
- A Resend account with API key (free tier: 3,000 emails/month)
- A verified sending domain in Resend (or use the onboarding domain for testing)
Build steps
Set up the database schema for email automation
Open V0, create a new project, and connect Supabase via the Connect panel. Then prompt V0 to create the schema for subscribers, campaigns, sequences, email logs, and tracking.
1// Paste this prompt into V0's AI chat:2// Build an email automation system. Create a Supabase schema with:3// 1. subscribers: id (uuid PK), email (text unique), first_name (text), tags (text[]), status (text default 'active' check in 'active','unsubscribed','bounced'), subscribed_at (timestamptz default now()), unsubscribed_at (timestamptz)4// 2. campaigns: id (uuid PK), name (text), subject (text), html_body (text), plain_body (text), status (text default 'draft'), scheduled_at (timestamptz), sent_at (timestamptz), created_by (uuid FK to auth.users), created_at (timestamptz)5// 3. sequences: id (uuid PK), name (text), trigger_event (text), created_by (uuid FK to auth.users), is_active (boolean default true)6// 4. sequence_steps: id (uuid PK), sequence_id (uuid FK to sequences), step_order (int), delay_hours (int), subject (text), html_body (text), created_at (timestamptz)7// 5. email_logs: id (uuid PK), subscriber_id (uuid FK), campaign_id (uuid FK nullable), sequence_id (uuid FK nullable), step_order (int), status (text check in 'queued','sent','delivered','opened','clicked','bounced'), sent_at (timestamptz), opened_at (timestamptz)8// 6. tracking_pixels: id (uuid PK), email_log_id (uuid FK), token (text unique default gen_random_uuid())9// Add RLS policies for authenticated access.Pro tip: Queue this schema prompt first, then immediately queue the subscriber management UI prompt. V0 processes up to 10 prompts sequentially.
Expected result: Supabase is connected with all six tables created and RLS policies configured.
Build the subscriber management page
Create the subscriber list page with filtering by tags and status, bulk selection, and a CSV import feature. This page uses a Server Component for the initial data load and a client component for interactive filtering.
1// Paste this prompt into V0's AI chat:2// Build a subscriber management page at app/subscribers/page.tsx.3// Requirements:4// - Server Component that fetches subscribers from Supabase with pagination (20 per page)5// - Display in a shadcn/ui Table with columns: Checkbox for bulk select, email, first_name, tags (as Badge components), status (Badge with color), subscribed_at date6// - Add filter controls above the table: Select for status filter, Input for search by email, and a tag filter using Command/Popover7// - Bulk action buttons above the table: "Delete Selected" and "Tag Selected" that appear when checkboxes are checked8// - An "Import CSV" Button that opens a Dialog with a file upload Input accepting .csv files9// - The import Server Action parses the CSV, validates emails with Zod, and bulk inserts into subscribers10// - An "Add Subscriber" Button that opens a Dialog with email Input, first_name Input, and tags Input11// - Show total subscriber count and active/unsubscribed breakdown as Card stats at the topExpected result: The subscriber page shows a filterable, searchable table with bulk selection. CSV import and manual add dialogs work for adding new subscribers.
Create the email sending API route with open tracking
Build the API route that sends emails via Resend with an injected tracking pixel. Also create the tracking pixel route that records opens and returns a transparent 1x1 GIF.
1import { NextRequest, NextResponse } from 'next/server'2import { Resend } from 'resend'3import { createClient } from '@supabase/supabase-js'45const resend = new Resend(process.env.RESEND_API_KEY)6const supabase = createClient(7 process.env.SUPABASE_URL!,8 process.env.SUPABASE_SERVICE_ROLE_KEY!9)1011export async function POST(req: NextRequest) {12 const { subscriberIds, subject, htmlBody, campaignId } = await req.json()1314 const { data: subscribers } = await supabase15 .from('subscribers')16 .select('id, email, first_name')17 .in('id', subscriberIds)18 .eq('status', 'active')1920 if (!subscribers?.length) {21 return NextResponse.json({ error: 'No active subscribers' }, { status: 400 })22 }2324 const results = []2526 for (const sub of subscribers) {27 const { data: logEntry } = await supabase28 .from('email_logs')29 .insert({30 subscriber_id: sub.id,31 campaign_id: campaignId,32 status: 'queued',33 })34 .select()35 .single()3637 const { data: pixel } = await supabase38 .from('tracking_pixels')39 .insert({ email_log_id: logEntry!.id })40 .select('token')41 .single()4243 const personalizedHtml = htmlBody44 .replace('{{first_name}}', sub.first_name || 'there')45 + `<img src="${req.nextUrl.origin}/api/track/${pixel!.token}" width="1" height="1" alt="" />`4647 const { error } = await resend.emails.send({48 from: 'newsletter@yourdomain.com',49 to: sub.email,50 subject,51 html: personalizedHtml,52 })5354 await supabase55 .from('email_logs')56 .update({ status: error ? 'bounced' : 'sent', sent_at: new Date().toISOString() })57 .eq('id', logEntry!.id)5859 results.push({ email: sub.email, success: !error })60 }6162 return NextResponse.json({ results })63}Pro tip: For large subscriber lists, consider batching sends in groups of 50 with a small delay between batches to respect Resend's rate limits on the free tier.
Expected result: The send route delivers emails via Resend with tracking pixels injected. Each email is logged with its delivery status.
Build the tracking pixel endpoint
Create a GET route that serves a transparent 1x1 pixel. When an email client loads this image, it records the open event in the database. Cache-Control: no-store ensures the pixel is fetched on every view.
1import { NextRequest, NextResponse } from 'next/server'2import { createClient } from '@supabase/supabase-js'34const supabase = createClient(5 process.env.SUPABASE_URL!,6 process.env.SUPABASE_SERVICE_ROLE_KEY!7)89const TRANSPARENT_GIF = Buffer.from(10 'R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7',11 'base64'12)1314export async function GET(15 req: NextRequest,16 { params }: { params: Promise<{ token: string }> }17) {18 const { token } = await params1920 const { data: pixel } = await supabase21 .from('tracking_pixels')22 .select('email_log_id')23 .eq('token', token)24 .single()2526 if (pixel) {27 await supabase28 .from('email_logs')29 .update({ status: 'opened', opened_at: new Date().toISOString() })30 .eq('id', pixel.email_log_id)31 .is('opened_at', null)32 }3334 return new NextResponse(TRANSPARENT_GIF, {35 headers: {36 'Content-Type': 'image/gif',37 'Cache-Control': 'no-store, no-cache, must-revalidate',38 },39 })40}Expected result: When an email is opened, the tracking pixel loads and records the open timestamp. The route returns a transparent 1x1 GIF with no-cache headers.
Create the drip sequence processor with Vercel Cron
Build a cron endpoint that runs every 15 minutes, checks which sequence steps are due based on subscriber enrollment time and step delay, and sends the emails. Configure it in vercel.json.
1import { NextRequest, NextResponse } from 'next/server'2import { createClient } from '@supabase/supabase-js'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)1011export async function GET(req: NextRequest) {12 const authHeader = req.headers.get('authorization')13 if (authHeader !== `Bearer ${process.env.CRON_SECRET}`) {14 return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })15 }1617 const { data: activeSequences } = await supabase18 .from('sequences')19 .select('id, sequence_steps(*)')20 .eq('is_active', true)2122 let sent = 02324 for (const seq of activeSequences ?? []) {25 for (const step of seq.sequence_steps) {26 const cutoff = new Date(27 Date.now() - step.delay_hours * 60 * 60 * 100028 ).toISOString()2930 const { data: eligibleSubs } = await supabase31 .from('subscribers')32 .select('id, email, first_name')33 .eq('status', 'active')34 .lte('subscribed_at', cutoff)35 .not('id', 'in', `(SELECT subscriber_id FROM email_logs WHERE sequence_id = '${seq.id}' AND step_order = ${step.step_order})`)3637 for (const sub of eligibleSubs ?? []) {38 await resend.emails.send({39 from: 'newsletter@yourdomain.com',40 to: sub.email,41 subject: step.subject.replace('{{first_name}}', sub.first_name || 'there'),42 html: step.html_body,43 })4445 await supabase.from('email_logs').insert({46 subscriber_id: sub.id,47 sequence_id: seq.id,48 step_order: step.step_order,49 status: 'sent',50 sent_at: new Date().toISOString(),51 })52 sent++53 }54 }55 }5657 return NextResponse.json({ processed: sent })58}Expected result: Every 15 minutes, the cron job checks for subscribers who are due for the next step in their sequence and sends the email automatically.
Build the campaign editor and analytics dashboard
Create the campaign editor with subject line, HTML body preview, and scheduling. Build the dashboard with open rate, click rate, and campaign comparison charts.
1// Paste this prompt into V0's AI chat:2// Build two pages for the email automation system:3// 1. app/campaigns/new/page.tsx — a 'use client' campaign editor with:4// - Input for campaign name and subject line5// - Textarea for HTML email body with placeholder text6// - A preview panel (side by side) that renders the HTML in an iframe7// - DatePicker for scheduling send time (optional, immediate if not set)8// - Tabs to switch between editing subscribers (select by tags using Checkbox list) and the email content9// - "Send Now" Button and "Schedule" Button that call Server Actions10// - Card wrapper for each section with clear labels11// 2. app/page.tsx — dashboard with:12// - Card stats at top: total subscribers, emails sent today, average open rate, average click rate13// - Recharts BarChart showing emails sent per day for the last 30 days14// - Table of recent campaigns with columns: name, subject, sent_at, total sent, open rate %, status Badge15// - Tabs for switching between Campaigns, Sequences, and Subscribers quick views16// Use shadcn/ui components throughout.Pro tip: Configure the Vercel Cron job in vercel.json: {"crons": [{"path": "/api/cron/sequences", "schedule": "*/15 * * * *"}]}. Set CRON_SECRET in the Vars tab to authenticate the endpoint.
Expected result: The campaign editor lets you compose emails with a live preview and schedule sends. The dashboard shows delivery stats and open rates in charts.
Complete code
1import { NextRequest, NextResponse } from 'next/server'2import { createClient } from '@supabase/supabase-js'34const supabase = createClient(5 process.env.SUPABASE_URL!,6 process.env.SUPABASE_SERVICE_ROLE_KEY!7)89const TRANSPARENT_GIF = Buffer.from(10 'R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7',11 'base64'12)1314export async function GET(15 req: NextRequest,16 { params }: { params: Promise<{ token: string }> }17) {18 const { token } = await params1920 const { data: pixel } = await supabase21 .from('tracking_pixels')22 .select('email_log_id')23 .eq('token', token)24 .single()2526 if (pixel) {27 await supabase28 .from('email_logs')29 .update({30 status: 'opened',31 opened_at: new Date().toISOString(),32 })33 .eq('id', pixel.email_log_id)34 .is('opened_at', null)35 }3637 return new NextResponse(TRANSPARENT_GIF, {38 headers: {39 'Content-Type': 'image/gif',40 'Cache-Control': 'no-store, no-cache, must-revalidate',41 },42 })43}Customization ideas
Add click tracking with redirect links
Wrap all links in emails with a redirect route (/api/click/[token]) that logs the click event before redirecting to the original URL.
Add A/B testing for subject lines
Split subscribers into groups, send different subject lines, measure open rates after 4 hours, then send the winning version to the remaining subscribers.
Add unsubscribe handling
Include a one-click unsubscribe link in every email footer that hits an API route to update the subscriber status to 'unsubscribed' and show a confirmation page.
Add template library
Create a library of reusable email templates with variables like first_name and company that can be selected when creating new campaigns or sequence steps.
Add segment builder with conditions
Build a visual segment builder that filters subscribers by tags, signup date, engagement score, and past campaign interactions for targeted sends.
Common pitfalls
Pitfall: Not setting Cache-Control: no-store on the tracking pixel response
How to avoid: Return the tracking pixel with headers Cache-Control: no-store, no-cache, must-revalidate to ensure every open triggers a server request.
Pitfall: Forgetting to authenticate the Vercel Cron endpoint
How to avoid: Check the Authorization header against a CRON_SECRET environment variable. Vercel automatically sends this header for cron invocations.
Pitfall: Sending emails synchronously in the API route without batching
How to avoid: Batch sends into groups of 50 with the Resend batch API, or use a background processing pattern with a queue table that the cron job drains.
Best practices
- Inject tracking pixels at send time, not in the template, so each email gets a unique token for accurate per-recipient open tracking.
- Use Vercel Cron jobs for drip sequence processing — configure in vercel.json with CRON_SECRET authentication in the Vars tab.
- Batch email sends to respect Resend rate limits (free tier: 3,000/month, 100/day). Use the Resend batch endpoint for campaigns with many recipients.
- Store email content as both HTML and plain text for maximum deliverability across email clients.
- Use Server Components for the dashboard and subscriber list. Only add 'use client' for the campaign editor where interactive features are needed.
- Leverage V0's prompt queuing — queue the subscriber UI, campaign editor, and sequence builder as three separate prompts in one session.
- Set RESEND_API_KEY and CRON_SECRET in the Vars tab without NEXT_PUBLIC_ prefix — both are server-side only.
AI prompts to try
Copy these prompts to build this project faster.
I'm building an email automation system with Next.js and Resend. I need to implement open tracking using a transparent 1x1 GIF pixel. Show me how to create the tracking pixel injection at send time, the tracking endpoint that records opens, and how to calculate open rates from the email_logs table. Include proper Cache-Control headers to prevent caching.
Build the drip sequence builder UI for an email automation system. Create app/sequences/[id]/page.tsx as a 'use client' component with a vertical timeline layout. Each step is a Card showing step_order Badge, delay_hours Input, subject Input, and html_body Textarea. Add a Button to add new steps at the bottom. Include a Switch at the top to activate/deactivate the sequence. Use drag-and-drop reordering with dnd-kit. Save changes via Server Action.
Frequently asked questions
How does open tracking work without JavaScript?
A unique 1x1 transparent GIF is injected into each email's HTML body at send time. When the recipient opens the email, their email client loads the image from your API route, which records the open event and returns the GIF. Cache-Control: no-store ensures each open is tracked.
What email service does this use?
Resend, which offers 3,000 free emails per month and has a simple API. Set your RESEND_API_KEY in V0's Vars tab. You can swap Resend for SendGrid, Mailgun, or Amazon SES by changing the send function.
How do drip sequences automatically send emails?
A Vercel Cron job runs every 15 minutes, checking which subscribers are due for their next sequence step based on their enrollment time and the step's delay_hours. It sends pending emails and logs the results.
Can I schedule campaigns for a specific date and time?
Yes. The campaign editor includes a DatePicker for scheduling. Scheduled campaigns are stored with a scheduled_at timestamp. The cron job checks for campaigns where scheduled_at has passed and status is 'scheduled', then sends them.
How do I handle unsubscribes to comply with email regulations?
Include a one-click unsubscribe link in every email footer. Create an API route at /api/unsubscribe/[token] that updates the subscriber status to 'unsubscribed' and sets unsubscribed_at. Filter out unsubscribed users in all send queries.
Can RapidDev help build a custom email automation system?
Yes. RapidDev has built 600+ apps including complex marketing automation platforms with advanced segmentation, A/B testing, and CRM integrations. Book a free consultation to discuss your requirements.
What V0 plan do I need for this project?
The Premium plan ($20/month) is recommended since the multi-page system requires numerous prompt iterations. The free tier can build the basic subscriber list but may require manual coding for the sequence processor and tracking system.
Talk to an Expert
Our team has built 600+ apps. Get personalized help with your project.
Book a free consultation