Skip to main content
RapidDev - Software Development Agency

How to Build a Membership Site with Lovable

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.

What you'll build

  • A subscriptions table storing Stripe customer ID, subscription ID, and current tier
  • Content tables with a required_tier column controlling who can see each item
  • RLS policies that compare the user's subscription tier to the content's required tier
  • A Stripe Checkout integration for purchasing subscriptions from a pricing page
  • A webhook Edge Function that updates the subscriptions table on Stripe billing events
  • A membership gate component that shows a locked preview with an upgrade prompt
  • A customer portal link that lets users manage their billing without leaving your site
Book a free consultation
4.9Clutch rating
600+Happy partners
17+Countries served
190+Team members
Intermediate15 min read2.5–3 hoursLovable Pro or higherApril 2026RapidDev Engineering Team
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.

What you're building

Content gating is the core mechanism: every piece of premium content has a required_tier value (e.g. 'pro', 'enterprise'). The subscriptions table stores the user's current tier. An RLS SELECT policy on your content table checks that the user's tier meets the requirement: USING (required_tier = 'free' OR EXISTS (SELECT 1 FROM subscriptions WHERE user_id = auth.uid() AND tier >= required_tier AND status = 'active')).

Stripe Checkout handles all the payment complexity. When a user clicks a pricing plan, your app calls a create-checkout-session Edge Function that creates a Stripe Checkout Session and returns the URL. Stripe handles the payment form, card validation, and 3D Secure. After payment, Stripe redirects back to your site and sends a webhook event.

The webhook Edge Function is the sync layer. It listens for checkout.session.completed, customer.subscription.updated, and customer.subscription.deleted events. On each event, it upserts the subscriptions table with the new tier and status. Because RLS reads the database in real time, the user's access updates the moment the webhook fires — no polling needed.

For downgrades and cancellations, Stripe sends customer.subscription.updated with cancel_at_period_end = true or a new status = 'canceled'. Your webhook sets the subscriptions row accordingly, and RLS enforces the change immediately.

Final result

A fully functional membership site where Stripe billing events drive content access — no manual tier management needed.

Tech stack

LovableFrontend membership site and pricing page
SupabaseDatabase and RLS-based content gating
Supabase AuthUser identity for subscription lookup
StripeSubscriptions, Checkout, billing portal
shadcn/uiCard, Badge, Dialog, Button, Separator
Supabase Edge FunctionsCheckout session and webhook handlers

Prerequisites

  • 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

Build steps

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.

prompt.txt
1Create a membership subscription schema in Supabase.
2
3Tables:
4
51. subscriptions:
6 id uuid primary key default gen_random_uuid()
7 user_id uuid references auth.users(id) on delete cascade unique
8 stripe_customer_id text unique
9 stripe_subscription_id text unique
10 tier text not null default 'free' values: 'free', 'pro', 'enterprise'
11 status text not null default 'active' values: 'active', 'trialing', 'past_due', 'canceled', 'incomplete'
12 current_period_end timestamptz
13 cancel_at_period_end boolean default false
14 updated_at timestamptz default now()
15 created_at timestamptz default now()
16
172. content_items:
18 id uuid primary key default gen_random_uuid()
19 title text not null
20 slug text unique not null
21 excerpt text
22 body text
23 required_tier text not null default 'free' 'free', 'pro', 'enterprise'
24 category text
25 thumbnail_url text
26 published_at timestamptz
27 created_at timestamptz default now()
28
29RLS on subscriptions:
30 SELECT: users can read their own row (user_id = auth.uid())
31 INSERT/UPDATE: service role only (webhook updates, not direct user writes)
32
33RLS on content_items:
34 SELECT: USING (
35 required_tier = 'free'
36 OR EXISTS (
37 SELECT 1 FROM subscriptions
38 WHERE user_id = auth.uid()
39 AND status IN ('active', 'trialing')
40 AND (
41 (tier = 'pro' AND required_tier IN ('free', 'pro'))
42 OR (tier = 'enterprise' AND required_tier IN ('free', 'pro', 'enterprise'))
43 )
44 )
45 )
46 INSERT/UPDATE/DELETE: service role only
47
48Create 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.
49
50Seed 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.

prompt.txt
1Step 1: Create a Supabase Edge Function at supabase/functions/create-checkout-session/index.ts.
2
3The function:
41. Verifies the caller's JWT (use Supabase client with Authorization header)
52. Accepts POST body: { price_id: string, success_url: string, cancel_url: string }
63. Looks up or creates a Stripe customer for the user:
7 - Check subscriptions table for existing stripe_customer_id
8 - If none: call stripe.customers.create({ email: user.email, metadata: { supabase_user_id: user.id } })
9 - Save the new customer ID to subscriptions table
104. Call stripe.checkout.sessions.create({
11 customer: customerId,
12 mode: 'subscription',
13 line_items: [{ price: price_id, quantity: 1 }],
14 success_url: success_url + '?session_id={CHECKOUT_SESSION_ID}',
15 cancel_url: cancel_url,
16 subscription_data: { metadata: { supabase_user_id: user.id } }
17 })
185. Return { url: session.url }
19
20Use STRIPE_SECRET_KEY from Deno.env.get.
21
22Step 2: Build src/pages/Pricing.tsx:
23- Three plan Cards: Free, Pro ($29/mo), Enterprise ($99/mo)
24- Each Card lists features with checkmarks
25- Pro Card: 'Get Started' Button calls the Edge Function with the Pro price_id from your Stripe Dashboard
26- On response, redirect to the Checkout URL: window.location.href = response.url
27- Current plan Badge on the card matching the user's current tier (fetched from subscriptions)
28- 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.

