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
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
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.
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.
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.
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'67export default async function PricingPage() {8 const supabase = await createClient()9 const { data: plans } = await supabase10 .from('plans')11 .select('*')12 .order('price_cents')1314 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 === 032 ? '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.
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.
1import { NextRequest, NextResponse } from 'next/server'2import Stripe from 'stripe'34const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)56export async function POST(req: NextRequest) {7 const formData = await req.formData()8 const priceId = formData.get('priceId') as string910 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 })1819 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.
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.
1import { NextRequest, NextResponse } from 'next/server'2import Stripe from 'stripe'3import { createClient } from '@supabase/supabase-js'45const 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)1011export async function POST(req: NextRequest) {12 const rawBody = await req.text()13 const sig = req.headers.get('stripe-signature')!1415 let event: Stripe.Event16 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 }2324 if (event.type === 'checkout.session.completed') {25 const session = event.data.object as Stripe.Checkout.Session26 const subscription = await stripe.subscriptions.retrieve(27 session.subscription as string28 )2930 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 }3940 if (event.type === 'customer.subscription.updated') {41 const sub = event.data.object as Stripe.Subscription42 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 }4849 if (event.type === 'customer.subscription.deleted') {50 const sub = event.data.object as Stripe.Subscription51 await supabase.from('subscriptions').update({52 status: 'cancelled',53 }).eq('stripe_subscription_id', sub.id)54 }5556 return NextResponse.json({ received: true })57}Expected result: Checkout completion creates subscription record. Status changes and cancellations are synced from Stripe to Supabase automatically.
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.
1import { createClient } from '@/lib/supabase/server'2import { cache } from 'react'34export const getSubscription = cache(async () => {5 const supabase = await createClient()6 const { data: { user } } = await supabase.auth.getUser()78 if (!user) return null910 const { data: subscription } = await supabase11 .from('subscriptions')12 .select('*, plans(*)')13 .eq('user_id', user.id)14 .in('status', ['active', 'trialing'])15 .single()1617 return subscription18})1920export async function requireSubscription() {21 const subscription = await getSubscription()22 if (!subscription) {23 throw new Error('Subscription required')24 }25 return subscription26}2728export async function hasFeature(feature: string) {29 const subscription = await getSubscription()30 if (!subscription) return false31 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
1import { createClient } from '@/lib/supabase/server'2import { cache } from 'react'34type Subscription = {5 id: string6 user_id: string7 plan_id: string8 stripe_subscription_id: string9 status: string10 current_period_end: string11 cancel_at_period_end: boolean12 plans: {13 name: string14 features: string[]15 price_cents: number16 } | null17}1819export const getSubscription = cache(20 async (): Promise<Subscription | null> => {21 const supabase = await createClient()22 const {23 data: { user },24 } = await supabase.auth.getUser()2526 if (!user) return null2728 const { data } = await supabase29 .from('subscriptions')30 .select('*, plans(*)')31 .eq('user_id', user.id)32 .in('status', ['active', 'trialing'])33 .single()3435 return data36 }37)3839export async function hasFeature(feature: string) {40 const sub = await getSubscription()41 if (!sub?.plans) return false42 return (sub.plans.features as string[]).includes(feature)43}4445export 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.
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.
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.
Talk to an Expert
Our team has built 600+ apps. Get personalized help with your project.
Book a free consultation