# How to Build Subscription system with V0

- Tool: v0
- Difficulty: Intermediate
- Compatibility: V0 Free
- Last updated: April 2026

## TL;DR

Build a SaaS subscription system with V0 featuring free/pro/enterprise pricing tiers, Stripe recurring billing, webhook-driven status sync, feature gating, and a customer portal for self-service management. You'll create a pricing page, checkout flow, and subscription status utility — all in about 1-2 hours.

## Before you start

- A V0 account (free tier works for this project)
- A Supabase project (free tier works — connect via V0's Connect panel)
- A Stripe account with subscription Price IDs created (test mode is free)
- A SaaS app or content platform to add billing to

## Step-by-step guide

### 1. Set up the plans and subscriptions database schema

Open V0 and create a new project. Use the Connect panel to add Supabase and Stripe via Vercel Marketplace. Create the plans and subscriptions tables with proper RLS policies.

```
// Paste this prompt into V0's AI chat:
// Create a Supabase schema for a SaaS subscription system:
// 1. plans table: id (uuid PK), name (text), stripe_price_id (text NOT NULL), features (jsonb — array of feature strings), price_cents (int), interval (text DEFAULT 'month')
// 2. subscriptions table: id (uuid PK), user_id (uuid FK references auth.users), plan_id (uuid FK), stripe_subscription_id (text UNIQUE), stripe_customer_id (text), status (text — 'active', 'trialing', 'past_due', 'cancelled'), current_period_end (timestamptz), cancel_at_period_end (boolean DEFAULT false)
// RLS: users can only read their own subscription row.
// Seed 3 plans: Free (no stripe_price_id, $0), Pro ($20/mo), Enterprise ($50/mo) with feature lists.
// Generate the SQL migration.
```

> Pro tip: Use V0's Connect panel to add Stripe via Vercel Marketplace — this auto-provisions NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY (client) and STRIPE_SECRET_KEY (server-only) into the Vars tab with correct prefixes.

**Expected result:** Plans and subscriptions tables created with RLS. Three plans seeded. Stripe keys auto-provisioned in Vars tab.

### 2. Build the pricing page with plan comparison cards

Create a Server Component pricing page that fetches plans from Supabase and renders them as Card components with feature lists, prices, and subscribe buttons. Include a monthly/annual toggle.

```
import { createClient } from '@/lib/supabase/server'
import { Card, CardContent, CardHeader, CardTitle, CardFooter } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Check } from 'lucide-react'

export default async function PricingPage() {
  const supabase = await createClient()
  const { data: plans } = await supabase
    .from('plans')
    .select('*')
    .order('price_cents')

  return (
    <div className="max-w-5xl mx-auto p-6">
      <h1 className="text-4xl font-bold text-center mb-2">Simple Pricing</h1>
      <p className="text-center text-muted-foreground mb-8">
        Choose the plan that fits your needs.
      </p>
      <div className="grid grid-cols-1 md:grid-cols-3 gap-6">
        {plans?.map((plan) => (
          <Card key={plan.id} className={plan.name === 'Pro' ? 'border-primary shadow-lg' : ''}>
            <CardHeader>
              <div className="flex justify-between">
                <CardTitle>{plan.name}</CardTitle>
                {plan.name === 'Pro' && <Badge>Popular</Badge>}
              </div>
            </CardHeader>
            <CardContent className="space-y-4">
              <p className="text-3xl font-bold">
                {plan.price_cents === 0
                  ? 'Free'
                  : `$${(plan.price_cents / 100).toFixed(0)}/mo`}
              </p>
              <ul className="space-y-2">
                {(plan.features as string[])?.map((feature: string) => (
                  <li key={feature} className="flex items-center gap-2 text-sm">
                    <Check className="w-4 h-4 text-green-500" />
                    {feature}
                  </li>
                ))}
              </ul>
            </CardContent>
            <CardFooter>
              {plan.stripe_price_id ? (
                <form action="/api/stripe/create-checkout" method="POST" className="w-full">
                  <input type="hidden" name="priceId" value={plan.stripe_price_id} />
                  <Button className="w-full" type="submit">Subscribe</Button>
                </form>
              ) : (
                <Button className="w-full" variant="outline" disabled>Current Plan</Button>
              )}
            </CardFooter>
          </Card>
        ))}
      </div>
    </div>
  )
}
```

**Expected result:** A pricing page with three plan Cards showing features with checkmarks, prices, and Subscribe buttons. The Pro plan has a highlighted border and 'Popular' Badge.

### 3. Create Stripe Checkout and Customer Portal API routes

Build API routes for creating Stripe Checkout sessions (new subscriptions) and Customer Portal sessions (manage existing subscriptions). Both redirect the user to Stripe-hosted pages.

```
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 formData = await req.formData()
  const priceId = formData.get('priceId') as string

  const session = await stripe.checkout.sessions.create({
    payment_method_types: ['card'],
    line_items: [{ price: priceId, quantity: 1 }],
    mode: 'subscription',
    subscription_data: { trial_period_days: 14 },
    success_url: `${req.nextUrl.origin}/account?subscribed=true`,
    cancel_url: `${req.nextUrl.origin}/pricing`,
  })

  return NextResponse.redirect(session.url!, { status: 303 })
}
```

**Expected result:** Submitting the pricing page form redirects to Stripe Checkout. After payment, the user lands on /account?subscribed=true.

### 4. Build the webhook handler for subscription lifecycle events

Create a webhook handler that processes all subscription lifecycle events from Stripe and syncs the subscription status to Supabase. Uses request.text() for raw body signature verification.

```
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.NEXT_PUBLIC_SUPABASE_URL!,
  process.env.SUPABASE_SERVICE_ROLE_KEY!
)

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

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

  if (event.type === 'checkout.session.completed') {
    const session = event.data.object as Stripe.Checkout.Session
    const subscription = await stripe.subscriptions.retrieve(
      session.subscription as string
    )

    await supabase.from('subscriptions').upsert({
      user_id: session.client_reference_id,
      stripe_subscription_id: subscription.id,
      stripe_customer_id: session.customer as string,
      plan_id: null,
      status: subscription.status,
      current_period_end: new Date(subscription.current_period_end * 1000).toISOString(),
    })
  }

  if (event.type === 'customer.subscription.updated') {
    const sub = event.data.object as Stripe.Subscription
    await supabase.from('subscriptions').update({
      status: sub.status,
      current_period_end: new Date(sub.current_period_end * 1000).toISOString(),
      cancel_at_period_end: sub.cancel_at_period_end,
    }).eq('stripe_subscription_id', sub.id)
  }

  if (event.type === 'customer.subscription.deleted') {
    const sub = event.data.object as Stripe.Subscription
    await supabase.from('subscriptions').update({
      status: 'cancelled',
    }).eq('stripe_subscription_id', sub.id)
  }

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

**Expected result:** Checkout completion creates subscription record. Status changes and cancellations are synced from Stripe to Supabase automatically.

### 5. Create the subscription status utility for feature gating

Build a reusable server utility that checks the current user's subscription status and plan. Use it in Server Components and middleware to gate premium features.

```
import { createClient } from '@/lib/supabase/server'
import { cache } from 'react'

export const getSubscription = cache(async () => {
  const supabase = await createClient()
  const { data: { user } } = await supabase.auth.getUser()

  if (!user) return null

  const { data: subscription } = await supabase
    .from('subscriptions')
    .select('*, plans(*)')
    .eq('user_id', user.id)
    .in('status', ['active', 'trialing'])
    .single()

  return subscription
})

export async function requireSubscription() {
  const subscription = await getSubscription()
  if (!subscription) {
    throw new Error('Subscription required')
  }
  return subscription
}

export async function hasFeature(feature: string) {
  const subscription = await getSubscription()
  if (!subscription) return false
  const features = subscription.plans?.features as string[] ?? []
  return features.includes(feature)
}
```

> Pro tip: The cache() wrapper from React deduplicates the Supabase query within a single request. Multiple Server Components calling getSubscription() only make one database query per page load.

**Expected result:** A reusable utility that checks subscription status. Call getSubscription() in Server Components or hasFeature('analytics') to conditionally render premium features.

## Complete code example

File: `lib/subscription.ts`

```typescript
import { createClient } from '@/lib/supabase/server'
import { cache } from 'react'

type Subscription = {
  id: string
  user_id: string
  plan_id: string
  stripe_subscription_id: string
  status: string
  current_period_end: string
  cancel_at_period_end: boolean
  plans: {
    name: string
    features: string[]
    price_cents: number
  } | null
}

export const getSubscription = cache(
  async (): Promise<Subscription | null> => {
    const supabase = await createClient()
    const {
      data: { user },
    } = await supabase.auth.getUser()

    if (!user) return null

    const { data } = await supabase
      .from('subscriptions')
      .select('*, plans(*)')
      .eq('user_id', user.id)
      .in('status', ['active', 'trialing'])
      .single()

    return data
  }
)

export async function hasFeature(feature: string) {
  const sub = await getSubscription()
  if (!sub?.plans) return false
  return (sub.plans.features as string[]).includes(feature)
}

export async function isPro() {
  const sub = await getSubscription()
  return sub?.plans?.name === 'Pro' || sub?.plans?.name === 'Enterprise'
}
```

## Common mistakes

- **Checking subscription status only in the frontend with client-side code** — Client-side checks can be bypassed by modifying JavaScript. Users could access premium features by editing React state or localStorage. Fix: Always check subscription status server-side using getSubscription() in Server Components, middleware, or API routes. Client-side checks are for UX only (showing/hiding UI).
- **Not syncing subscription status from Stripe via webhooks** — If a payment fails, Stripe changes the subscription to past_due. Without webhook handling, your app still shows 'active' and gives premium access to non-paying users. Fix: Handle customer.subscription.updated and customer.subscription.deleted webhook events to sync the latest status from Stripe to your subscriptions table.
- **Using request.json() instead of request.text() in the Stripe webhook handler** — Stripe's webhook signature is computed against the raw body. Parsing as JSON changes the byte representation, causing signature verification to always fail. Fix: Use request.text() to get the raw body, pass it to stripe.webhooks.constructEvent() for verification.

## Best practices

- Use Stripe via Vercel Marketplace in V0's Connect panel for automatic STRIPE_SECRET_KEY and NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY provisioning
- Always gate premium features server-side using getSubscription() — client-side checks are for UX, not security
- Use React cache() wrapper to deduplicate subscription queries across multiple Server Components on the same page
- Handle all critical webhook events: checkout.session.completed, customer.subscription.updated, customer.subscription.deleted
- Set STRIPE_WEBHOOK_SECRET in V0's Vars tab (no NEXT_PUBLIC_ prefix) after deploying and registering the webhook URL
- Use Stripe Customer Portal for subscription management instead of building custom upgrade/cancel flows
- Use Design Mode (Option+D) to visually polish the pricing page Card layout and Badge colors at zero credit cost

## Frequently asked questions

### How do I add a free trial to subscriptions?

Set trial_period_days in the Stripe Checkout session creation (e.g., 14 for a 14-day trial). During the trial, the subscription status is 'trialing'. When the trial ends, Stripe charges the first invoice automatically.

### How does feature gating work?

The getSubscription() utility queries the user's subscription and plan. Each plan has a features array in JSONB. Call hasFeature('analytics') in Server Components to conditionally render premium features. Server-side checking ensures users cannot bypass gates.

### What V0 plan do I need?

V0 Free tier works for this project. The subscription system uses standard API routes, Server Components, and shadcn/ui components. Stripe and Supabase integrations via the Connect panel are available on all plans.

### How do customers change or cancel their plan?

Use Stripe Customer Portal. Create an API route that generates a portal session and redirects the customer. The portal handles plan upgrades, downgrades, cancellation, and payment method updates — all built and hosted by Stripe.

### What happens when a payment fails?

Stripe retries failed payments according to your retry settings. The subscription status changes to past_due. Your webhook handler syncs this status to Supabase. The getSubscription() utility returns null for past_due subscriptions, gating premium features until payment succeeds.

### Can RapidDev help build a custom subscription system?

Yes. RapidDev has built 600+ apps including SaaS platforms with complex billing systems, usage-based pricing, team subscriptions, and enterprise invoicing. Book a free consultation to discuss your billing requirements.

### How do I deploy and test the webhook?

Publish to production via Share > Publish in V0. Register the webhook URL in Stripe Dashboard > Developers > Webhooks. Add STRIPE_WEBHOOK_SECRET to V0's Vars tab. Use Stripe's test mode with card 4242 4242 4242 4242 to simulate the full subscription flow.

---

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