prompt.txt
1Create a Supabase Edge Function at supabase/functions/stripe-webhook/index.ts.
2
3The function:
41. Reads the raw request body as text (do NOT parse as JSON before verification)
52. Calls stripe.webhooks.constructEventAsync(body, signature, STRIPE_WEBHOOK_SECRET) use ASYNC version for Deno
63. Handles these event types:
7
8 checkout.session.completed:
9 - Get the subscription ID from event.data.object.subscription
10 - Get the customer ID from event.data.object.customer
11 - Get supabase_user_id from event.data.object.subscription_data.metadata or lookup by customer
12 - Fetch the subscription from Stripe: stripe.subscriptions.retrieve(subscriptionId)
13 - Map the Stripe price_id to your tier: use a lookup object { [PRICE_ID_PRO]: 'pro', [PRICE_ID_ENTERPRISE]: 'enterprise' }
14 - Upsert subscriptions table with user_id, stripe_customer_id, stripe_subscription_id, tier, status, current_period_end
15
16 customer.subscription.updated:
17 - Same as above: fetch subscription, map price to tier, upsert subscriptions table
18 - Handle cancel_at_period_end = true: set cancel_at_period_end in your table
19
20 customer.subscription.deleted:
21 - Find subscription by stripe_subscription_id
22 - Update: status = 'canceled', tier = 'free'
23
244. Return 200 for all handled events, 400 for signature verification failures
25
26IMPORTANT: Return 200 even for unhandled event types Stripe retries events that return non-2xx.
27
28Register 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
1// src/components/MembershipGate.tsx
2import { useAuth } from '@/contexts/AuthContext'
3import { useSubscription } from '@/hooks/useSubscription'
4import { Button } from '@/components/ui/button'
5import { Lock } from 'lucide-react'
6import { useNavigate } from 'react-router-dom'
7
8const TIER_ORDER: Record<string, number> = {
9 free: 0,
10 pro: 1,
11 enterprise: 2,
12}
13
14type Props = {
15 requiredTier: 'free' | 'pro' | 'enterprise'
16 children: React.ReactNode
17 preview?: React.ReactNode
18}
19
20export function MembershipGate({ requiredTier, children, preview }: Props) {
21 const { user } = useAuth()
22 const { tier, loading } = useSubscription()
23 const navigate = useNavigate()
24
25 if (loading) return <div className='h-48 animate-pulse bg-muted rounded-lg' />
26
27 const userTierLevel = TIER_ORDER[tier ?? 'free'] ?? 0
28 const requiredLevel = TIER_ORDER[requiredTier] ?? 0
29
30 if (!user || userTierLevel < requiredLevel) {
31 return (
32 <div className='relative overflow-hidden rounded-lg'>
33 {preview && (
34 <div className='pointer-events-none select-none blur-sm opacity-60'>
35 {preview}
36 </div>
37 )}
38 <div className='absolute inset-0 flex flex-col items-center justify-center bg-background/80 backdrop-blur-sm gap-4 p-6 text-center'>
39 <Lock className='h-8 w-8 text-muted-foreground' />
40 <p className='font-semibold text-lg'>This content requires the {requiredTier} plan</p>
41 <p className='text-muted-foreground text-sm max-w-xs'>
42 Upgrade your subscription to unlock this and all other {requiredTier} content.
43 </p>
44 <Button onClick={() => navigate('/pricing')}>
45 Upgrade to {requiredTier.charAt(0).toUpperCase() + requiredTier.slice(1)}
46 </Button>
47 </div>
48 </div>
49 )
50 }
51
52 return <>{children}</>
53}

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.

prompt.txt
1Create a Supabase Edge Function at supabase/functions/create-portal-session/index.ts.
2
3The function:
41. Verifies the caller's JWT
52. Looks up the user's stripe_customer_id from the subscriptions table
63. If no customer ID exists, return 400: 'No active subscription found'
74. Call stripe.billingPortal.sessions.create({
8 customer: stripe_customer_id,
9 return_url: request headers origin + '/account'
10 })
115. Return { url: session.url }
12
13In the frontend Account or Profile page, add a 'Manage Billing' Button:
14- On click, call the Edge Function
15- On response, redirect: window.location.href = response.url
16- Show a loading spinner on the button while the call is in progress
17- Show a toast on error: 'Could not open billing portal. Please try again.'
18
19Also add a subscription status Card to the Account page showing:
20- Current tier Badge
21- Renewal date (current_period_end formatted)
22- Cancel at period end warning if applicable: 'Your subscription cancels on [date]'
23- 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

