Skip to main content
RapidDev - Software Development Agency

How to Build Billing system with V0

Build a recurring subscription billing system with V0 using Next.js, Stripe Billing, and Supabase. You'll get a pricing page, Stripe Checkout integration, webhook-driven subscription lifecycle management, downloadable invoice history, and a customer billing portal — all in about 1-2 hours.

What you'll build

  • Public pricing page with plan comparison Cards featuring feature lists and subscribe Buttons
  • Stripe Checkout Session creation for subscription sign-ups with automatic plan selection
  • Webhook handler processing subscription lifecycle events (created, updated, deleted, invoice paid/failed)
  • Customer billing dashboard with current plan, next billing date, and payment method details
  • Invoice history table with PDF download links pulled from Stripe via webhook sync
  • Stripe Customer Portal integration for self-service plan changes and cancellations
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 a recurring subscription billing system with V0 using Next.js, Stripe Billing, and Supabase. You'll get a pricing page, Stripe Checkout integration, webhook-driven subscription lifecycle management, downloadable invoice history, and a customer billing portal — all in about 1-2 hours.

What you're building

Every SaaS product needs a billing system. Founders need to offer pricing plans, process recurring payments, handle upgrades and downgrades, manage invoices, and let customers self-serve their billing without contacting support.

V0 accelerates this by generating the pricing page UI, Stripe integration code, and billing dashboard from prompts. Stripe via Vercel Marketplace auto-provisions your API keys, and Supabase via the Connect panel stores your local subscription and invoice data for fast querying.

The architecture uses Stripe as the source of truth for subscriptions and payments. Webhook events from Stripe are processed by an API route that syncs subscription status and invoices to Supabase. Server Components fetch billing data from Supabase for fast page loads. The Stripe Customer Portal handles plan changes and payment method updates without custom UI.

Final result

A complete subscription billing system with a pricing page, Stripe Checkout, webhook-synced subscriptions and invoices, a customer billing dashboard, and Stripe Customer Portal integration for self-service management.

Tech stack

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

Prerequisites

  • A V0 account (Premium plan recommended for multi-page builds)
  • A Supabase project (free tier works — connect via V0's Connect panel)
  • A Stripe account with products and prices created in the Stripe Dashboard
  • At least two subscription plans configured in Stripe (e.g., monthly and annual)

Build steps

1

Set up Stripe products and the Supabase schema

Connect Stripe via Vercel Marketplace in V0's Connect panel — this auto-provisions STRIPE_SECRET_KEY and NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY. Then connect Supabase and create tables to mirror Stripe subscription data locally.

prompt.txt
1// Paste this prompt into V0's AI chat:
2// Build a subscription billing system with Stripe and Supabase. Create these tables:
3// 1. plans: id (uuid PK), stripe_product_id (text), stripe_price_id (text), name (text), price (numeric), interval (text CHECK in 'month','year'), features (jsonb), is_active (boolean default true)
4// 2. subscriptions: id (uuid PK), user_id (uuid FK to auth.users), plan_id (uuid FK), stripe_subscription_id (text unique), status (text), current_period_start (timestamptz), current_period_end (timestamptz), cancel_at_period_end (boolean default false), created_at (timestamptz)
5// 3. invoices: id (uuid PK), subscription_id (uuid FK), stripe_invoice_id (text unique), amount (numeric), status (text), pdf_url (text), created_at (timestamptz)
6// 4. payment_methods: id (uuid PK), user_id (uuid FK), stripe_payment_method_id (text), brand (text), last4 (text), is_default (boolean)
7// Add RLS so users can only see their own subscriptions, invoices, and payment methods.

Pro tip: Use the Connect panel to add Stripe via Vercel Marketplace — it auto-provisions both STRIPE_SECRET_KEY and NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY without any manual copy-paste.

Expected result: Stripe and Supabase are connected. Tables for plans, subscriptions, invoices, and payment methods are created with RLS policies.

2

Build the public pricing page with plan comparison

Create a responsive pricing page that fetches plans from Supabase and displays them as Cards with feature lists, prices, and subscribe buttons. Each button triggers a Stripe Checkout Session for the selected plan.

prompt.txt
1// Paste this prompt into V0's AI chat:
2// Build a pricing page at app/pricing/page.tsx (Server Component).
3// Requirements:
4// - Fetch all active plans from Supabase ordered by price
5// - Display plans in a responsive grid of shadcn/ui Cards (2-3 columns)
6// - Each Card shows: plan name, price with interval (e.g. $25/month), feature list from jsonb
7// - Highlight the recommended plan with a Badge and ring border
8// - RadioGroup toggle at the top to switch between monthly and annual pricing
9// - Subscribe Button on each Card that POSTs to /api/stripe/checkout with the plan's stripe_price_id
10// - If user is logged in and already subscribed, show "Current Plan" Badge instead of Button
11// - Annual plans should show the monthly equivalent and savings percentage

Expected result: The pricing page shows plan Cards with features, prices, and subscribe buttons. Monthly/annual toggle switches the displayed prices.

3

Create the Stripe Checkout and Customer Portal API routes

Build the API routes that create Stripe Checkout Sessions for new subscriptions and Stripe Customer Portal sessions for existing subscribers to manage their billing.

app/api/stripe/checkout/route.ts
1import { NextRequest, NextResponse } from 'next/server'
2import Stripe from 'stripe'
3
4const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)
5
6export async function POST(req: NextRequest) {
7 const { priceId, userId, email } = await req.json()
8
9 const session = await stripe.checkout.sessions.create({
10 mode: 'subscription',
11 payment_method_types: ['card'],
12 line_items: [{ price: priceId, quantity: 1 }],
13 success_url: `${req.nextUrl.origin}/billing?success=true`,
14 cancel_url: `${req.nextUrl.origin}/pricing`,
15 customer_email: email,
16 metadata: { user_id: userId },
17 subscription_data: {
18 metadata: { user_id: userId },
19 },
20 })
21
22 return NextResponse.json({ url: session.url })
23}

