# How to Build a Membership Site with Lovable

- Tool: How to Build with Lovable
- Difficulty: Intermediate
- Compatibility: Lovable Pro or higher
- Last updated: April 2026

## TL;DR

Build a tiered membership site in Lovable where Stripe subscriptions gate access to content. RLS policies check a user's subscription tier against each content item's required tier. A Stripe webhook keeps the tier column in sync when users upgrade, downgrade, or cancel — so access control lives entirely in the database, not in frontend logic.

## Before you start

- Stripe account with at least two subscription products (e.g. Free, Pro) created in the Stripe Dashboard
- Stripe publishable key and secret key saved to Cloud tab → Secrets
- Stripe webhook secret saved as STRIPE_WEBHOOK_SECRET in Cloud tab → Secrets
- Supabase Auth set up with a profiles table (or user identity layer)
- Your published Lovable URL added to Stripe's allowed redirect URLs in the Checkout settings

## Step-by-step guide

### 1. Create the subscriptions schema and RLS gating policies

Prompt Lovable to create the subscriptions table and the tier-based RLS policies. This is the foundation — every other step builds on top of it.

```
Create a membership subscription schema in Supabase.

Tables:

1. subscriptions:
   id uuid primary key default gen_random_uuid()
   user_id uuid references auth.users(id) on delete cascade unique
   stripe_customer_id text unique
   stripe_subscription_id text unique
   tier text not null default 'free' — values: 'free', 'pro', 'enterprise'
   status text not null default 'active' — values: 'active', 'trialing', 'past_due', 'canceled', 'incomplete'
   current_period_end timestamptz
   cancel_at_period_end boolean default false
   updated_at timestamptz default now()
   created_at timestamptz default now()

2. content_items:
   id uuid primary key default gen_random_uuid()
   title text not null
   slug text unique not null
   excerpt text
   body text
   required_tier text not null default 'free' — 'free', 'pro', 'enterprise'
   category text
   thumbnail_url text
   published_at timestamptz
   created_at timestamptz default now()

RLS on subscriptions:
  SELECT: users can read their own row (user_id = auth.uid())
  INSERT/UPDATE: service role only (webhook updates, not direct user writes)

RLS on content_items:
  SELECT: USING (
    required_tier = 'free'
    OR EXISTS (
      SELECT 1 FROM subscriptions
      WHERE user_id = auth.uid()
      AND status IN ('active', 'trialing')
      AND (
        (tier = 'pro' AND required_tier IN ('free', 'pro'))
        OR (tier = 'enterprise' AND required_tier IN ('free', 'pro', 'enterprise'))
      )
    )
  )
  INSERT/UPDATE/DELETE: service role only

Create a helper function get_user_tier(p_user_id uuid) RETURNS text that returns the user's current tier or 'free' if no subscription row exists.

Seed 6 sample content_items: 2 free, 2 pro, 2 enterprise.
```

> Pro tip: Add a check constraint on subscriptions.tier: CHECK (tier IN ('free', 'pro', 'enterprise')). This prevents webhook bugs from inserting invalid tier values that would silently break the RLS comparison.

**Expected result:** Both tables are created. RLS policies exist. Free content is readable by all authenticated users. Pro and enterprise content returns no rows for users without a matching subscription.

### 2. Build the pricing page with Stripe Checkout

Create the pricing page with plan comparison cards and Stripe Checkout integration via an Edge Function. The Edge Function creates a Checkout Session and returns the URL.

```
Step 1: Create a Supabase Edge Function at supabase/functions/create-checkout-session/index.ts.

The function:
1. Verifies the caller's JWT (use Supabase client with Authorization header)
2. Accepts POST body: { price_id: string, success_url: string, cancel_url: string }
3. Looks up or creates a Stripe customer for the user:
   - Check subscriptions table for existing stripe_customer_id
   - If none: call stripe.customers.create({ email: user.email, metadata: { supabase_user_id: user.id } })
   - Save the new customer ID to subscriptions table
4. Call stripe.checkout.sessions.create({
     customer: customerId,
     mode: 'subscription',
     line_items: [{ price: price_id, quantity: 1 }],
     success_url: success_url + '?session_id={CHECKOUT_SESSION_ID}',
     cancel_url: cancel_url,
     subscription_data: { metadata: { supabase_user_id: user.id } }
   })
5. Return { url: session.url }

Use STRIPE_SECRET_KEY from Deno.env.get.

Step 2: Build src/pages/Pricing.tsx:
- Three plan Cards: Free, Pro ($29/mo), Enterprise ($99/mo)
- Each Card lists features with checkmarks
- Pro Card: 'Get Started' Button calls the Edge Function with the Pro price_id from your Stripe Dashboard
- On response, redirect to the Checkout URL: window.location.href = response.url
- Current plan Badge on the card matching the user's current tier (fetched from subscriptions)
- Already-subscribed users see 'Current Plan' instead of the upgrade button
```