src/components/MembershipGate.tsx
1import { useAuth } from '@/contexts/AuthContext'
2import { useSubscription } from '@/hooks/useSubscription'
3import { Button } from '@/components/ui/button'
4import { Lock } from 'lucide-react'
5import { useNavigate } from 'react-router-dom'
6
7const TIER_ORDER: Record<string, number> = {
8 free: 0,
9 pro: 1,
10 enterprise: 2,
11}
12
13type Props = {
14 requiredTier: 'free' | 'pro' | 'enterprise'
15 children: React.ReactNode
16 preview?: React.ReactNode
17 className?: string
18}
19
20export function MembershipGate({ requiredTier, children, preview, className }: Props) {
21 const { user } = useAuth()
22 const { tier, loading } = useSubscription()
23 const navigate = useNavigate()
24
25 if (loading) {
26 return <div className={`h-48 animate-pulse bg-muted rounded-lg ${className ?? ''}`} />
27 }
28
29 const userLevel = TIER_ORDER[tier ?? 'free'] ?? 0
30 const reqLevel = TIER_ORDER[requiredTier] ?? 0
31
32 if (!user || userLevel < reqLevel) {
33 return (
34 <div className={`relative overflow-hidden rounded-lg border ${className ?? ''}`}>
35 {preview && (
36 <div className='pointer-events-none select-none blur-sm opacity-50'>
37 {preview}
38 </div>
39 )}
40 <div className='absolute inset-0 flex flex-col items-center justify-center bg-background/80 backdrop-blur-sm gap-3 p-6 text-center'>
41 <Lock className='h-8 w-8 text-muted-foreground' />
42 <p className='font-semibold text-base'>
43 {requiredTier.charAt(0).toUpperCase() + requiredTier.slice(1)} plan required
44 </p>
45 <p className='text-muted-foreground text-sm max-w-xs'>
46 Upgrade to unlock this content and everything else in the {requiredTier} tier.
47 </p>
48 <Button size='sm' onClick={() => navigate('/pricing')}>
49 View Plans
50 </Button>
51 </div>
52 </div>
53 )
54 }
55
56 return <>{children}</>
57}

Customization ideas

Free trial without a credit card

In the Stripe Checkout Session, set trial_period_days: 14. The user gets full Pro access for 14 days. Your webhook handles trialing status and your RLS treats trialing the same as active.

Annual billing with discount

Create annual price IDs in Stripe and add a monthly/annual toggle to the pricing page. The toggle switches which price_id is passed to the checkout Edge Function. Annual prices typically have 2 months free.

Team subscriptions

Add a team_members table (team_id, user_id). When one team member has a Pro subscription, all team members get Pro access. Modify the RLS policy to also check team membership.

Metered billing

For usage-based features (e.g. API calls), use Stripe's metered billing. Track usage in a usage_events table and have an Edge Function report usage to Stripe via stripe.subscriptionItems.createUsageRecord().

Affiliate and coupon system

Create promotion codes in Stripe Dashboard. Add a coupon code Input on your pricing page and pass it to the Checkout Session as discounts: [{ promotion_code: code }]. Track which affiliate referred the signup in subscription metadata.

Content drip schedule

Add a drip_days column to content_items. Show content only if the user's subscription has been active for at least drip_days days. Modify the RLS policy to check (now() - subscriptions.created_at) > (interval '1 day' * content_items.drip_days).

Common pitfalls

Pitfall: Calling stripe.webhooks.constructEvent() instead of constructEventAsync() in Deno

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

Pitfall: Reading the request body as JSON before passing it to constructEventAsync

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

Pitfall: Gating content only in the frontend without database RLS

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

Pitfall: Not handling customer.subscription.updated for downgrade scenarios

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

Pitfall: Hardcoding Stripe price IDs in frontend code

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

AI prompts to try

Copy these prompts to build this project faster.

ChatGPT Prompt

I'm building a Supabase RLS policy for content gating based on subscription tier. My tiers are free, pro, enterprise in ascending order. My subscriptions table has a tier column. I want pro users to see free and pro content, and enterprise users to see everything. How do I write a RLS SELECT policy that handles this tier hierarchy without using a hard-coded list of allowed tiers per tier level?

Lovable Prompt

Add a free trial flow to my membership site. When a new user signs up, automatically start a 7-day Pro trial without requiring a credit card. Create a trial_ends_at column in subscriptions with a default of now() + 7 days. Update the RLS policy to also grant Pro access if trial_ends_at > now(). Show a trial countdown banner at the top of the page with days remaining and an 'Activate Subscription' button that goes to the pricing page.

Build Prompt

In my Lovable app, my stripe-webhook Edge Function handles checkout.session.completed. I need to get the user's supabase_user_id from the event to update the subscriptions table. I stored it in subscription_data.metadata when creating the Checkout Session, but I'm getting undefined when I read event.data.object.subscription_data?.metadata. How do I correctly retrieve custom metadata from a completed checkout session webhook payload in Stripe?

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.

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.