Pro tip: Always pass user_id in both the Checkout Session metadata and the subscription_data.metadata. The Checkout Session metadata is available in the checkout.session.completed event, while the subscription metadata persists across all subscription lifecycle events.

Expected result: POST to /api/stripe/checkout creates a Stripe Checkout Session and returns the redirect URL. POST to /api/stripe/portal creates a Customer Portal session.

4

Build the webhook handler for subscription lifecycle events

Create the Stripe webhook handler that processes subscription and invoice events. It syncs subscription status changes and invoice data to Supabase for fast local querying.

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.SUPABASE_URL!,
8 process.env.SUPABASE_SERVICE_ROLE_KEY!
9)
10
11export async function POST(req: NextRequest) {
12 const body = await req.text()
13 const sig = req.headers.get('stripe-signature')!
14
15 let event: Stripe.Event
16 try {
17 event = stripe.webhooks.constructEvent(body, sig, process.env.STRIPE_WEBHOOK_SECRET!)
18 } catch {
19 return NextResponse.json({ error: 'Invalid signature' }, { status: 400 })
20 }
21
22 switch (event.type) {
23 case 'customer.subscription.created':
24 case 'customer.subscription.updated': {
25 const sub = event.data.object as Stripe.Subscription
26 const userId = sub.metadata.user_id
27 await supabase.from('subscriptions').upsert({
28 user_id: userId,
29 stripe_subscription_id: sub.id,
30 status: sub.status,
31 current_period_start: new Date(sub.current_period_start * 1000).toISOString(),
32 current_period_end: new Date(sub.current_period_end * 1000).toISOString(),
33 cancel_at_period_end: sub.cancel_at_period_end,
34 }, { onConflict: 'stripe_subscription_id' })
35 break
36 }
37 case 'customer.subscription.deleted': {
38 const sub = event.data.object as Stripe.Subscription
39 await supabase.from('subscriptions').update({ status: 'canceled' })
40 .eq('stripe_subscription_id', sub.id)
41 break
42 }
43 case 'invoice.paid': {
44 const invoice = event.data.object as Stripe.Invoice
45 await supabase.from('invoices').upsert({
46 stripe_invoice_id: invoice.id,
47 amount: (invoice.amount_paid ?? 0) / 100,
48 status: 'paid',
49 pdf_url: invoice.invoice_pdf ?? null,
50 }, { onConflict: 'stripe_invoice_id' })
51 break
52 }
53 }
54
55 return NextResponse.json({ received: true })
56}