> Pro tip: Set the STRIPE_PRICE_ID_PRO and STRIPE_PRICE_ID_ENTERPRISE in Cloud tab → Secrets rather than hardcoding them. This lets you swap prices for annual billing or promotions without changing code.

**Expected result:** The pricing page renders three plan cards. Clicking Get Started for Pro calls the Edge Function and redirects to Stripe Checkout. After payment, Stripe redirects back to your success URL.

### 3. Build the webhook Edge Function for tier sync

Create the webhook handler that listens for Stripe billing events and updates the subscriptions table. This is the sync layer that keeps access control in sync with billing.

```
Create a Supabase Edge Function at supabase/functions/stripe-webhook/index.ts.

The function:
1. Reads the raw request body as text (do NOT parse as JSON before verification)
2. Calls stripe.webhooks.constructEventAsync(body, signature, STRIPE_WEBHOOK_SECRET) — use ASYNC version for Deno
3. Handles these event types:

   checkout.session.completed:
   - Get the subscription ID from event.data.object.subscription
   - Get the customer ID from event.data.object.customer
   - Get supabase_user_id from event.data.object.subscription_data.metadata or lookup by customer
   - Fetch the subscription from Stripe: stripe.subscriptions.retrieve(subscriptionId)
   - Map the Stripe price_id to your tier: use a lookup object { [PRICE_ID_PRO]: 'pro', [PRICE_ID_ENTERPRISE]: 'enterprise' }
   - Upsert subscriptions table with user_id, stripe_customer_id, stripe_subscription_id, tier, status, current_period_end

   customer.subscription.updated:
   - Same as above: fetch subscription, map price to tier, upsert subscriptions table
   - Handle cancel_at_period_end = true: set cancel_at_period_end in your table

   customer.subscription.deleted:
   - Find subscription by stripe_subscription_id
   - Update: status = 'canceled', tier = 'free'

4. Return 200 for all handled events, 400 for signature verification failures

IMPORTANT: Return 200 even for unhandled event types — Stripe retries events that return non-2xx.

Register this webhook in Stripe Dashboard → Webhooks → Add endpoint with your Edge Function URL. Select events: checkout.session.completed, customer.subscription.updated, customer.subscription.deleted.
```

> Pro tip: Log each processed webhook event to a stripe_webhook_log table (event_id, event_type, processed_at, payload jsonb). This lets you replay failed events and debug sync issues without contacting Stripe support.

**Expected result:** After a successful Stripe Checkout, the webhook fires, and the subscriptions table is updated with the correct tier. Pro content becomes accessible to the user immediately.

### 4. Build the membership gate component

Create a reusable MembershipGate component that wraps gated content. It shows a blurred preview with an upgrade prompt for users below the required tier.

```
// src/components/MembershipGate.tsx
import { useAuth } from '@/contexts/AuthContext'
import { useSubscription } from '@/hooks/useSubscription'
import { Button } from '@/components/ui/button'
import { Lock } from 'lucide-react'
import { useNavigate } from 'react-router-dom'

const TIER_ORDER: Record<string, number> = {
  free: 0,
  pro: 1,
  enterprise: 2,
}

type Props = {
  requiredTier: 'free' | 'pro' | 'enterprise'
  children: React.ReactNode
  preview?: React.ReactNode
}

export function MembershipGate({ requiredTier, children, preview }: Props) {
  const { user } = useAuth()
  const { tier, loading } = useSubscription()
  const navigate = useNavigate()

  if (loading) return <div className='h-48 animate-pulse bg-muted rounded-lg' />

  const userTierLevel = TIER_ORDER[tier ?? 'free'] ?? 0
  const requiredLevel = TIER_ORDER[requiredTier] ?? 0

  if (!user || userTierLevel < requiredLevel) {
    return (
      <div className='relative overflow-hidden rounded-lg'>
        {preview && (
          <div className='pointer-events-none select-none blur-sm opacity-60'>
            {preview}
          </div>
        )}
        <div className='absolute inset-0 flex flex-col items-center justify-center bg-background/80 backdrop-blur-sm gap-4 p-6 text-center'>
          <Lock className='h-8 w-8 text-muted-foreground' />
          <p className='font-semibold text-lg'>This content requires the {requiredTier} plan</p>
          <p className='text-muted-foreground text-sm max-w-xs'>
            Upgrade your subscription to unlock this and all other {requiredTier} content.
          </p>
          <Button onClick={() => navigate('/pricing')}>
            Upgrade to {requiredTier.charAt(0).toUpperCase() + requiredTier.slice(1)}
          </Button>
        </div>
      </div>
    )
  }

  return <>{children}</>
}
```

