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
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
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.
1Create a membership subscription schema in Supabase.23Tables:451. subscriptions:6 id uuid primary key default gen_random_uuid()7 user_id uuid references auth.users(id) on delete cascade unique8 stripe_customer_id text unique9 stripe_subscription_id text unique10 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 timestamptz13 cancel_at_period_end boolean default false14 updated_at timestamptz default now()15 created_at timestamptz default now()16172. content_items:18 id uuid primary key default gen_random_uuid()19 title text not null20 slug text unique not null21 excerpt text22 body text23 required_tier text not null default 'free' — 'free', 'pro', 'enterprise'24 category text25 thumbnail_url text26 published_at timestamptz27 created_at timestamptz default now()2829RLS 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)3233RLS on content_items:34 SELECT: USING (35 required_tier = 'free'36 OR EXISTS (37 SELECT 1 FROM subscriptions38 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 only4748Create 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.4950Seed 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.
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.
1Step 1: Create a Supabase Edge Function at supabase/functions/create-checkout-session/index.ts.23The 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_id8 - If none: call stripe.customers.create({ email: user.email, metadata: { supabase_user_id: user.id } })9 - Save the new customer ID to subscriptions table104. 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 }1920Use STRIPE_SECRET_KEY from Deno.env.get.2122Step 2: Build src/pages/Pricing.tsx:23- Three plan Cards: Free, Pro ($29/mo), Enterprise ($99/mo)24- Each Card lists features with checkmarks25- Pro Card: 'Get Started' Button calls the Edge Function with the Pro price_id from your Stripe Dashboard26- On response, redirect to the Checkout URL: window.location.href = response.url27- 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 buttonPro 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.
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.
1Create a Supabase Edge Function at supabase/functions/stripe-webhook/index.ts.23The 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 Deno63. Handles these event types:78 checkout.session.completed:9 - Get the subscription ID from event.data.object.subscription10 - Get the customer ID from event.data.object.customer11 - Get supabase_user_id from event.data.object.subscription_data.metadata or lookup by customer12 - 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_end1516 customer.subscription.updated:17 - Same as above: fetch subscription, map price to tier, upsert subscriptions table18 - Handle cancel_at_period_end = true: set cancel_at_period_end in your table1920 customer.subscription.deleted:21 - Find subscription by stripe_subscription_id22 - Update: status = 'canceled', tier = 'free'23244. Return 200 for all handled events, 400 for signature verification failures2526IMPORTANT: Return 200 even for unhandled event types — Stripe retries events that return non-2xx.2728Register 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.
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.
1// src/components/MembershipGate.tsx2import { 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'78const TIER_ORDER: Record<string, number> = {9 free: 0,10 pro: 1,11 enterprise: 2,12}1314type Props = {15 requiredTier: 'free' | 'pro' | 'enterprise'16 children: React.ReactNode17 preview?: React.ReactNode18}1920export function MembershipGate({ requiredTier, children, preview }: Props) {21 const { user } = useAuth()22 const { tier, loading } = useSubscription()23 const navigate = useNavigate()2425 if (loading) return <div className='h-48 animate-pulse bg-muted rounded-lg' />2627 const userTierLevel = TIER_ORDER[tier ?? 'free'] ?? 028 const requiredLevel = TIER_ORDER[requiredTier] ?? 02930 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 }5152 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.
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.
1Create a Supabase Edge Function at supabase/functions/create-portal-session/index.ts.23The function:41. Verifies the caller's JWT52. Looks up the user's stripe_customer_id from the subscriptions table63. 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 }1213In the frontend Account or Profile page, add a 'Manage Billing' Button:14- On click, call the Edge Function15- On response, redirect: window.location.href = response.url16- Show a loading spinner on the button while the call is in progress17- Show a toast on error: 'Could not open billing portal. Please try again.'1819Also add a subscription status Card to the Account page showing:20- Current tier Badge21- 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 truePro 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
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'67const TIER_ORDER: Record<string, number> = {8 free: 0,9 pro: 1,10 enterprise: 2,11}1213type Props = {14 requiredTier: 'free' | 'pro' | 'enterprise'15 children: React.ReactNode16 preview?: React.ReactNode17 className?: string18}1920export function MembershipGate({ requiredTier, children, preview, className }: Props) {21 const { user } = useAuth()22 const { tier, loading } = useSubscription()23 const navigate = useNavigate()2425 if (loading) {26 return <div className={`h-48 animate-pulse bg-muted rounded-lg ${className ?? ''}`} />27 }2829 const userLevel = TIER_ORDER[tier ?? 'free'] ?? 030 const reqLevel = TIER_ORDER[requiredTier] ?? 03132 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 required44 </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 Plans50 </Button>51 </div>52 </div>53 )54 }5556 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.
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?
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.
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.
Talk to an Expert
Our team has built 600+ apps. Get personalized help with your project.
Book a free consultation