Expected result: The webhook handler processes subscription created/updated/deleted and invoice paid events, syncing all changes to Supabase in real-time.

5

Build the customer billing dashboard

Create the billing page where logged-in users can see their current plan, next billing date, invoice history with PDF download links, and a button to open the Stripe Customer Portal for self-service management.

prompt.txt
1// Paste this prompt into V0's AI chat:
2// Build a billing dashboard at app/billing/page.tsx.
3// Requirements:
4// - Show current subscription Card: plan name, status Badge, price, next billing date, payment method (brand + last4)
5// - "Manage Subscription" Button that redirects to Stripe Customer Portal via /api/stripe/portal
6// - Invoice history Table with columns: Date, Amount, Status Badge (paid/failed), PDF download link
7// - If user has no subscription, show the pricing page CTA
8// - AlertDialog for cancellation confirmation with warning about losing access
9// - Use Server Components to fetch subscription and invoice data from Supabase
10// - Show a success toast if redirected from Stripe Checkout with ?success=true query param

Pro tip: Use the Stripe Customer Portal for plan changes and cancellations instead of building custom UI. Configure it in the Stripe Dashboard under Settings then Customer Portal — it handles plan switching, payment method updates, and cancellation flows automatically.

Expected result: The billing dashboard shows the current plan, invoice history with PDF links, and a button to open the Stripe Customer Portal for self-service management.

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.SUPABASE_URL!,
8 process.env.SUPABASE_SERVICE_ROLE_KEY!
9)
10
11export async function POST(req: NextRequest) {
12 const body = 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 body,
19 sig,
20 process.env.STRIPE_WEBHOOK_SECRET!
21 )
22 } catch {
23 return NextResponse.json({ error: 'Invalid signature' }, { status: 400 })
24 }
25
26 switch (event.type) {
27 case 'customer.subscription.created':
28 case 'customer.subscription.updated': {
29 const sub = event.data.object as Stripe.Subscription
30 await supabase.from('subscriptions').upsert(
31 {
32 user_id: sub.metadata.user_id,
33 stripe_subscription_id: sub.id,
34 status: sub.status,
35 current_period_start: new Date(
36 sub.current_period_start * 1000
37 ).toISOString(),
38 current_period_end: new Date(
39 sub.current_period_end * 1000
40 ).toISOString(),
41 cancel_at_period_end: sub.cancel_at_period_end,
42 },
43 { onConflict: 'stripe_subscription_id' }
44 )
45 break
46 }
47 case 'customer.subscription.deleted': {
48 const sub = event.data.object as Stripe.Subscription
49 await supabase
50 .from('subscriptions')
51 .update({ status: 'canceled' })
52 .eq('stripe_subscription_id', sub.id)
53 break
54 }
55 case 'invoice.paid':
56 case 'invoice.payment_failed': {
57 const invoice = event.data.object as Stripe.Invoice
58 await supabase.from('invoices').upsert(
59 {
60 stripe_invoice_id: invoice.id,
61 amount: (invoice.amount_paid ?? 0) / 100,
62 status: event.type === 'invoice.paid' ? 'paid' : 'failed',
63 pdf_url: invoice.invoice_pdf ?? null,
64 },
65 { onConflict: 'stripe_invoice_id' }
66 )
67 break
68 }
69 }
70
71 return NextResponse.json({ received: true })
72}

Customization ideas

Add usage-based billing

Integrate Stripe Metered Billing to charge customers based on actual usage (API calls, storage, etc.) by reporting usage events via the Stripe API from your backend.

Add trial periods

Configure trial_period_days in the Stripe Checkout Session creation to offer free trials. Display trial status and days remaining on the billing dashboard.

Add coupon and promo codes

Enable Stripe Checkout's allow_promotion_codes option and create coupons in the Stripe Dashboard. Show applied discounts on invoices.

