# How to Build Membership site with V0

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

## TL;DR

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.

## Before you start

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

## Step-by-step guide

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

```
// Paste this prompt into V0's AI chat:
// Build a membership site. Create a Supabase schema:
// 1. plans: id (uuid PK), name (text), stripe_price_id (text), tier (int), features (text[]), is_active (boolean DEFAULT true)
// 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)
// 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)
// 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)
// Seed plans: Free (tier 0), Basic (tier 1), Premium (tier 2).
// 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.

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

```
// Paste this prompt into V0's AI chat:
// Create a membership landing page at app/page.tsx.
// Requirements:
// - Hero section: headline about premium content, subheading, CTA Button
// - Pricing section with shadcn/ui Cards for each plan:
//   - Plan name, price/month, tier Badge, features list with checkmarks
//   - 'Subscribe' Button on paid plans → creates Stripe Checkout session
//   - 'Get Started' on free plan → sign up with Supabase Auth
//   - Highlight the recommended plan with a border and 'Popular' Badge
// - FAQ section with Accordion: billing questions, cancellation, content access
// - Separator between sections
// - Server Action for creating Stripe Checkout subscription session
```

**Expected result:** The landing page shows pricing cards with Stripe Checkout buttons that create subscription sessions.

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

```
// Paste this prompt into V0's AI chat:
// Create content pages:
// 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.
// 2. app/content/[slug]/page.tsx — Server Component that:
//   a. Fetches the content by slug
//   b. Checks the user's membership tier from the memberships table
//   c. If tier >= tier_required, renders the full article body
//   d. If tier < tier_required, renders the excerpt + a Paywall component:
//      - Shows excerpt text fading out with gradient overlay
//      - 'Unlock this content' Card with the required plan name and Subscribe Button
//   e. Tracks content_progress (last_accessed timestamp) for authenticated users
// - Add category filter Tabs and search Input on the library page
```

**Expected result:** The content library shows all content with lock indicators. Article pages check tier and show full content or a paywall.

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

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

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

  const subscription = event.data.object as Stripe.Subscription
  const userId = subscription.metadata?.user_id

  if (!userId) return NextResponse.json({ received: true })

  switch (event.type) {
    case 'customer.subscription.created':
    case 'customer.subscription.updated': {
      const priceId = subscription.items.data[0]?.price.id
      const { data: plan } = await supabase
        .from('plans')
        .select('id')
        .eq('stripe_price_id', priceId)
        .single()

      await supabase.from('memberships').upsert({
        user_id: userId,
        plan_id: plan?.id,
        stripe_subscription_id: subscription.id,
        status: subscription.status === 'active' ? 'active' : 'inactive',
        current_period_end: new Date(subscription.current_period_end * 1000).toISOString(),
      })
      break
    }
    case 'customer.subscription.deleted': {
      await supabase.from('memberships')
        .update({ status: 'cancelled' })
        .eq('stripe_subscription_id', subscription.id)
      break
    }
  }

  revalidatePath('/content')
  return NextResponse.json({ received: true })
}
```

**Expected result:** Subscription changes in Stripe automatically update the membership table and revalidate cached content pages.

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

```
// Paste this prompt into V0's AI chat:
// Create an account page at app/account/page.tsx.
// Requirements:
// - Fetch the current user's membership with plan details
// - Show a Card with: plan name, tier Badge, status Badge (active=green, cancelled=red), current_period_end date
// - 'Manage Subscription' Button that creates a Stripe billing portal session and redirects
// - Content progress section: Table showing accessed content with completed checkmarks and last_accessed dates
// - If no membership, show a CTA Card: 'Join a plan to access premium content' with link to pricing
// - Server Action for creating the Stripe billing portal session

// The Server Action:
'use server'
import Stripe from 'stripe'
import { redirect } from 'next/navigation'

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

export async function createPortalSession(customerId: string) {
  const session = await stripe.billingPortal.sessions.create({
    customer: customerId,
    return_url: `${process.env.NEXT_PUBLIC_APP_URL}/account`,
  })
  redirect(session.url)
}
```

> 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 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'
import { revalidatePath } from 'next/cache'

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 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 sig' }, { status: 400 })
  }

  const sub = event.data.object as Stripe.Subscription
  const userId = sub.metadata?.user_id
  if (!userId) return NextResponse.json({ received: true })

  if (
    event.type === 'customer.subscription.created' ||
    event.type === 'customer.subscription.updated'
  ) {
    const priceId = sub.items.data[0]?.price.id
    const { data: plan } = await supabase
      .from('plans')
      .select('id')
      .eq('stripe_price_id', priceId)
      .single()

    await supabase.from('memberships').upsert({
      user_id: userId,
      plan_id: plan?.id,
      stripe_subscription_id: sub.id,
      status: sub.status === 'active' ? 'active' : 'inactive',
      current_period_end: new Date(
        sub.current_period_end * 1000
      ).toISOString(),
    })
  }

  if (event.type === 'customer.subscription.deleted') {
    await supabase.from('memberships')
      .update({ status: 'cancelled' })
      .eq('stripe_subscription_id', sub.id)
  }

  revalidatePath('/content')
  return NextResponse.json({ received: true })
}
```

## Common mistakes

- **Checking membership status only at build time, not on each request** — If a membership expires or is cancelled, the cached page still shows premium content until the next rebuild. Fix: Use revalidatePath('/content') in the Stripe webhook handler to bust the cache when subscription status changes. Or use dynamic rendering for gated content pages.
- **Using request.json() instead of request.text() in the Stripe webhook** — Stripe signature verification needs the raw body. Parsing changes the format and breaks verification. Fix: Always use request.text() and pass the raw body to stripe.webhooks.constructEvent().
- **Gating content only with client-side checks** — Users can inspect the HTML source or disable JavaScript to see premium content. Client-side gates are cosmetic only. Fix: 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

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

---

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