# How to Build Billing system with V0

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

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

## Before you start

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

## Step-by-step guide

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

```
// Paste this prompt into V0's AI chat:
// Build a subscription billing system with Stripe and Supabase. Create these tables:
// 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)
// 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)
// 3. invoices: id (uuid PK), subscription_id (uuid FK), stripe_invoice_id (text unique), amount (numeric), status (text), pdf_url (text), created_at (timestamptz)
// 4. payment_methods: id (uuid PK), user_id (uuid FK), stripe_payment_method_id (text), brand (text), last4 (text), is_default (boolean)
// 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.

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

```
import { NextRequest, NextResponse } from 'next/server'
import Stripe from 'stripe'

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)

export async function POST(req: NextRequest) {
  const { priceId, userId, email } = await req.json()

  const session = await stripe.checkout.sessions.create({
    mode: 'subscription',
    payment_method_types: ['card'],
    line_items: [{ price: priceId, quantity: 1 }],
    success_url: `${req.nextUrl.origin}/billing?success=true`,
    cancel_url: `${req.nextUrl.origin}/pricing`,
    customer_email: email,
    metadata: { user_id: userId },
    subscription_data: {
      metadata: { user_id: userId },
    },
  })

  return NextResponse.json({ url: session.url })
}
```

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

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

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)
const supabase = createClient(
  process.env.SUPABASE_URL!,
  process.env.SUPABASE_SERVICE_ROLE_KEY!
)

export async function POST(req: NextRequest) {
  const body = await req.text()
  const sig = req.headers.get('stripe-signature')!

  let event: Stripe.Event
  try {
    event = stripe.webhooks.constructEvent(body, sig, process.env.STRIPE_WEBHOOK_SECRET!)
  } catch {
    return NextResponse.json({ error: 'Invalid signature' }, { status: 400 })
  }

  switch (event.type) {
    case 'customer.subscription.created':
    case 'customer.subscription.updated': {
      const sub = event.data.object as Stripe.Subscription
      const userId = sub.metadata.user_id
      await supabase.from('subscriptions').upsert({
        user_id: userId,
        stripe_subscription_id: sub.id,
        status: sub.status,
        current_period_start: new Date(sub.current_period_start * 1000).toISOString(),
        current_period_end: new Date(sub.current_period_end * 1000).toISOString(),
        cancel_at_period_end: sub.cancel_at_period_end,
      }, { onConflict: 'stripe_subscription_id' })
      break
    }
    case 'customer.subscription.deleted': {
      const sub = event.data.object as Stripe.Subscription
      await supabase.from('subscriptions').update({ status: 'canceled' })
        .eq('stripe_subscription_id', sub.id)
      break
    }
    case 'invoice.paid': {
      const invoice = event.data.object as Stripe.Invoice
      await supabase.from('invoices').upsert({
        stripe_invoice_id: invoice.id,
        amount: (invoice.amount_paid ?? 0) / 100,
        status: 'paid',
        pdf_url: invoice.invoice_pdf ?? null,
      }, { onConflict: 'stripe_invoice_id' })
      break
    }
  }

  return NextResponse.json({ received: true })
}
```

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

```
// Paste this prompt into V0's AI chat:
// Build a billing dashboard at app/billing/page.tsx.
// Requirements:
// - Show current subscription Card: plan name, status Badge, price, next billing date, payment method (brand + last4)
// - "Manage Subscription" Button that redirects to Stripe Customer Portal via /api/stripe/portal
// - Invoice history Table with columns: Date, Amount, Status Badge (paid/failed), PDF download link
// - If user has no subscription, show the pricing page CTA
// - AlertDialog for cancellation confirmation with warning about losing access
// - Use Server Components to fetch subscription and invoice data from Supabase
// - 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 example

File: `app/api/webhooks/stripe/route.ts`

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

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)
const supabase = createClient(
  process.env.SUPABASE_URL!,
  process.env.SUPABASE_SERVICE_ROLE_KEY!
)

export async function POST(req: NextRequest) {
  const body = await req.text()
  const sig = req.headers.get('stripe-signature')!

  let event: Stripe.Event
  try {
    event = stripe.webhooks.constructEvent(
      body,
      sig,
      process.env.STRIPE_WEBHOOK_SECRET!
    )
  } catch {
    return NextResponse.json({ error: 'Invalid signature' }, { status: 400 })
  }

  switch (event.type) {
    case 'customer.subscription.created':
    case 'customer.subscription.updated': {
      const sub = event.data.object as Stripe.Subscription
      await supabase.from('subscriptions').upsert(
        {
          user_id: sub.metadata.user_id,
          stripe_subscription_id: sub.id,
          status: sub.status,
          current_period_start: new Date(
            sub.current_period_start * 1000
          ).toISOString(),
          current_period_end: new Date(
            sub.current_period_end * 1000
          ).toISOString(),
          cancel_at_period_end: sub.cancel_at_period_end,
        },
        { onConflict: 'stripe_subscription_id' }
      )
      break
    }
    case 'customer.subscription.deleted': {
      const sub = event.data.object as Stripe.Subscription
      await supabase
        .from('subscriptions')
        .update({ status: 'canceled' })
        .eq('stripe_subscription_id', sub.id)
      break
    }
    case 'invoice.paid':
    case 'invoice.payment_failed': {
      const invoice = event.data.object as Stripe.Invoice
      await supabase.from('invoices').upsert(
        {
          stripe_invoice_id: invoice.id,
          amount: (invoice.amount_paid ?? 0) / 100,
          status: event.type === 'invoice.paid' ? 'paid' : 'failed',
          pdf_url: invoice.invoice_pdf ?? null,
        },
        { onConflict: 'stripe_invoice_id' }
      )
      break
    }
  }

  return NextResponse.json({ received: true })
}
```

## Common mistakes

- **Using request.json() instead of request.text() for Stripe webhooks** — Stripe signature verification requires the raw request body. Parsing it as JSON first alters the byte representation, causing constructEvent to throw an invalid signature error every time. Fix: Always use request.text() to get the raw body, then pass it to stripe.webhooks.constructEvent() along with the signature header and webhook secret.
- **Not handling duplicate webhook events** — Stripe retries failed webhook deliveries up to 3 times. Without idempotency, a single subscription change could create duplicate records in your database. Fix: 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.
- **Storing subscription status only in Stripe without a local copy** — Querying the Stripe API on every page load is slow and rate-limited. Without local data, your billing pages will be sluggish and unreliable. Fix: 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

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

---

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