Skip to main content
RapidDev - Software Development Agency

How to Build Subscription box service with V0

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

  • Plan comparison landing page with shadcn/ui Card tiers, Badge for 'Most Popular', and RadioGroup for selection
  • Preference quiz form with Checkbox and Select inputs for size, dietary restrictions, and flavor preferences
  • Stripe subscription checkout via Vercel Marketplace integration with automatic key provisioning
  • Webhook handler for invoice.paid, subscription.updated, and subscription.deleted events with idempotent box creation
  • Customer account page with subscription management, box history, and preference editing via Tabs
  • Admin curation dashboard for assigning products to subscriber boxes with Table and Dialog components
Book a free consultation
4.9Clutch rating
600+Happy partners
17+Countries served
190+Team members
Advanced12 min read2-4 hoursV0 Premium or higherApril 2026RapidDev Engineering Team
TL;DR

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

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

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

1

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.

prompt.txt
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 idempotency
6// 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.

2

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.

app/page.tsx
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'
6
7export default async function LandingPage() {
8 const supabase = await createClient()
9 const { data: plans } = await supabase
10 .from('plans')
11 .select('*')
12 .eq('is_active', true)
13 .order('price_cents')
14
15 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 door
19 </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.

3

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.

app/api/stripe/subscribe/route.ts
1import { NextRequest, NextResponse } from 'next/server'
2import Stripe from 'stripe'
3import { createClient } from '@supabase/supabase-js'
4
5const 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)
10
11export async function POST(req: NextRequest) {
12 const { planId, preferences } = await req.json()
13
14 const { data: plan } = await supabase
15 .from('plans')
16 .select('stripe_price_id, name')
17 .eq('id', planId)
18 .single()
19
20 if (!plan) {
21 return NextResponse.json({ error: 'Plan not found' }, { status: 404 })
22 }
23
24 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 })
43
44 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.

4

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.

app/api/webhooks/stripe/route.ts
1import { NextRequest, NextResponse } from 'next/server'
2import Stripe from 'stripe'
3import { createClient } from '@supabase/supabase-js'
4
5const 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)
10
11export async function POST(req: NextRequest) {
12 const rawBody = await req.text()
13 const sig = req.headers.get('stripe-signature')!
14
15 let event: Stripe.Event
16 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 }
25
26 if (event.type === 'invoice.paid') {
27 const invoice = event.data.object as Stripe.Invoice
28 const subscriptionId = invoice.subscription as string
29
30 const { data: subscriber } = await supabase
31 .from('subscribers')
32 .select('id')
33 .eq('stripe_subscription_id', subscriptionId)
34 .single()
35
36 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]
41
42 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 }
53
54 if (event.type === 'customer.subscription.updated') {
55 const subscription = event.data.object as Stripe.Subscription
56 await supabase
57 .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 }
64
65 if (event.type === 'customer.subscription.deleted') {
66 const subscription = event.data.object as Stripe.Subscription
67 await supabase
68 .from('subscribers')
69 .update({ status: 'cancelled' })
70 .eq('stripe_subscription_id', subscription.id)
71 }
72
73 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.

5

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.

prompt.txt
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, Preferences
4// 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 box
6// 4. Preferences tab: Form with Checkbox groups for dietary preferences, Select for size, Select for flavor preferences, and Save Button that calls a Server Action
7// 5. AlertDialog for cancel confirmation
8// 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.

6

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.

prompt.txt
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, Actions
4// 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 jsonb
7// - Table of available products with Add Button for each
8// - Table of assigned items with quantity Input and Remove Button
9// - Save Button to update box_items
10// 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 week
12// 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

app/api/webhooks/stripe/route.ts
1import { NextRequest, NextResponse } from 'next/server'
2import Stripe from 'stripe'
3import { createClient } from '@supabase/supabase-js'
4
5const 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)
10
11export async function POST(req: NextRequest) {
12 const rawBody = await req.text()
13 const sig = req.headers.get('stripe-signature')!
14
15 let event: Stripe.Event
16 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 }
25
26 if (event.type === 'invoice.paid') {
27 const invoice = event.data.object as Stripe.Invoice
28 const subId = invoice.subscription as string
29
30 const { data: subscriber } = await supabase
31 .from('subscribers')
32 .select('id')
33 .eq('stripe_subscription_id', subId)
34 .single()
35
36 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]
43
44 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 }
55
56 if (event.type === 'customer.subscription.deleted') {
57 const sub = event.data.object as Stripe.Subscription
58 await supabase
59 .from('subscribers')
60 .update({ status: 'cancelled' })
61 .eq('stripe_subscription_id', sub.id)
62 }
63
64 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.

ChatGPT Prompt

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.

Build Prompt

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.

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.