Skip to main content
RapidDev - Software Development Agency

How to Build Subscription system with V0

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.

What you'll build

  • Pricing page with shadcn/ui Card components for each tier, monthly/annual toggle with Switch, and subscribe Button
  • Stripe Checkout integration in subscription mode with trial period support via Vercel Marketplace
  • Webhook handler processing checkout.session.completed, invoice.paid, subscription.updated, and subscription.deleted
  • Subscription status utility function for gating premium features in Server Components and middleware
  • Stripe Customer Portal integration for self-service plan changes, cancellation, and payment updates
  • Current Plan Badge indicator and invoice history Table on the account page
Book a free consultation
4.9Clutch rating
600+Happy partners
17+Countries served
190+Team members
Intermediate9 min read1-2 hoursV0 FreeApril 2026RapidDev Engineering Team
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.

What you're building

Every SaaS product needs a way to charge for premium features. A subscription system handles plan selection, recurring billing, trial periods, upgrades, downgrades, and cancellations. Building this from scratch is complex, but Stripe handles the billing lifecycle.

V0 generates the pricing page, checkout API, webhook handler, and feature gating logic from prompts. Stripe via the Vercel Marketplace auto-provisions API keys. Supabase stores plan definitions, subscription records, and user associations.

The architecture uses a Server Component pricing page, API routes for creating Stripe Checkout and Customer Portal sessions, a webhook handler for billing lifecycle events, and a reusable getSubscription() utility that checks the user's current plan for feature gating in Server Components and middleware.

Final result

A complete subscription system with tiered pricing, Stripe recurring billing with trials, webhook-synced status, feature gating, and self-service subscription management.

Tech stack

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

Prerequisites

  • 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

Build steps

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.

prompt.txt
1// Paste this prompt into V0's AI chat:
2// Create a Supabase schema for a SaaS subscription system:
3// 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')
4// 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)
5// RLS: users can only read their own subscription row.
6// Seed 3 plans: Free (no stripe_price_id, $0), Pro ($20/mo), Enterprise ($50/mo) with feature lists.
7// 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.

app/pricing/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 { Check } from 'lucide-react'
6
7export default async function PricingPage() {
8 const supabase = await createClient()
9 const { data: plans } = await supabase
10 .from('plans')
11 .select('*')
12 .order('price_cents')
13
14 return (
15 <div className="max-w-5xl mx-auto p-6">
16 <h1 className="text-4xl font-bold text-center mb-2">Simple Pricing</h1>
17 <p className="text-center text-muted-foreground mb-8">
18 Choose the plan that fits your needs.
19 </p>
20 <div className="grid grid-cols-1 md:grid-cols-3 gap-6">
21 {plans?.map((plan) => (
22 <Card key={plan.id} className={plan.name === 'Pro' ? 'border-primary shadow-lg' : ''}>
23 <CardHeader>
24 <div className="flex justify-between">
25 <CardTitle>{plan.name}</CardTitle>
26 {plan.name === 'Pro' && <Badge>Popular</Badge>}
27 </div>
28 </CardHeader>
29 <CardContent className="space-y-4">
30 <p className="text-3xl font-bold">
31 {plan.price_cents === 0
32 ? 'Free'
33 : `$${(plan.price_cents / 100).toFixed(0)}/mo`}
34 </p>
35 <ul className="space-y-2">
36 {(plan.features as string[])?.map((feature: string) => (
37 <li key={feature} className="flex items-center gap-2 text-sm">
38 <Check className="w-4 h-4 text-green-500" />
39 {feature}
40 </li>
41 ))}
42 </ul>
43 </CardContent>
44 <CardFooter>
45 {plan.stripe_price_id ? (
46 <form action="/api/stripe/create-checkout" method="POST" className="w-full">
47 <input type="hidden" name="priceId" value={plan.stripe_price_id} />
48 <Button className="w-full" type="submit">Subscribe</Button>
49 </form>
50 ) : (
51 <Button className="w-full" variant="outline" disabled>Current Plan</Button>
52 )}
53 </CardFooter>
54 </Card>
55 ))}
56 </div>
57 </div>
58 )
59}

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.