> Pro tip: Pass a preview prop to MembershipGate containing the first paragraph or thumbnail of the gated content. Showing a blurred preview performs better than a blank lock screen — users can see what they're missing.

**Expected result:** Wrapping content in <MembershipGate requiredTier='pro'> shows a blurred preview with an upgrade prompt for free users. Pro subscribers see the full content.

### 5. Add Stripe Customer Portal for subscription management

Let users manage their own billing — upgrade, downgrade, cancel, or update payment method — through Stripe's hosted Customer Portal. One Edge Function call gets the portal URL.

```
Create a Supabase Edge Function at supabase/functions/create-portal-session/index.ts.

The function:
1. Verifies the caller's JWT
2. Looks up the user's stripe_customer_id from the subscriptions table
3. If no customer ID exists, return 400: 'No active subscription found'
4. Call stripe.billingPortal.sessions.create({
     customer: stripe_customer_id,
     return_url: request headers origin + '/account'
   })
5. Return { url: session.url }

In the frontend Account or Profile page, add a 'Manage Billing' Button:
- On click, call the Edge Function
- On response, redirect: window.location.href = response.url
- Show a loading spinner on the button while the call is in progress
- Show a toast on error: 'Could not open billing portal. Please try again.'

Also add a subscription status Card to the Account page showing:
- Current tier Badge
- Renewal date (current_period_end formatted)
- Cancel at period end warning if applicable: 'Your subscription cancels on [date]'
- A 'Reactivate Subscription' Button if cancel_at_period_end is true
```

> Pro tip: Enable the Stripe Customer Portal in your Stripe Dashboard (Billing → Customer portal) and configure which features are available: changing plans, canceling, and updating payment methods. You control what users can do without building those flows yourself.

**Expected result:** Clicking Manage Billing redirects to the Stripe-hosted portal. Users can update their card, change plans, or cancel. Stripe sends webhooks and your subscriptions table updates automatically.

## Complete code example

File: `src/components/MembershipGate.tsx`

```typescript
import { useAuth } from '@/contexts/AuthContext'
import { useSubscription } from '@/hooks/useSubscription'
import { Button } from '@/components/ui/button'
import { Lock } from 'lucide-react'
import { useNavigate } from 'react-router-dom'

const TIER_ORDER: Record<string, number> = {
  free: 0,
  pro: 1,
  enterprise: 2,
}

type Props = {
  requiredTier: 'free' | 'pro' | 'enterprise'
  children: React.ReactNode
  preview?: React.ReactNode
  className?: string
}

export function MembershipGate({ requiredTier, children, preview, className }: Props) {
  const { user } = useAuth()
  const { tier, loading } = useSubscription()
  const navigate = useNavigate()

  if (loading) {
    return <div className={`h-48 animate-pulse bg-muted rounded-lg ${className ?? ''}`} />
  }

  const userLevel = TIER_ORDER[tier ?? 'free'] ?? 0
  const reqLevel = TIER_ORDER[requiredTier] ?? 0

  if (!user || userLevel < reqLevel) {
    return (
      <div className={`relative overflow-hidden rounded-lg border ${className ?? ''}`}>
        {preview && (
          <div className='pointer-events-none select-none blur-sm opacity-50'>
            {preview}
          </div>
        )}
        <div className='absolute inset-0 flex flex-col items-center justify-center bg-background/80 backdrop-blur-sm gap-3 p-6 text-center'>
          <Lock className='h-8 w-8 text-muted-foreground' />
          <p className='font-semibold text-base'>
            {requiredTier.charAt(0).toUpperCase() + requiredTier.slice(1)} plan required
          </p>
          <p className='text-muted-foreground text-sm max-w-xs'>
            Upgrade to unlock this content and everything else in the {requiredTier} tier.
          </p>
          <Button size='sm' onClick={() => navigate('/pricing')}>
            View Plans
          </Button>
        </div>
      </div>
    )
  }

  return <>{children}</>
}
```

## Common mistakes