Add dunning management notifications

Handle invoice.payment_failed webhook events to show in-app banners and send email reminders when payment methods fail.

Common pitfalls

Pitfall: Using request.json() instead of request.text() for Stripe webhooks

How to avoid: Always use request.text() to get the raw body, then pass it to stripe.webhooks.constructEvent() along with the signature header and webhook secret.

Pitfall: Not handling duplicate webhook events

How to avoid: Use unique constraints on stripe_subscription_id and stripe_invoice_id columns. Use upsert with ON CONFLICT DO UPDATE so duplicate events update existing rows instead of creating new ones.

Pitfall: Storing subscription status only in Stripe without a local copy

How to avoid: Sync subscription and invoice data to Supabase via webhooks. Use Supabase as the read layer for fast page loads, and Stripe as the source of truth for mutations.

Best practices

  • Use Stripe as the source of truth for subscriptions and sync data to Supabase via webhooks for fast reads
  • Always verify webhook signatures with request.text() to prevent spoofed events from modifying your billing data
  • Use the Stripe Customer Portal for plan management instead of building custom upgrade/downgrade UI
  • Store STRIPE_SECRET_KEY and STRIPE_WEBHOOK_SECRET in V0's Vars tab without NEXT_PUBLIC_ prefix
  • Add unique constraints on stripe_subscription_id and stripe_invoice_id to handle duplicate webhook events idempotently
  • Pass user_id in both session metadata and subscription_data.metadata to ensure it persists across all webhook events
  • Use Design Mode (Option+D) to adjust pricing card layout, feature list styling, and badge colors without spending credits
  • Use Server Components for the billing dashboard to keep Supabase queries and subscription data server-side

AI prompts to try

Copy these prompts to build this project faster.

ChatGPT Prompt

I'm building a subscription billing system with Next.js App Router, Stripe, and Supabase. I need Stripe Checkout for signups, webhooks to sync subscription lifecycle to Supabase, a billing dashboard, and Stripe Customer Portal integration. Help me design the webhook handler that processes subscription and invoice events idempotently.

Build Prompt

Build a Stripe webhook handler for subscription billing that processes: customer.subscription.created, customer.subscription.updated, customer.subscription.deleted, invoice.paid, and invoice.payment_failed. Each event should upsert the corresponding Supabase row using the Stripe resource ID as the unique key. Use request.text() for raw body and stripe.webhooks.constructEvent for signature verification.

Frequently asked questions

How do I handle plan upgrades and downgrades?

Use the Stripe Customer Portal, which handles plan switching automatically including proration calculations. Configure allowed plan switches in the Stripe Dashboard under Settings then Customer Portal. When a customer switches plans, Stripe sends a customer.subscription.updated webhook that your handler syncs to Supabase.

What happens when a payment fails?

Stripe automatically retries failed payments according to your retry schedule. The invoice.payment_failed webhook event updates the invoice status in Supabase. You can show a banner on the billing page prompting the customer to update their payment method via the Customer Portal.

What V0 plan do I need for a billing system?

V0 Premium is recommended because the billing system requires multiple pages (pricing, billing dashboard), API routes (checkout, portal, webhook), and Stripe integration. The free plan's credits may not cover the full build.

How do I test the billing system locally in V0?

Use Stripe test mode (automatically provided via Vercel Marketplace). Test cards like 4242 4242 4242 4242 simulate successful payments. For webhook testing, use the Stripe CLI to forward events to your V0 preview URL.

How do I deploy the billing system?

Click Share then Publish to Production in V0. After publishing, register the webhook endpoint at https://yourdomain.vercel.app/api/webhooks/stripe in the Stripe Dashboard. Select the subscription and invoice events.

Can I add one-time payments alongside subscriptions?

Yes. Create a separate Checkout Session with mode set to payment instead of subscription. The webhook handler can process checkout.session.completed events for one-time purchases alongside the subscription events.

Can RapidDev help build a custom billing system?

Yes. RapidDev has built 600+ apps including complex billing systems with usage-based metering, multi-currency support, and enterprise invoicing. Book a free consultation to discuss your specific billing requirements.

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.