app/api/stripe/create-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 formData = await req.formData()
8 const priceId = formData.get('priceId') as string
9
10 const session = await stripe.checkout.sessions.create({
11 payment_method_types: ['card'],
12 line_items: [{ price: priceId, quantity: 1 }],
13 mode: 'subscription',
14 subscription_data: { trial_period_days: 14 },
15 success_url: `${req.nextUrl.origin}/account?subscribed=true`,
16 cancel_url: `${req.nextUrl.origin}/pricing`,
17 })
18
19 return NextResponse.redirect(session.url!, { status: 303 })
20}

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.

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, sig, process.env.STRIPE_WEBHOOK_SECRET!
19 )
20 } catch {
21 return NextResponse.json({ error: 'Invalid signature' }, { status: 400 })
22 }
23
24 if (event.type === 'checkout.session.completed') {
25 const session = event.data.object as Stripe.Checkout.Session
26 const subscription = await stripe.subscriptions.retrieve(
27 session.subscription as string
28 )
29
30 await supabase.from('subscriptions').upsert({
31 user_id: session.client_reference_id,
32 stripe_subscription_id: subscription.id,
33 stripe_customer_id: session.customer as string,
34 plan_id: null,
35 status: subscription.status,
36 current_period_end: new Date(subscription.current_period_end * 1000).toISOString(),
37 })
38 }
39
40 if (event.type === 'customer.subscription.updated') {
41 const sub = event.data.object as Stripe.Subscription
42 await supabase.from('subscriptions').update({
43 status: sub.status,
44 current_period_end: new Date(sub.current_period_end * 1000).toISOString(),
45 cancel_at_period_end: sub.cancel_at_period_end,
46 }).eq('stripe_subscription_id', sub.id)
47 }
48
49 if (event.type === 'customer.subscription.deleted') {
50 const sub = event.data.object as Stripe.Subscription
51 await supabase.from('subscriptions').update({
52 status: 'cancelled',
53 }).eq('stripe_subscription_id', sub.id)
54 }
55
56 return NextResponse.json({ received: true })
57}

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.

lib/subscription.ts
1import { createClient } from '@/lib/supabase/server'
2import { cache } from 'react'
3
4export const getSubscription = cache(async () => {
5 const supabase = await createClient()
6 const { data: { user } } = await supabase.auth.getUser()
7
8 if (!user) return null
9
10 const { data: subscription } = await supabase
11 .from('subscriptions')
12 .select('*, plans(*)')
13 .eq('user_id', user.id)
14 .in('status', ['active', 'trialing'])
15 .single()
16
17 return subscription
18})
19
20export async function requireSubscription() {
21 const subscription = await getSubscription()
22 if (!subscription) {
23 throw new Error('Subscription required')
24 }
25 return subscription
26}
27
28export async function hasFeature(feature: string) {
29 const subscription = await getSubscription()
30 if (!subscription) return false
31 const features = subscription.plans?.features as string[] ?? []
32 return features.includes(feature)
33}

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

lib/subscription.ts
1import { createClient } from '@/lib/supabase/server'
2import { cache } from 'react'
3
4type Subscription = {
5 id: string
6 user_id: string
7 plan_id: string
8 stripe_subscription_id: string
9 status: string
10 current_period_end: string
11 cancel_at_period_end: boolean
12 plans: {
13 name: string
14 features: string[]
15 price_cents: number
16 } | null
17}
18
19export const getSubscription = cache(
20 async (): Promise<Subscription | null> => {
21 const supabase = await createClient()
22 const {
23 data: { user },
24 } = await supabase.auth.getUser()
25
26 if (!user) return null
27
28 const { data } = await supabase
29 .from('subscriptions')
30 .select('*, plans(*)')
31 .eq('user_id', user.id)
32 .in('status', ['active', 'trialing'])
33 .single()
34
35 return data
36 }
37)
38
39export async function hasFeature(feature: string) {
40 const sub = await getSubscription()
41 if (!sub?.plans) return false
42 return (sub.plans.features as string[]).includes(feature)
43}
44
45export async function isPro() {
46 const sub = await getSubscription()
47 return sub?.plans?.name === 'Pro' || sub?.plans?.name === 'Enterprise'
48}

Customization ideas

Add usage-based billing

Track API calls or storage usage per user and report metered usage to Stripe for usage-based pricing alongside the base subscription.

Add team subscriptions

Allow one subscriber to invite team members. Create an org_members table and share the subscription across the team with per-seat pricing.

Add annual discount toggle

Create annual Price IDs in Stripe with a discount. Add a Switch component on the pricing page that toggles between monthly and annual pricing.

Add upgrade/downgrade prorations

When users change plans mid-cycle, use Stripe's proration settings to automatically calculate the credit/charge for the remaining period.

Common pitfalls

Pitfall: Checking subscription status only in the frontend with client-side code

How to avoid: 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).

Pitfall: Not syncing subscription status from Stripe via webhooks

How to avoid: Handle customer.subscription.updated and customer.subscription.deleted webhook events to sync the latest status from Stripe to your subscriptions table.

Pitfall: Using request.json() instead of request.text() in the Stripe webhook handler

How to avoid: 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

AI prompts to try

Copy these prompts to build this project faster.

ChatGPT Prompt

I'm building a SaaS subscription system with Next.js App Router, Supabase, and Stripe. I need: 1) A pricing page with free/pro/enterprise tiers, 2) Stripe Checkout in subscription mode, 3) Webhook handler for billing lifecycle events, 4) A reusable getSubscription() utility for feature gating. Help me design the schema and subscription status checking pattern.

Build Prompt

Create a reusable subscription status utility for Next.js App Router that: 1) Uses React cache() to deduplicate queries per request, 2) Queries Supabase for the user's active or trialing subscription joined with plans, 3) Exports getSubscription(), hasFeature(feature), and isPro() functions, 4) Can be used in Server Components, Server Actions, and middleware. The utility should handle null users gracefully and return null for unauthenticated requests.

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.

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.