- **Calling stripe.webhooks.constructEvent() instead of constructEventAsync() in Deno** — The synchronous constructEvent() uses Node.js crypto internals that are not available in Deno's runtime. It throws a runtime error that makes all webhook requests fail. Fix: Always use stripe.webhooks.constructEventAsync(body, signature, secret) in Supabase Edge Functions. The async version uses the Web Crypto API which is available in Deno.
- **Reading the request body as JSON before passing it to constructEventAsync** — Stripe's webhook signature is computed over the raw request body bytes. Parsing as JSON and re-serializing changes the string, invalidating the signature check — all webhooks fail with 'No signatures found matching the expected signature'. Fix: Read the raw body as text: const body = await req.text(). Pass this string directly to constructEventAsync. Only parse the body as JSON after successful signature verification.
- **Gating content only in the frontend without database RLS** — A savvy user can call your Supabase API directly with their JWT token and retrieve all content regardless of what the frontend shows. Fix: Always enforce access via RLS policies on the content table. The frontend MembershipGate component is for UX — it shows the upgrade prompt. The RLS policy is what actually prevents data from being fetched.
- **Not handling customer.subscription.updated for downgrade scenarios** — Stripe sends this event when a user's subscription is downgraded or enters past_due status. If you only handle checkout.session.completed, users who cancel or go past-due keep their premium access indefinitely. Fix: Always handle customer.subscription.updated and customer.subscription.deleted in your webhook. On updated, re-read the tier from the Stripe subscription's price ID. On deleted, set tier = 'free' and status = 'canceled'.
- **Hardcoding Stripe price IDs in frontend code** — Price IDs appear in your frontend bundle, visible to anyone who inspects the page source. While they're not secret, hardcoding them makes plan changes require a code deployment. Fix: Store price IDs in Cloud tab → Secrets and read them in the Edge Function via Deno.env.get(). Alternatively, fetch them from a prices table in Supabase that the frontend can read.

## Best practices

- Enforce access via RLS at the database layer — the frontend gate is UX only
- Handle all three subscription webhook events: checkout.session.completed, subscription.updated, and subscription.deleted
- Use Stripe's hosted Customer Portal for billing management — it handles edge cases (3DS, failed payments, prorations) that are difficult to implement yourself
- Log every webhook event to a stripe_events table before processing — this enables event replay when debugging sync issues
- Test your entire billing flow in Stripe's test mode using card number 4242 4242 4242 4242 before going live
- Store cancel_at_period_end in your subscriptions table and show users a countdown banner warning them access ends soon
- Never grant access based on a Stripe checkout success redirect URL — only trust webhook events which are cryptographically signed
- Keep the tier comparison logic in one place (the RLS policy and the useSubscription hook) — avoid duplicating tier checks across components

## Frequently asked questions

### What happens to a user's access when their subscription is canceled?

Cancellation in Stripe usually means cancel_at_period_end = true: the subscription stays active until the billing period ends, then Stripe fires customer.subscription.deleted. Your webhook sets tier = 'free' and status = 'canceled'. Until that event fires, the user retains full access — which is the correct behavior since they've paid for the current period.

### How do I test the webhook locally in Lovable?

You can't run the webhook locally in Lovable since Edge Functions run on Supabase's servers. Deploy the Edge Function (Lovable does this automatically) and use Stripe's webhook test events: in Stripe Dashboard → Webhooks, select your endpoint and click 'Send test webhook'. Choose checkout.session.completed and send a test payload to verify your handler works.

### Can a user have multiple active subscriptions?

The subscriptions table has a UNIQUE constraint on user_id, so each user has exactly one subscription row. If a user buys a second plan, the webhook's UPSERT updates the existing row with the new tier. This matches the typical membership model where upgrading replaces the existing plan.

### How do I handle the case where a user's payment fails?

Stripe sends customer.subscription.updated with status = 'past_due' when payment fails. Your webhook updates the subscriptions row with this status. Update your RLS policy to treat past_due as limited access (or full access depending on your grace period policy). Stripe automatically retries the payment and sends another webhook when it succeeds or the subscription is canceled.

### Can I show a preview of gated content to encourage upgrades?

Yes. Pass the preview prop to MembershipGate with the first few lines or a thumbnail of the content. The component renders it with a blur filter and an upgrade overlay on top. This gives users a taste of what they're missing, which typically increases conversion compared to a hard lock with no preview.

### How do I give users a coupon or discount code?

Create a promotion code in Stripe Dashboard → Products → Promotion codes. Add a coupon Input field to your pricing page. Pass it to the create-checkout-session Edge Function, which includes it in the Checkout Session as discounts: [{ promotion_code: code }]. Stripe validates the code and applies the discount automatically.

### Do I need to re-deploy the Edge Function when I change Stripe prices?

Only if you hardcoded price IDs in the Edge Function. If you stored them in Cloud tab → Secrets, update the secret value in Supabase — Edge Functions read secrets at runtime, so no redeployment is needed. Lovable will need to read the updated value on the next function invocation.

### Is there a Lovable-native way to set up Stripe without writing Edge Functions?

Lovable has a native Stripe connector (Cloud tab → Shared Connectors) that handles basic Checkout flows for one-time payments. For subscription billing with tier-based content gating and webhook sync, you need the Edge Function approach described here — the native connector doesn't support subscription webhooks.

---

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