Build a subscription box service with V0 featuring plan selection, preference quizzes, Stripe recurring billing, webhook-driven box creation, and an admin curation dashboard. You'll create a landing page with plan comparison, customer account management, and idempotent webhook handlers — all in about 2-4 hours.
What you're building
Subscription box businesses like Birchbox and HelloFresh generate predictable recurring revenue by delivering curated products monthly. Building one requires recurring billing, preference tracking, box curation, and fulfillment management.
V0 handles the complexity by generating the plan comparison UI, checkout flow, webhook handlers, and admin dashboard from prompts. Stripe manages the entire billing lifecycle — subscriptions, invoices, plan changes, and cancellations. The Vercel Marketplace integration auto-provisions Stripe keys in one click.
The architecture uses Stripe Checkout in subscription mode for signups, webhook handlers for billing lifecycle events (invoice.paid triggers new box creation), Server Actions for preference and subscription management, and an admin interface for curating box contents. Supabase stores subscribers, boxes, products, and preferences.
Final result
A subscription box platform with plan selection, preference customization, Stripe recurring billing, automated box creation on payment, and an admin curation dashboard.
Tech stack
Prerequisites
- A V0 account (Premium recommended for complex multi-file generation)
- A Supabase project (free tier works — connect via V0's Connect panel)
- A Stripe account with subscription products configured (test mode is free)
- Stripe Price IDs for your subscription plans (monthly, quarterly, annual)
- Product catalog data for items that go into boxes
Build steps
Set up the database schema for plans, subscribers, and boxes
Open V0 and create a new project. Use the Connect panel to add Supabase and Stripe via Vercel Marketplace. Then create the schema for subscription plans, subscribers with preferences, boxes with items, and a products catalog.
1// Paste this prompt into V0's AI chat:2// Create a Supabase schema for a subscription box service:3// 1. plans table: id (uuid PK), name (text), description (text), price_cents (int), interval (text — 'monthly', 'quarterly', 'annual'), stripe_price_id (text NOT NULL), is_active (boolean DEFAULT true)4// 2. subscribers table: id (uuid PK), user_id (uuid FK), plan_id (uuid FK), stripe_customer_id (text), stripe_subscription_id (text), status (text — 'active', 'paused', 'cancelled', 'past_due'), preferences (jsonb — stores size/flavor/dietary prefs), started_at (timestamptz), current_period_end (timestamptz)5// 3. boxes table: id (uuid PK), subscriber_id (uuid FK), period_start (date), period_end (date), status (text — 'curating', 'shipped', 'delivered'), tracking_number (text nullable), shipped_at (timestamptz nullable), UNIQUE(subscriber_id, period_start) for idempotency6// 4. box_items table: id (uuid PK), box_id (uuid FK), product_id (uuid FK), quantity (int DEFAULT 1)7// 5. products table: id (uuid PK), name (text), description (text), category (text), image_url (text), cost_cents (int), stock (int)8// Add RLS so users see only their own subscriptions and boxes.9// Seed 3 plans: Basic $29/mo, Premium $49/mo, Deluxe $79/mo.Pro tip: After connecting Stripe via Vercel Marketplace, check V0's Vars tab to confirm STRIPE_SECRET_KEY and NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY are auto-provisioned. Create subscription Price IDs in Stripe Dashboard to populate stripe_price_id in the plans table.
Expected result: Five tables created with RLS policies, three plans seeded. Stripe keys auto-provisioned in Vars tab from Vercel Marketplace.
Build the plan comparison landing page with preference quiz
Create the landing page with plan comparison Card components and a preference quiz form. Users select a plan, answer preference questions, and proceed to Stripe Checkout.
1import { createClient } from '@/lib/supabase/server'2import { Card, CardContent, CardHeader, CardTitle, CardFooter } from '@/components/ui/card'3import { Badge } from '@/components/ui/badge'4import { Button } from '@/components/ui/button'5import Link from 'next/link'67export default async function LandingPage() {8 const supabase = await createClient()9 const { data: plans } = await supabase10 .from('plans')11 .select('*')12 .eq('is_active', true)13 .order('price_cents')1415 return (16 <div className="max-w-5xl mx-auto p-6">17 <h1 className="text-4xl font-bold text-center mb-4">18 Curated boxes delivered to your door19 </h1>20 <p className="text-center text-muted-foreground mb-8">21 Choose your plan and customize your preferences.22 </p>23 <div className="grid grid-cols-1 md:grid-cols-3 gap-6">24 {plans?.map((plan, i) => (25 <Card key={plan.id} className={i === 1 ? 'border-primary' : ''}>26 <CardHeader>27 <div className="flex justify-between items-center">28 <CardTitle>{plan.name}</CardTitle>29 {i === 1 && <Badge>Most Popular</Badge>}30 </div>31 </CardHeader>32 <CardContent>33 <p className="text-3xl font-bold">34 ${(plan.price_cents / 100).toFixed(0)}35 <span className="text-sm font-normal text-muted-foreground">36 /{plan.interval}37 </span>38 </p>39 <p className="mt-2 text-muted-foreground">{plan.description}</p>40 </CardContent>41 <CardFooter>42 <Link href={`/subscribe?plan=${plan.id}`} className="w-full">43 <Button className="w-full">44 {i === 1 ? 'Get Started' : 'Choose Plan'}45 </Button>46 </Link>47 </CardFooter>48 </Card>49 ))}50 </div>51 </div>52 )53}Expected result: A landing page with three plan Cards showing name, price, description, and a CTA Button. The middle plan has a 'Most Popular' Badge and highlighted border.
Create the Stripe subscription checkout API route
Build an API route that creates a Stripe Checkout session in subscription mode. The checkout session includes the selected plan's price_id, trial period, and subscriber preferences in metadata.
1import { NextRequest, NextResponse } from 'next/server'2import Stripe from 'stripe'3import { createClient } from '@supabase/supabase-js'45const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)6const supabase = createClient(7 process.env.NEXT_PUBLIC_SUPABASE_URL!,8 process.env.SUPABASE_SERVICE_ROLE_KEY!9)1011export async function POST(req: NextRequest) {12 const { planId, preferences } = await req.json()1314 const { data: plan } = await supabase15 .from('plans')16 .select('stripe_price_id, name')17 .eq('id', planId)18 .single()1920 if (!plan) {21 return NextResponse.json({ error: 'Plan not found' }, { status: 404 })22 }2324 const session = await stripe.checkout.sessions.create({25 payment_method_types: ['card'],26 line_items: [27 {28 price: plan.stripe_price_id,29 quantity: 1,30 },31 ],32 mode: 'subscription',33 subscription_data: {34 trial_period_days: 7,35 },36 success_url: `${req.nextUrl.origin}/account?subscribed=true`,37 cancel_url: `${req.nextUrl.origin}/subscribe?plan=${planId}`,38 metadata: {39 plan_id: planId,40 preferences: JSON.stringify(preferences),41 },42 })4344 return NextResponse.json({ url: session.url })45}Expected result: POST with planId and preferences creates a Stripe Checkout session in subscription mode with 7-day trial and returns the checkout URL for redirect.
Build the webhook handler with idempotent box creation
Create a Stripe webhook handler that processes invoice.paid (creates new box), customer.subscription.updated (syncs status), and customer.subscription.deleted (cancels). Uses a unique constraint to prevent duplicate boxes.
1import { NextRequest, NextResponse } from 'next/server'2import Stripe from 'stripe'3import { createClient } from '@supabase/supabase-js'45const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)6const supabase = createClient(7 process.env.NEXT_PUBLIC_SUPABASE_URL!,8 process.env.SUPABASE_SERVICE_ROLE_KEY!9)1011export async function POST(req: NextRequest) {12 const rawBody = await req.text()13 const sig = req.headers.get('stripe-signature')!1415 let event: Stripe.Event16 try {17 event = stripe.webhooks.constructEvent(18 rawBody,19 sig,20 process.env.STRIPE_WEBHOOK_SECRET!21 )22 } catch {23 return NextResponse.json({ error: 'Invalid signature' }, { status: 400 })24 }2526 if (event.type === 'invoice.paid') {27 const invoice = event.data.object as Stripe.Invoice28 const subscriptionId = invoice.subscription as string2930 const { data: subscriber } = await supabase31 .from('subscribers')32 .select('id')33 .eq('stripe_subscription_id', subscriptionId)34 .single()3536 if (subscriber) {37 const periodStart = new Date(invoice.period_start * 1000)38 .toISOString().split('T')[0]39 const periodEnd = new Date(invoice.period_end * 1000)40 .toISOString().split('T')[0]4142 await supabase.from('boxes').upsert(43 {44 subscriber_id: subscriber.id,45 period_start: periodStart,46 period_end: periodEnd,47 status: 'curating',48 },49 { onConflict: 'subscriber_id,period_start' }50 )51 }52 }5354 if (event.type === 'customer.subscription.updated') {55 const subscription = event.data.object as Stripe.Subscription56 await supabase57 .from('subscribers')58 .update({59 status: subscription.status === 'active' ? 'active' : 'past_due',60 current_period_end: new Date(subscription.current_period_end * 1000).toISOString(),61 })62 .eq('stripe_subscription_id', subscription.id)63 }6465 if (event.type === 'customer.subscription.deleted') {66 const subscription = event.data.object as Stripe.Subscription67 await supabase68 .from('subscribers')69 .update({ status: 'cancelled' })70 .eq('stripe_subscription_id', subscription.id)71 }7273 return NextResponse.json({ received: true })74}Pro tip: The UNIQUE constraint on (subscriber_id, period_start) combined with upsert makes box creation idempotent. Stripe can send invoice.paid multiple times — the upsert prevents duplicate boxes.
Expected result: Successful invoice payment creates a new box for the subscriber. Subscription updates sync status. Cancellations mark the subscriber as cancelled. Duplicate webhook events are handled safely.
Build the customer account page with subscription management
Create a customer account page with Tabs for subscription details, box history, and preference editing. Include pause/cancel functionality via Stripe Customer Portal.
1// Paste this prompt into V0's AI chat:2// Build a customer account page at app/account/page.tsx with:3// 1. Tabs component with three tabs: Subscription, Boxes, Preferences4// 2. Subscription tab: Card showing current plan name, status Badge (active=green, paused=yellow, cancelled=red), next billing date, and Buttons for "Manage Subscription" (opens Stripe Customer Portal) and "Cancel"5// 3. Boxes tab: Timeline or Card list of past boxes showing period dates, status Badge (curating=blue, shipped=purple, delivered=green), tracking number link, and list of items in each box6// 4. Preferences tab: Form with Checkbox groups for dietary preferences, Select for size, Select for flavor preferences, and Save Button that calls a Server Action7// 5. AlertDialog for cancel confirmation8// Use Server Component for data fetching, client components only for interactive elements.9// Fetch data from Supabase subscribers, boxes, and box_items tables joined with products.Expected result: An account page with three Tabs showing subscription status, box history with items, and editable preferences. Manage Subscription opens Stripe Customer Portal.
Create the admin curation dashboard
Build an admin page where team members can view boxes in 'curating' status, assign products to each box, and mark boxes as shipped with tracking numbers.
1// Paste this prompt into V0's AI chat:2// Build an admin curation dashboard at app/admin/boxes/page.tsx with:3// 1. Table showing all boxes with columns: Subscriber, Plan, Period, Status Badge, Items Count, Actions4// 2. Filter by status using Select (curating, shipped, delivered)5// 3. Click a row to open a Dialog for curation:6// - Show subscriber preferences (size, dietary, flavor) from subscribers.preferences jsonb7// - Table of available products with Add Button for each8// - Table of assigned items with quantity Input and Remove Button9// - Save Button to update box_items10// 4. Ship Button that opens a Dialog with Input for tracking number, then updates box status to 'shipped'11// 5. Summary Cards at top: boxes to curate, shipped this week, delivered this week12// Use Server Actions for assigning products and updating shipment status.13// Fetch from boxes joined with subscribers, box_items, and products.Expected result: An admin dashboard for curating boxes with product assignment Dialog, tracking number entry, and status management. Summary Cards show key metrics.
Complete code
1import { NextRequest, NextResponse } from 'next/server'2import Stripe from 'stripe'3import { createClient } from '@supabase/supabase-js'45const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)6const supabase = createClient(7 process.env.NEXT_PUBLIC_SUPABASE_URL!,8 process.env.SUPABASE_SERVICE_ROLE_KEY!9)1011export async function POST(req: NextRequest) {12 const rawBody = await req.text()13 const sig = req.headers.get('stripe-signature')!1415 let event: Stripe.Event16 try {17 event = stripe.webhooks.constructEvent(18 rawBody,19 sig,20 process.env.STRIPE_WEBHOOK_SECRET!21 )22 } catch {23 return NextResponse.json({ error: 'Invalid signature' }, { status: 400 })24 }2526 if (event.type === 'invoice.paid') {27 const invoice = event.data.object as Stripe.Invoice28 const subId = invoice.subscription as string2930 const { data: subscriber } = await supabase31 .from('subscribers')32 .select('id')33 .eq('stripe_subscription_id', subId)34 .single()3536 if (subscriber) {37 const periodStart = new Date(invoice.period_start * 1000)38 .toISOString()39 .split('T')[0]40 const periodEnd = new Date(invoice.period_end * 1000)41 .toISOString()42 .split('T')[0]4344 await supabase.from('boxes').upsert(45 {46 subscriber_id: subscriber.id,47 period_start: periodStart,48 period_end: periodEnd,49 status: 'curating',50 },51 { onConflict: 'subscriber_id,period_start' }52 )53 }54 }5556 if (event.type === 'customer.subscription.deleted') {57 const sub = event.data.object as Stripe.Subscription58 await supabase59 .from('subscribers')60 .update({ status: 'cancelled' })61 .eq('stripe_subscription_id', sub.id)62 }6364 return NextResponse.json({ received: true })65}Customization ideas
Add gift subscriptions
Let customers buy a subscription as a gift by entering a recipient email. Create a gift_subscriptions table that stores the sender, recipient, and activation status.
Add product rating and feedback
After a box is delivered, prompt subscribers to rate each product. Use ratings to personalize future box curation based on preferences.
Add referral program
Give subscribers a referral code. When a new customer signs up with the code, both get a discount on their next box. Track referrals in Supabase.
Add seasonal and limited edition boxes
Create special one-time boxes alongside recurring subscriptions. Allow subscribers to add seasonal boxes to their next shipment as an upsell.
Common pitfalls
Pitfall: Not handling duplicate invoice.paid webhook events
How to avoid: Use a UNIQUE constraint on (subscriber_id, period_start) in the boxes table and use Supabase upsert with onConflict. Duplicate events update instead of inserting.
Pitfall: Using request.json() instead of request.text() in the Stripe webhook
How to avoid: Always use request.text() for the raw body, pass it to stripe.webhooks.constructEvent(), then work with the parsed event object.
Pitfall: Storing subscription status only in your database without syncing from Stripe
How to avoid: Handle customer.subscription.updated and customer.subscription.deleted webhook events to sync status from Stripe to your subscribers table.
Pitfall: Not providing a way to manage subscriptions (pause/cancel/change plan)
How to avoid: Use Stripe Customer Portal — create a portal session via the API route and redirect the customer. The portal handles plan changes, cancellation, and payment method updates.
Best practices
- Use Stripe via Vercel Marketplace in V0's Connect panel for automatic key provisioning into the Vars tab
- Make box creation idempotent with a UNIQUE constraint on (subscriber_id, period_start) and upsert to handle duplicate webhook events
- Always use request.text() for Stripe webhook body — set STRIPE_WEBHOOK_SECRET in Vars tab (no NEXT_PUBLIC_ prefix)
- Use Stripe Customer Portal for subscription management instead of building custom pause/cancel/upgrade flows
- Store subscriber preferences as JSONB for flexible preference schemas that can evolve without migrations
- Use Design Mode (Option+D) to visually adjust plan Card layouts and Badge colors at zero credit cost
- Handle all critical Stripe events: invoice.paid, customer.subscription.updated, and customer.subscription.deleted
- Add trial_period_days to the Checkout session so new subscribers can try the service before being charged
AI prompts to try
Copy these prompts to build this project faster.
I'm building a subscription box service with Next.js App Router, Supabase, and Stripe. I need: 1) Stripe Checkout in subscription mode with plan selection, 2) Webhook handler for invoice.paid that creates boxes idempotently, 3) Customer account with subscription management, 4) Admin curation dashboard. Help me design the schema and handle webhook idempotency.
Create an idempotent Stripe webhook handler for a subscription box service. The handler must: 1) Use request.text() for raw body, 2) Verify signature with constructEvent, 3) On invoice.paid: find subscriber by stripe_subscription_id, create box with upsert using UNIQUE(subscriber_id, period_start), 4) On subscription.updated: sync status and current_period_end, 5) On subscription.deleted: mark subscriber as cancelled. Use Supabase for all database operations.
Frequently asked questions
How does the subscription billing cycle work?
Stripe automatically charges the customer on their billing date based on the plan interval (monthly, quarterly, annual). Each successful charge triggers an invoice.paid webhook event, which creates a new box for that period in your database.
How do I prevent duplicate boxes from webhook retries?
A UNIQUE constraint on (subscriber_id, period_start) in the boxes table prevents duplicate rows. Using Supabase upsert with onConflict means retry webhook events update the existing box instead of creating a new one.
What V0 plan do I need?
V0 Premium ($20/month) is recommended for this project due to the number of complex files — landing page, preference quiz, checkout API, webhook handler, customer account, and admin dashboard. Free tier may run low on credits.
How do customers manage their subscription?
Create an API route that generates a Stripe Customer Portal session and redirects the customer. The portal lets them change plans, update payment methods, pause, or cancel — all handled by Stripe's hosted UI.
Can I offer a free trial?
Yes. Set trial_period_days in the Stripe Checkout session creation. During the trial, the customer has an active subscription but is not charged. When the trial ends, Stripe charges the first invoice and triggers invoice.paid.
Can RapidDev help build a custom subscription box platform?
Yes. RapidDev has built 600+ apps including subscription commerce platforms with personalized curation, fulfillment integration, and analytics dashboards. Book a free consultation to discuss your subscription box business requirements.
How do I deploy and register the Stripe webhook?
First publish to production via Share > Publish in V0. Copy your production URL and go to Stripe Dashboard > Developers > Webhooks. Add the endpoint URL (https://your-domain.vercel.app/api/webhooks/stripe) and select invoice.paid, customer.subscription.updated, and customer.subscription.deleted events. Copy the signing secret and add it as STRIPE_WEBHOOK_SECRET in V0's Vars tab.
Talk to an Expert
Our team has built 600+ apps. Get personalized help with your project.
Book a free consultation