Build a gated content membership site with V0 using Next.js, Supabase, Stripe, and shadcn/ui. Free users see teasers, paid members access premium content based on their tier, and Stripe webhooks automatically sync subscription status for real-time access control. Takes about 1-2 hours.
What you're building
Creators, coaches, and community builders monetize their knowledge through membership sites with tiered content access. A custom-built membership platform gives you full control over pricing, content gates, and the member experience without Patreon's 8-12% cut.
V0 generates the pricing page, content library, and paywall components from prompts. Stripe via the Vercel Marketplace handles recurring subscriptions with automatic key provisioning. Supabase stores membership records and content.
The architecture uses Stripe webhooks to sync subscription status in real-time, a Server Component content page that checks membership tier before rendering, revalidatePath in the webhook handler to bust cached pages, and Stripe's billing portal for self-service subscription management.
Final result
A membership site with tiered content access, Stripe subscription billing, webhook-synced membership status, and a self-service billing portal.
Tech stack
Prerequisites
- A V0 account (Premium or higher recommended)
- A Supabase project (free tier works — connect via V0's Connect panel)
- A Stripe account with products and prices created (test mode works)
- Your content and tier structure defined (what is free, basic, premium)
Build steps
Set up the database schema for plans, memberships, and content
Create the Supabase schema linking Stripe subscription data to content tier requirements. Each content piece has a tier_required level that determines which members can access it.
1// Paste this prompt into V0's AI chat:2// Build a membership site. Create a Supabase schema:3// 1. plans: id (uuid PK), name (text), stripe_price_id (text), tier (int), features (text[]), is_active (boolean DEFAULT true)4// 2. memberships: id (uuid PK), user_id (uuid FK to auth.users), plan_id (uuid FK to plans), stripe_subscription_id (text), status (text DEFAULT 'active'), current_period_end (timestamptz)5// 3. content: id (uuid PK), title (text), slug (text UNIQUE), body (text), excerpt (text), tier_required (int DEFAULT 0), category (text), published_at (timestamptz), author_id (uuid FK to auth.users)6// 4. content_progress: user_id (uuid FK to auth.users), content_id (uuid FK to content), completed (boolean DEFAULT false), last_accessed (timestamptz), PRIMARY KEY (user_id, content_id)7// Seed plans: Free (tier 0), Basic (tier 1), Premium (tier 2).8// Add RLS policies. Generate SQL and TypeScript types.Pro tip: Use V0's Stripe integration via Vercel Marketplace to auto-provision STRIPE_SECRET_KEY and NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY into the Vars tab.
Expected result: Supabase is connected with plans seeded, content table with tier gating, and Stripe keys in the Vars tab.
Build the landing page with pricing table
Create the public landing page with a hero section and pricing table. Each plan card links to Stripe Checkout for subscription creation.
1// Paste this prompt into V0's AI chat:2// Create a membership landing page at app/page.tsx.3// Requirements:4// - Hero section: headline about premium content, subheading, CTA Button5// - Pricing section with shadcn/ui Cards for each plan:6// - Plan name, price/month, tier Badge, features list with checkmarks7// - 'Subscribe' Button on paid plans → creates Stripe Checkout session8// - 'Get Started' on free plan → sign up with Supabase Auth9// - Highlight the recommended plan with a border and 'Popular' Badge10// - FAQ section with Accordion: billing questions, cancellation, content access11// - Separator between sections12// - Server Action for creating Stripe Checkout subscription sessionExpected result: The landing page shows pricing cards with Stripe Checkout buttons that create subscription sessions.
Create the content library with tier-based access control
Build the content library page and individual article pages with server-side tier checking. Content above the user's tier shows a paywall with an upgrade CTA.
1// Paste this prompt into V0's AI chat:2// Create content pages:3// 1. app/content/page.tsx — content library with Card grid. Each Card shows: title, excerpt, category Badge, tier Badge. If user's membership tier < content tier_required, show a Lock icon overlay and 'Upgrade to Access' Badge.4// 2. app/content/[slug]/page.tsx — Server Component that:5// a. Fetches the content by slug6// b. Checks the user's membership tier from the memberships table7// c. If tier >= tier_required, renders the full article body8// d. If tier < tier_required, renders the excerpt + a Paywall component:9// - Shows excerpt text fading out with gradient overlay10// - 'Unlock this content' Card with the required plan name and Subscribe Button11// e. Tracks content_progress (last_accessed timestamp) for authenticated users12// - Add category filter Tabs and search Input on the library pageExpected result: The content library shows all content with lock indicators. Article pages check tier and show full content or a paywall.
Build the Stripe webhook for subscription sync
Create the webhook handler that keeps membership status in sync with Stripe. When subscriptions are created, updated, or cancelled, the membership record is updated and cached pages are revalidated.
1import { NextRequest, NextResponse } from 'next/server'2import Stripe from 'stripe'3import { createClient } from '@supabase/supabase-js'4import { revalidatePath } from 'next/cache'56const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)7const supabase = createClient(8 process.env.SUPABASE_URL!,9 process.env.SUPABASE_SERVICE_ROLE_KEY!10)1112export async function POST(req: NextRequest) {13 const rawBody = await req.text()14 const sig = req.headers.get('stripe-signature')!1516 let event: Stripe.Event17 try {18 event = stripe.webhooks.constructEvent(rawBody, sig, process.env.STRIPE_WEBHOOK_SECRET!)19 } catch {20 return NextResponse.json({ error: 'Invalid signature' }, { status: 400 })21 }2223 const subscription = event.data.object as Stripe.Subscription24 const userId = subscription.metadata?.user_id2526 if (!userId) return NextResponse.json({ received: true })2728 switch (event.type) {29 case 'customer.subscription.created':30 case 'customer.subscription.updated': {31 const priceId = subscription.items.data[0]?.price.id32 const { data: plan } = await supabase33 .from('plans')34 .select('id')35 .eq('stripe_price_id', priceId)36 .single()3738 await supabase.from('memberships').upsert({39 user_id: userId,40 plan_id: plan?.id,41 stripe_subscription_id: subscription.id,42 status: subscription.status === 'active' ? 'active' : 'inactive',43 current_period_end: new Date(subscription.current_period_end * 1000).toISOString(),44 })45 break46 }47 case 'customer.subscription.deleted': {48 await supabase.from('memberships')49 .update({ status: 'cancelled' })50 .eq('stripe_subscription_id', subscription.id)51 break52 }53 }5455 revalidatePath('/content')56 return NextResponse.json({ received: true })57}Expected result: Subscription changes in Stripe automatically update the membership table and revalidate cached content pages.
Add account management and deploy
Build the member account page with subscription details and a Stripe billing portal link for self-service management (upgrade, downgrade, cancel).
1// Paste this prompt into V0's AI chat:2// Create an account page at app/account/page.tsx.3// Requirements:4// - Fetch the current user's membership with plan details5// - Show a Card with: plan name, tier Badge, status Badge (active=green, cancelled=red), current_period_end date6// - 'Manage Subscription' Button that creates a Stripe billing portal session and redirects7// - Content progress section: Table showing accessed content with completed checkmarks and last_accessed dates8// - If no membership, show a CTA Card: 'Join a plan to access premium content' with link to pricing9// - Server Action for creating the Stripe billing portal session1011// The Server Action:12'use server'13import Stripe from 'stripe'14import { redirect } from 'next/navigation'1516const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)1718export async function createPortalSession(customerId: string) {19 const session = await stripe.billingPortal.sessions.create({20 customer: customerId,21 return_url: `${process.env.NEXT_PUBLIC_APP_URL}/account`,22 })23 redirect(session.url)24}Pro tip: Register webhook events in Stripe Dashboard: customer.subscription.created, customer.subscription.updated, customer.subscription.deleted, and invoice.payment_failed.
Expected result: Members can view their subscription status and manage billing through Stripe's hosted portal. The app is deployed to Vercel.
Complete code
1import { NextRequest, NextResponse } from 'next/server'2import Stripe from 'stripe'3import { createClient } from '@supabase/supabase-js'4import { revalidatePath } from 'next/cache'56const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)7const supabase = createClient(8 process.env.SUPABASE_URL!,9 process.env.SUPABASE_SERVICE_ROLE_KEY!10)1112export async function POST(req: NextRequest) {13 const rawBody = await req.text()14 const sig = req.headers.get('stripe-signature')!1516 let event: Stripe.Event17 try {18 event = stripe.webhooks.constructEvent(19 rawBody, sig, process.env.STRIPE_WEBHOOK_SECRET!20 )21 } catch {22 return NextResponse.json({ error: 'Invalid sig' }, { status: 400 })23 }2425 const sub = event.data.object as Stripe.Subscription26 const userId = sub.metadata?.user_id27 if (!userId) return NextResponse.json({ received: true })2829 if (30 event.type === 'customer.subscription.created' ||31 event.type === 'customer.subscription.updated'32 ) {33 const priceId = sub.items.data[0]?.price.id34 const { data: plan } = await supabase35 .from('plans')36 .select('id')37 .eq('stripe_price_id', priceId)38 .single()3940 await supabase.from('memberships').upsert({41 user_id: userId,42 plan_id: plan?.id,43 stripe_subscription_id: sub.id,44 status: sub.status === 'active' ? 'active' : 'inactive',45 current_period_end: new Date(46 sub.current_period_end * 100047 ).toISOString(),48 })49 }5051 if (event.type === 'customer.subscription.deleted') {52 await supabase.from('memberships')53 .update({ status: 'cancelled' })54 .eq('stripe_subscription_id', sub.id)55 }5657 revalidatePath('/content')58 return NextResponse.json({ received: true })59}Customization ideas
Drip content scheduling
Release content on a schedule after membership start date, unlocking new articles weekly to keep members engaged over time.
Community discussion per article
Add a comments section below each content piece where members can discuss and ask questions, using Supabase Realtime for live updates.
Content completion certificates
Generate PDF certificates when members complete all content in a category, using @react-pdf/renderer for server-side PDF creation.
Free trial period
Add a 7-day free trial by setting trial_period_days in the Stripe Checkout session and showing trial status in the account page.
Common pitfalls
Pitfall: Checking membership status only at build time, not on each request
How to avoid: Use revalidatePath('/content') in the Stripe webhook handler to bust the cache when subscription status changes. Or use dynamic rendering for gated content pages.
Pitfall: Using request.json() instead of request.text() in the Stripe webhook
How to avoid: Always use request.text() and pass the raw body to stripe.webhooks.constructEvent().
Pitfall: Gating content only with client-side checks
How to avoid: Check membership tier in the Server Component before rendering content. Only pass the body prop to the article component if the user's tier is sufficient.
Best practices
- Check membership tier in Server Components before rendering premium content — never rely on client-side gating alone
- Use revalidatePath in the webhook handler to bust cached content pages when subscription status changes
- Use Stripe's billing portal for subscription management — it handles upgrades, downgrades, and cancellations with zero code
- Store stripe_price_id in the plans table to map Stripe subscriptions to your tier system
- Always use request.text() for Stripe webhook raw body verification
- Use V0's Design Mode (Option+D) to adjust pricing card layouts and paywall styling without spending credits
- Register all relevant webhook events: customer.subscription.created, updated, deleted, and invoice.payment_failed
- Use ISR with revalidate for content pages to balance performance with freshness
AI prompts to try
Copy these prompts to build this project faster.
I'm building a membership site with Next.js App Router, Supabase, and Stripe. I need a Stripe webhook handler that syncs subscription status. When customer.subscription.created or updated fires, it should look up the plan by stripe_price_id, upsert the membership record, and call revalidatePath to bust cached content pages. When customer.subscription.deleted fires, it should mark the membership as cancelled. Please write the complete webhook route at app/api/webhooks/stripe/route.ts.
Create a Paywall component that wraps premium content. If the user has sufficient tier, render the full content. If not, show the excerpt with a gradient fade overlay and a Card below it saying 'This content requires [Plan Name] membership' with a Subscribe Button. The component should accept tier_required, user_tier, excerpt, and children (full content) as props. Make it a Server Component that conditionally renders children.
Frequently asked questions
How does content gating work?
Each content piece has a tier_required number (0=free, 1=basic, 2=premium). The content page Server Component checks the user's membership tier from Supabase. If their tier is high enough, it renders the full article. Otherwise, it shows the excerpt with a paywall component prompting an upgrade.
What happens when a subscription is cancelled?
Stripe sends a customer.subscription.deleted webhook. The handler updates the membership status to 'cancelled' and calls revalidatePath to bust cached content pages. The member loses access to gated content immediately.
Can members manage their own subscriptions?
Yes. The account page has a 'Manage Subscription' button that creates a Stripe billing portal session. Members can upgrade, downgrade, cancel, update payment methods, and view invoices through Stripe's hosted portal.
Do I need a paid V0 plan?
Premium ($20/month) is recommended. The membership site has multiple pages (landing, content library, article with paywall, account, webhook handler) that require several prompts.
Can I add a free trial period?
Yes. Add trial_period_days: 7 to the Stripe Checkout session creation. During the trial, the membership status is 'trialing' and the user has full access. The webhook handles the transition to 'active' or 'cancelled' when the trial ends.
How do I deploy the membership site?
Click Share in V0, then Publish to Production. Set STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET, and NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY in the Vars tab. Register webhook events in Stripe Dashboard pointing to your production URL.
Can RapidDev help build a custom membership site?
Yes. RapidDev has built over 600 apps including membership platforms with tiered content, drip scheduling, and community features. Book a free consultation to plan your membership business.
Talk to an Expert
Our team has built 600+ apps. Get personalized help with your project.
Book a free consultation