Skip to main content
RapidDev - Software Development Agency
Lovable PromptsB2C SaaSIntermediate

Build a Membership Site in Lovable

A tier-gated content membership site (free, premium, founder) with recurring Stripe billing, server-enforced content access via RLS, a self-serve customer portal, and webhook-driven subscription state — your own owned alternative to Memberstack or Circle.

Time to MVP

1–2 days

Credits

~150–250 credits for full chain

Difficulty

Intermediate

Cloud features

4

TL;DR

The one-paragraph version before you dive in.

Paste the starter prompt below into Lovable Build mode and you get a tier-gated membership site scaffold — free, premium, and founder tiers, a Stripe-powered pricing page, content library with RLS-enforced access, and a Stripe customer portal. Follow five prompts in sequence. Expected build: 1–2 days, ~150–250 credits on a Pro $25/mo plan.

Setup checklist

Complete these steps in Lovable before pasting the starter prompt — takes about 5 minutes.

Cloud tab settings

Database

Stores profiles (tier mirror), subscriptions, content (with min_tier column), content_views, and a stripe_events idempotency table.

  1. 1Click the + button next to Preview in the top toolbar to open the Cloud tab
  2. 2Click Database — you will see an empty Table Editor
  3. 3Leave it empty for now — the starter prompt will create all tables and migrations

Auth

Email+password and optional magic-link sign-in for members. Tier stored in JWT app_metadata so RLS reads it without hitting the DB on every request.

  1. 1Cloud tab → Users & Auth
  2. 2Under Sign-in methods, confirm Email is enabled (it is by default)
  3. 3Enable Magic Link if you want passwordless sign-in for members
  4. 4Set 'Allow new users to sign up' to ON — members self-register on the pricing page

Storage

Stores hero images for content articles and optional video uploads. One private bucket with tier-scoped read policies.

  1. 1Cloud tab → Storage
  2. 2Click Create bucket, name it 'content-assets', leave Public OFF
  3. 3The starter prompt will add a Storage policy scoped to authenticated users

Edge Functions

Hosts the three Stripe Edge Functions: create-checkout-session (subscriptions), create-portal-session (self-serve cancel/upgrade), and stripe-webhook (raw-body event handler with constructEventAsync).

  1. 1Cloud tab → Edge Functions
  2. 2No manual setup needed — the starter prompt will scaffold all three function files
  3. 3After the prompt runs, add your Stripe secrets before deploying (see Secrets section)

Secrets (Cloud tab → Secrets)

STRIPE_SECRET_KEY

Purpose: Allows Edge Functions to create Stripe Checkout and Customer Portal sessions

Where to get it: https://dashboard.stripe.com/apikeys — use the test-mode secret key (sk_test_...) during build; switch to live (sk_live_...) before launch

STRIPE_WEBHOOK_SECRET

Purpose: Verifies incoming Stripe webhook events so the handler can trust subscription state changes

Where to get it: https://dashboard.stripe.com/webhooks — create a webhook pointing to your deployed Edge Function URL + /stripe-webhook, then copy the Signing secret (whsec_...)

STRIPE_PRICE_ID_PREMIUM

Purpose: Passed to Stripe Checkout to charge the correct recurring plan

Where to get it: https://dashboard.stripe.com/products — create a recurring product named 'Premium', monthly price, then copy the Price ID (price_...)

STRIPE_PRICE_ID_FOUNDER

Purpose: Passed to Stripe Checkout for the founder/annual plan

Where to get it: https://dashboard.stripe.com/products — create a one-time or annual recurring 'Founder' product, copy the Price ID

RESEND_API_KEY

Purpose: Sends welcome, renewal, payment-failed, and cancellation emails to members

Where to get it: https://resend.com/api-keys — create an API key with full sending permissions

Preflight checklist

  • You are in a fresh Lovable project (Vite + React + TypeScript + Tailwind + shadcn/ui already scaffolded — that is the default)
  • You are on the Pro $25/mo plan or willing to upgrade — full chain runs ~150–250 credits and the Free plan caps at ~30/mo
  • You have a Stripe account (free to create at stripe.com). Start in test mode — real billing is not needed until launch
  • You have at least two pieces of sample content ready (a free article and a premium article) to verify tier gating works after the build

The starter prompt

Copy this. Paste it into Lovable Build mode (the default chat at the bottom-left of the editor). Hit send.

lovable-agent-mode.txt
~70–100 credits
Build a membership site with tier-gated content using React + Vite + TypeScript + Tailwind CSS + shadcn/ui on the frontend and Supabase (Lovable Cloud) on the backend.

Create one SQL migration file at supabase/migrations/0001_membership_schema.sql with the following:

1. Table: profiles
   - id uuid PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE
   - full_name text
   - email text NOT NULL
   - tier text NOT NULL DEFAULT 'free' CHECK (tier IN ('free','premium','founder'))
   - created_at timestamptz DEFAULT now()
   - RLS: Enable RLS. SELECT policy: own row only (auth.uid() = id). UPDATE policy: own row only. NEVER allow client to write the tier column — tier is managed by the webhook.

2. Table: subscriptions
   - id uuid PRIMARY KEY DEFAULT gen_random_uuid()
   - user_id uuid NOT NULL REFERENCES auth.users(id)
   - stripe_customer_id text NOT NULL
   - stripe_subscription_id text UNIQUE
   - plan text CHECK (plan IN ('premium','founder'))
   - status text CHECK (status IN ('trialing','active','past_due','canceled','incomplete'))
   - current_period_end timestamptz
   - cancel_at_period_end bool DEFAULT false
   - created_at timestamptz DEFAULT now()
   - RLS: Enable RLS. SELECT policy: own row (user_id = auth.uid()). No client INSERT/UPDATE — webhook-only.

3. Table: content
   - id uuid PRIMARY KEY DEFAULT gen_random_uuid()
   - title text NOT NULL
   - slug text UNIQUE NOT NULL
   - body_md text NOT NULL
   - hero_image_path text
   - min_tier text NOT NULL DEFAULT 'free' CHECK (min_tier IN ('free','premium','founder'))
   - published_at timestamptz
   - author_id uuid REFERENCES auth.users(id)
   - created_at timestamptz DEFAULT now()
   - RLS: Enable RLS.
     PUBLIC SELECT: WHERE min_tier = 'free' AND published_at <= now()
     AUTHENTICATED SELECT: WHERE (
       min_tier = 'free'
       OR (min_tier = 'premium' AND (auth.jwt() -> 'app_metadata' ->> 'tier') IN ('premium','founder'))
       OR (min_tier = 'founder' AND (auth.jwt() -> 'app_metadata' ->> 'tier') = 'founder')
     ) AND published_at <= now()
     Admin (role='admin' in app_metadata): all operations.

4. Table: content_views
   - id uuid PRIMARY KEY DEFAULT gen_random_uuid()
   - content_id uuid REFERENCES content(id)
   - user_id uuid REFERENCES auth.users(id)
   - viewed_at timestamptz DEFAULT now()
   - RLS: Enable RLS. INSERT: authenticated users, user_id = auth.uid(). SELECT: admin-only.

5. Table: stripe_events
   - id uuid PRIMARY KEY DEFAULT gen_random_uuid()
   - stripe_event_id text UNIQUE NOT NULL
   - received_at timestamptz DEFAULT now()
   - RLS: No client access.

Create a SECURITY DEFINER plpgsql function update_member_tier(p_user_id uuid, p_tier text) that updates both profiles.tier AND calls supabase.auth.admin.updateUserById (handle this in the Edge Function instead — just update profiles.tier from this function).

Build the following layout and pages:

Layouts:
- PublicLayout: header (logo left, nav: Library | Pricing | Sign in right), footer
- MemberLayout: same header but with Account dropdown (current tier badge, My Account, Sign out)
- AdminLayout: header + sidebar (Content, Members)

Pages:
- / (home): hero section with value prop, 3 featured free content cards, "Join as Premium" CTA
- /library: paginated content list (12 per page) with tier-lock badges on premium/founder cards. Free articles open directly. Clicking a locked card shows the Paywall component.
- /content/:slug: full article reader using MarkdownRenderer (react-markdown). If content.min_tier > user's tier, show Paywall instead of body. Record a content_views row on load.
- /pricing: 3-column pricing table (Free | Premium $X/mo | Founder $Y/yr) with feature matrix. "Start free" (signup), "Go Premium" (Stripe checkout), "Become a Founder" (Stripe checkout). Show current-plan badge if signed in.
- /account: current tier badge, subscription status and next renewal date, Manage Subscription button (calls create-portal-session Edge Function and redirects)
- /signup and /login: standard email+password forms
- /admin/content: table of all content rows, create/edit drawer with min_tier dropdown

Components:
- ContentCard: hero image, title, excerpt (first 120 chars of body_md), tier-lock badge (TierBadge) for premium/founder, published date
- TierBadge: color-coded pill — gray for free, violet-500 for premium, amber-500 for founder
- Paywall: shown when content.min_tier > user.tier. Shows tier name, feature list, and upgrade CTA button that links to /pricing
- PricingTable: 3-column with feature matrix rows (unlimited free articles, premium weekly articles, founder Discord access, etc.)
- MarkdownRenderer: react-markdown with Tailwind prose classes for reading typography

Scaffolding for Edge Functions (bodies to be implemented in follow-up #2):
- supabase/functions/create-checkout-session/index.ts: accepts {plan: 'premium'|'founder'} from the authenticated user
- supabase/functions/create-portal-session/index.ts: creates a Billing Portal session for the authenticated user
- supabase/functions/stripe-webhook/index.ts: stub with correct import structure

Styling: dark mode default (add ThemeProvider). Primary amber-500 for founder, violet-500 for premium. Reading typography uses max-w-prose, font-serif for article body. Generous whitespace on content detail pages. Tier badges have subtle ring shadows.

Deliverable: A working app where (1) anon users can browse the library and read free articles; (2) signed-in free users see Paywall on premium content; (3) the /pricing page renders the 3-tier table with checkout buttons; (4) /admin/content lets an admin create and publish articles with min_tier. Stripe checkout and webhook are stubs — do not wire up live Stripe yet.

What this prompt generates

  • Creates a SQL migration with 5 tables (profiles, subscriptions, content, content_views, stripe_events) and tier-aware RLS policies on content
  • Generates PublicLayout, MemberLayout, and AdminLayout with header and navigation
  • Builds /library and /content/:slug with the Paywall component for locked content
  • Generates /pricing with a 3-column PricingTable component and checkout CTA buttons
  • Scaffolds three Edge Function files (create-checkout-session, create-portal-session, stripe-webhook) for the follow-up Stripe wiring
  • Produces /admin/content CRUD with min_tier dropdown for publishing content

Paste into: Lovable Build mode (the default chat at the bottom-left of the editor)

Expected output

What Lovable will generate after the starter prompt runs successfully.

Files
supabase/migrations/0001_membership_schema.sql

5 tables + tier-aware RLS policies + stripe_events idempotency table

src/layouts/PublicLayout.tsx

Public header + footer wrapper

src/layouts/MemberLayout.tsx

Authenticated header with tier badge + account dropdown

src/components/ContentCard.tsx

Card with tier-lock badge and excerpt

src/components/Paywall.tsx

Upgrade CTA shown when content.min_tier exceeds user tier

src/components/PricingTable.tsx

3-column tier comparison with checkout buttons

src/components/TierBadge.tsx

Color-coded pill: free (gray) / premium (violet) / founder (amber)

src/components/MarkdownRenderer.tsx

react-markdown with Tailwind prose classes

src/pages/Home.tsx

Landing page with featured free content cards

src/pages/Library.tsx

Paginated content list with tier filtering

src/pages/ContentDetail.tsx

Article reader with paywall gate

src/pages/Pricing.tsx

3-tier pricing page with Stripe checkout CTAs

src/pages/Account.tsx

Current plan + manage subscription link

src/pages/admin/Content.tsx

Admin CRUD for articles with min_tier dropdown

supabase/functions/create-checkout-session/index.ts

Stripe Checkout session creator (stubbed)

supabase/functions/create-portal-session/index.ts

Stripe Customer Portal session creator (stubbed)

supabase/functions/stripe-webhook/index.ts

Webhook handler stub with correct import structure

Routes
/

Public landing page with value prop and free content preview

/library

Paginated content list with tier-lock badges

/content/:slug

Article reader with server-enforced paywall

/pricing

3-column tier table with Stripe checkout buttons

/account

Current subscription status and Manage Subscription link

/signup

Email+password registration

/login

Email+password sign-in

/admin/content

Admin article CRUD with min_tier dropdown

DB Tables
profiles
ColumnType
iduuid primary key
full_nametext
emailtext not null
tiertext not null default 'free'
created_attimestamptz default now()

RLS: Own row read/write only. Client must never write the tier column — it is managed exclusively by the stripe-webhook Edge Function.

subscriptions
ColumnType
iduuid primary key
user_iduuid not null references auth.users(id)
stripe_customer_idtext not null
stripe_subscription_idtext unique
plantext
statustext
current_period_endtimestamptz
cancel_at_period_endbool default false

RLS: Own row SELECT only. No client INSERT or UPDATE — all writes via webhook.

content
ColumnType
iduuid primary key
titletext not null
slugtext unique not null
body_mdtext not null
hero_image_pathtext
min_tiertext not null default 'free'
published_attimestamptz
author_iduuid references auth.users(id)

RLS: Public SELECT for free content. Authenticated SELECT gated by JWT app_metadata.tier matching min_tier hierarchy. Admin full access. This is the security spine — tier gating lives here, not in React.

content_views
ColumnType
iduuid primary key
content_iduuid references content(id)
user_iduuid references auth.users(id)
viewed_attimestamptz default now()

RLS: Authenticated INSERT (own user_id). Admin-only SELECT for analytics.

stripe_events
ColumnType
iduuid primary key
stripe_event_idtext unique not null
received_attimestamptz default now()

RLS: No client access. Used by webhook handler for idempotency (prevents double-processing the same event).

Components
ContentCard

Library card with tier-lock badge and 120-char excerpt

Paywall

Upgrade CTA shown when user's tier is below content.min_tier

PricingTable

3-column feature matrix with checkout CTA buttons

TierBadge

Color-coded tier pill used on cards and account page

MarkdownRenderer

react-markdown with Tailwind prose classes for article reading

Follow-up prompts

Paste these into Agent Mode one by one, in order, after the starter prompt finishes.

1

Audit content RLS so premium content cannot leak via DevTools

Server-enforced tier gating so premium content cannot be read via DevTools, curl, or the Supabase REST API by free users

~20–30 credits
prompt
Audit and harden the RLS policy on the content table.

First, open the SQL Editor and run: SELECT policyname, cmd, qual FROM pg_policies WHERE tablename = 'content';

Then do the following:
1. Drop any existing SELECT policy on content.
2. Create two separate SELECT policies:
   - Policy 'public_free_content' for the anon role: USING (min_tier = 'free' AND published_at <= now())
   - Policy 'tier_gated_content' for the authenticated role: USING (
       published_at <= now() AND (
         min_tier = 'free'
         OR (min_tier = 'premium' AND (auth.jwt() -> 'app_metadata' ->> 'tier') IN ('premium','founder'))
         OR (min_tier = 'founder' AND (auth.jwt() -> 'app_metadata' ->> 'tier') = 'founder')
       )
     )
3. After applying, test as anon: use the Supabase REST URL with the anon key and curl or Insomnia to SELECT from content. Confirm that zero premium rows are returned — the body_md field must NOT appear in the response for min_tier='premium' content when unauthenticated.
4. Add a comment in the migration: -- Content RLS: security enforced at DB layer. Paywall component is UX only.

Deliverable: A free user signed into the app sees the Paywall component on premium content, AND a curl request with the anon key returns zero premium rows from /rest/v1/content.

When to use: Immediately after the starter prompt finishes, before publishing any premium content

2

Wire Stripe webhook with raw body + constructEventAsync + idempotency

Working Stripe subscription billing: checkout, customer portal, and webhook handler with raw-body verification and idempotency

~70–100 credits
prompt
Implement the stripe-webhook Edge Function at supabase/functions/stripe-webhook/index.ts.

The function must:
1. Read the raw request body BEFORE any parsing: const body = await req.text();
2. Read the Stripe signature: const sig = req.headers.get('stripe-signature');
3. Verify the event: const event = await stripe.webhooks.constructEventAsync(body, sig, Deno.env.get('STRIPE_WEBHOOK_SECRET'));
   DO NOT use constructEvent (synchronous) — Deno's WebCrypto is async and will throw: 'SubtleCryptoProvider cannot be used in a synchronous context'
   DO NOT call req.json() before verification — JSON-parsing mutates the body and breaks signature verification.
4. Check idempotency: SELECT id FROM stripe_events WHERE stripe_event_id = event.id. If found, return new Response('Already processed', {status: 200}) immediately.
5. Insert into stripe_events: INSERT INTO stripe_events(stripe_event_id) VALUES(event.id).
6. Handle these event types:
   - checkout.session.completed: get userId from metadata.userId, plan from metadata.plan, write a subscriptions row with status='active', call supabaseAdmin.auth.admin.updateUserById(userId, {app_metadata: {tier: plan}}), update profiles.tier = plan WHERE id = userId.
   - customer.subscription.updated: if cancel_at_period_end=true, update subscriptions.cancel_at_period_end=true but do NOT change tier. If status='past_due', update subscriptions.status='past_due'.
   - customer.subscription.deleted: downgrade tier to 'free' — call supabaseAdmin.auth.admin.updateUserById(userId, {app_metadata: {tier: 'free'}}), update profiles.tier='free', subscriptions.status='canceled'.
7. Return new Response('OK', {status: 200}) after processing.

Also implement create-checkout-session: accept {plan} from the authenticated user, get or create the Stripe customer, call stripe.checkout.sessions.create with mode='subscription', the correct price ID from Deno.env.get('STRIPE_PRICE_ID_PREMIUM') or 'STRIPE_PRICE_ID_FOUNDER', success_url='/checkout/success', cancel_url='/pricing', and metadata: {userId: user.id, plan}.

Also implement create-portal-session: get stripe_customer_id from subscriptions WHERE user_id=auth.uid(), call stripe.billingPortal.sessions.create({customer, return_url: '/account'}), return {url}.

Deliverable: All three Edge Functions deployed. Test with Stripe test card 4242 4242 4242 4242 on the deployed lovable.app URL — not in preview.

When to use: Immediately after follow-up #1 (RLS audit) — this is the most important follow-up

3

Add Stripe customer portal and /checkout/success page

Self-serve cancel/upgrade via Stripe Customer Portal and a /checkout/success redirect with forced JWT refresh

~25–35 credits
prompt
Add two things:

1. /checkout/success page: When Stripe redirects here after a successful payment, call await supabase.auth.refreshSession() to force the JWT to refresh and pick up the new app_metadata.tier. Show a 'Welcome to [Plan]!' confirmation card with a 'Start reading premium content' button that links to /library. If the session refresh fails (network timeout), show: 'Your upgrade is active. If you still see the free tier after 60 seconds, sign out and sign back in to refresh your access.'

2. Update /account to wire up the Manage Subscription button: call the create-portal-session Edge Function and redirect to the returned URL. Show the current subscription status (tier badge, renewal date formatted as 'Renews June 25, 2027', or 'Cancels June 25, 2027' if cancel_at_period_end is true). Show a 'Downgrade to Free' note explaining it takes effect at period end.

Deliverable: A paid member can click Manage Subscription, land on the Stripe Customer Portal, cancel or change plan, and return to /account with accurate status shown.

When to use: After the webhook is verified working with a test payment

4

Send welcome, renewal, and churn emails via Resend

Transactional emails for welcome, renewal, payment failure, and cancellation events via Resend

~50–70 credits
prompt
Add transactional email notifications via Resend.

In the stripe-webhook Edge Function, after each subscription event, call a helper async function sendMembershipEmail(userId, type, metadata) that:
- Fetches the user's email from profiles WHERE id = userId
- Depending on type, sends via the Resend API (POST https://api.resend.com/emails with Authorization: Bearer RESEND_API_KEY):
  - 'welcome': Subject 'Welcome to [Site Name] Premium', body explaining what they can access, link to /library
  - 'renewed': Subject 'Your [Plan] membership has renewed', renewal date, link to /account
  - 'payment_failed': Subject 'Action required: update your payment method', link to Stripe portal, note that access continues for 7 days
  - 'canceled': Subject 'Your membership has ended', what they lose access to, link to /pricing to resubscribe
- Return early (don't throw) if RESEND_API_KEY is not set — email is optional, billing still works without it.

Event mapping:
- checkout.session.completed → 'welcome'
- customer.subscription.updated (status=active, not first-time) → 'renewed'
- invoice.payment_failed → 'payment_failed'
- customer.subscription.deleted → 'canceled'

Deliverable: Test each email type by triggering the corresponding test webhook from Stripe Dashboard → Webhooks → Send test event.

When to use: After the webhook and portal are both verified working

5

Add a founder tier with annual payment option

Founder tier with annual or one-time pricing, distinct checkout flow, and correct tier hierarchy in RLS and UI

~40–60 credits
prompt
Add a founder tier to the pricing page and checkout flow.

1. Update /pricing PricingTable: add a third column for 'Founder' with annual pricing (e.g., $199/yr). Highlight it with an amber-500 border and a 'Best value' badge. Feature list should include everything in Premium plus a Discord community access badge and early access to new content.

2. The 'Become a Founder' button calls create-checkout-session with plan='founder', using STRIPE_PRICE_ID_FOUNDER (a one-time payment or annual recurring from your Stripe Dashboard).

3. Update the stripe-webhook handler for checkout.session.completed: if metadata.plan='founder', set app_metadata.tier='founder' and profiles.tier='founder'. For a one-time payment (mode='payment'), also write a subscriptions row with status='active', plan='founder', current_period_end=null (lifetime).

4. Update all Paywall components and TierBadge to show the correct upgrade path: free users upgrading to premium see one CTA, premium users who could upgrade to founder see a different CTA ('Upgrade to Founder — save 40%').

5. Update the content RLS policy to confirm founder-only content (min_tier='founder') is accessible only to users with tier='founder' in app_metadata.

Deliverable: A complete 3-tier pricing page where free → premium → founder each have distinct pricing, feature lists, and upgrade paths.

When to use: After premium subscriptions are live and you are ready to add a lifetime or annual option

6

Add content drip schedule with weekly release

Automated content drip using published_at gating in RLS, an admin date picker, and a 'next article' countdown in the library

~35–45 credits
prompt
Add a scheduled content drip system so premium articles publish automatically on a set schedule.

1. The content table already has published_at timestamptz. Set this to a future date for scheduled articles — the RLS policy already gates on published_at <= now().

2. In /admin/content, update the create/edit drawer: add a date-time picker for published_at. If published_at is in the future, show a 'Scheduled: [date]' badge on the row in the admin table. If null, treat as draft (never published).

3. In the /library page component, add a 'Next premium article' indicator: query the content table for the next scheduled published_at > now() WHERE min_tier != 'free', format as 'Next article: Tuesday, July 1 at 9am'. Show this above the library list for authenticated users.

4. Add a Postgres function next_scheduled_content() RETURNS TABLE(title text, published_at timestamptz, min_tier text) that returns the next 3 upcoming scheduled articles for the admin dashboard.

Deliverable: Admins can schedule articles in the future. The library page shows the next release date. RLS automatically opens access when published_at passes — no cron required.

When to use: When you have a content backlog you want to release on a schedule rather than all at once

Common errors

Real error strings you'll see. Find yours, paste the fix prompt.

Free user finds premium article body in Network tab despite Paywall component showing

Content was gated only in React with a conditional render — the full body_md was already fetched from Supabase and exists in the JS bundle. The RLS policy never actually restricted the SELECT.

Fix — paste into Lovable Agent Mode
Move the gate to RLS. Drop existing SELECT policies on the content table. Create: CREATE POLICY public_free ON content FOR SELECT TO anon USING (min_tier = 'free' AND published_at <= now()); CREATE POLICY tier_gated ON content FOR SELECT TO authenticated USING (published_at <= now() AND (min_tier = 'free' OR (min_tier = 'premium' AND (auth.jwt() -> 'app_metadata' ->> 'tier') IN ('premium','founder')) OR (min_tier = 'founder' AND (auth.jwt() -> 'app_metadata' ->> 'tier') = 'founder'))). After this change, a free user's Supabase query returns zero premium rows — the React Paywall becomes a UX hint, not the security barrier.
User pays for premium but tier still shows 'free' on next page load

The webhook fired and wrote to the subscriptions table, but did not call supabaseAdmin.auth.admin.updateUserById to update app_metadata.tier — the JWT still carries the old tier claim.

Fix — paste into Lovable Agent Mode
In the stripe-webhook Edge Function on checkout.session.completed, after writing the subscriptions row, add: await supabaseAdmin.auth.admin.updateUserById(userId, {app_metadata: {tier: 'premium'}}); Also update profiles.tier = 'premium'. On the /checkout/success page, call await supabase.auth.refreshSession() to force the client JWT to refresh immediately. If users still see the old tier, add a visible message: 'Your upgrade is active — if the content is still locked, sign out and back in to refresh your session.'
SubtleCryptoProvider cannot be used in a synchronous context. Use await constructEventAsync(...) instead of constructEvent(...)

Lovable scaffolded the Stripe webhook with Node's synchronous stripe.webhooks.constructEvent API. Deno's WebCrypto is async-only, so the synchronous variant throws on the first webhook call.

Fix — paste into Lovable Agent Mode
In stripe-webhook Edge Function, replace constructEvent with await constructEventAsync. Make the handler async. Read the body BEFORE verification: const body = await req.text(); const event = await stripe.webhooks.constructEventAsync(body, sig, Deno.env.get('STRIPE_WEBHOOK_SECRET')); Do NOT call req.json() at any point — JSON-parsing the body corrupts the bytes and breaks signature verification.
Stripe integration doesn't work in preview / Checkout never redirects

Lovable's Cloud connector for Stripe is deploy-only — it does not function inside the in-editor preview iframe.

Manual fix

This is expected behavior. Click Publish in the top-right toolbar, deploy to the temporary lovable.app URL, then test Stripe with test card 4242 4242 4242 4242. Your webhook endpoint URL in Stripe Dashboard must point to the deployed URL (e.g., https://your-project.lovable.app/functions/v1/stripe-webhook), not a localhost address.

User cancels subscription in portal but still sees premium content for 10+ minutes

cancel_at_period_end=true fires customer.subscription.updated — not customer.subscription.deleted. Many webhook handlers immediately downgrade on the first event, but Stripe means: the subscription stays active until the period ends.

Fix — paste into Lovable Agent Mode
In webhook handler for customer.subscription.updated: if event.data.object.cancel_at_period_end = true, do NOT change the user's tier. Update subscriptions.cancel_at_period_end = true so the /account page can show 'Cancels on [date]'. Only downgrade tier to 'free' when customer.subscription.deleted fires (at actual period end). Update profiles.tier and app_metadata.tier to 'free' only on that event.
Same webhook event processed twice, duplicate subscriptions rows created

Stripe retries webhook delivery if your handler does not return 200 within ~10 seconds, and occasionally redelivers events — without an idempotency check your handler processes the same event twice.

Fix — paste into Lovable Agent Mode
Add idempotency at the top of the stripe-webhook handler, after signature verification: const exists = await supabaseAdmin.from('stripe_events').select('id').eq('stripe_event_id', event.id).single(); if (exists.data) return new Response('Already processed', {status: 200}); Then INSERT into stripe_events before processing. Wrap the insert + process in a try/catch so a unique constraint violation also returns 200.

Cost reality

What this build actually costs — no surprises on your card.

Recommended Lovable plan

Pro $25/mo recommended — with expectation of 1–2 top-ups ($15 per 50 credits) for webhook and RLS iteration cycles. Free plan caps at ~30 credits/month which is exhausted during the starter prompt.

Monthly run cost breakdown

~150–250 credits (starter ~70–100, five follow-ups ~80–150; most readers ship a working MVP with follow-ups #1 and #2 for ~160 total) total
ItemCost
Lovable Pro

Only needed while iterating — drop to Free after launch since the app runs on Lovable Cloud

$25/mo
Supabase / Lovable Cloud

Free tier: 500MB DB holds tens of thousands of profiles + content rows; 1GB Storage covers hero images for 500+ articles

$0/mo at MVP scale
Stripe

No monthly fee. At $20/mo premium × 200 subscribers = $4,000 MRR, Stripe takes ~$144 — vs. Memberstack Basic ($25 + 4% = $185 in fees)

2.9% + 30¢ per charge
Resend

Free up to 3,000 emails/month; $20/mo for 50K

$0–20/mo
Custom domain

Optional; Cloudflare or Porkbun

~$10–15/yr

Scaling notes: Supabase Free 500MB DB is comfortable up to ~2K subscribers + 500 articles + view tracking. Bump to Supabase Pro $25/mo at 5K+ subscribers or before launching video content (Storage egress jumps fast). Stripe fees are the dominant ongoing cost — at 500 subscribers paying $20/mo, you pay ~$360/mo in Stripe fees, but you save vs. Memberstack's 4% transaction fee on top of the $25 plan fee.

Production checklist

Steps to take before you share the URL with real users.

Domain & SSL

  • Connect a custom domain

    Lovable Dashboard → your project → Settings → Custom Domain. Add a CNAME record at your DNS provider pointing to your Lovable project's generated URL. SSL is provisioned automatically.

  • Update Stripe redirect URLs

    After connecting your domain, update success_url and cancel_url in your create-checkout-session Edge Function to use the new domain. Also update the return_url in create-portal-session.

Stripe Live Mode

  • Switch from test to live Stripe keys

    Cloud tab → Secrets. Replace STRIPE_SECRET_KEY with your live key (sk_live_...). Replace STRIPE_PRICE_ID_PREMIUM and STRIPE_PRICE_ID_FOUNDER with price IDs from your live-mode products. Create a new webhook in Stripe Dashboard pointing to your production Edge Function URL and replace STRIPE_WEBHOOK_SECRET.

  • Test live mode with a real card

    Use your own card to make a $1 test purchase on the live pricing page. Verify the webhook fires, app_metadata.tier updates, and the content unlocks. Then refund via Stripe Dashboard.

Email

  • Verify your sending domain in Resend

    Resend Dashboard → Domains → Add Domain. Add the TXT and MX DNS records at your domain provider. Verification usually takes 10–30 minutes. Without verification, emails land in spam.

  • Update email from address

    In the sendMembershipEmail helper, change the from field from the Resend sandbox (onboarding@resend.dev) to your verified domain address (e.g., hello@yourdomain.com)

Backups & Monitoring

  • Confirm daily database backups are enabled

    Cloud tab → Database → Backups. Lovable Cloud enables daily backups by default. Verify at least one backup exists before going live.

  • Set a budget alert in Stripe

    Stripe Dashboard → Settings → Billing → Budget alerts. Set an alert at your expected monthly charge volume so unexpected usage spikes notify you before they become large invoices.

Frequently asked questions

Will premium content actually be secure, or can savvy users get it via DevTools?

Yes, it is secure — if you follow follow-up #1. The starter prompt creates an RLS policy on the content table that restricts which rows Supabase returns based on the user's tier from their JWT. A free user's Supabase query literally returns zero premium rows — the body_md field never reaches the browser. The React Paywall component is just a UX signal on top of a real database-level gate. If you skip follow-up #1 and only gate in React code, that is not secure and any user with DevTools can read the full article. The test: curl the Supabase REST endpoint /rest/v1/content with the anon key — if you see body_md for min_tier='premium' rows, your RLS is missing.

Why is tier stored in app_metadata instead of profiles.tier?

JWT app_metadata is server-controlled — only the Supabase service-role key (used by your Edge Function webhook) can write to it. User metadata and the profiles table are client-accessible, which means a determined user could potentially modify their own tier value with a direct Supabase client call. By storing the authoritative tier in app_metadata and reading it in RLS via auth.jwt() -> 'app_metadata' ->> 'tier', you guarantee that no client-side code can escalate their own tier. The profiles.tier column is a mirror — kept in sync by the webhook — so you can run Postgres queries against it, but RLS reads from the JWT, not from profiles.

How do I migrate existing Memberstack or Substack subscribers?

For Memberstack, export your subscriber list as CSV (Members → Export). Create Stripe customers for each subscriber via the Stripe API or Dashboard bulk import. Set their tiers manually via the Supabase Dashboard: Cloud tab → Users & Auth → find the user → edit app_metadata to add their tier. For Substack, export your paid subscriber list from Settings → Exports. The migration steps are: (1) get users to re-register on your new site, (2) import them into Stripe as existing customers with their subscription history, (3) set their app_metadata.tier via the Supabase admin API. There is no automated one-click migration tool — plan 2–4 hours for up to 500 subscribers.

Can I offer a free trial that auto-converts to paid?

Yes, through Stripe's built-in trial support. In the create-checkout-session Edge Function, add subscription_data: {trial_period_days: 14} to the session parameters. Stripe will not charge the card until the trial ends. Your webhook already handles customer.subscription.trial_will_end (fires 3 days before end) — wire that event to the Resend 'trial_ending' email in follow-up #4. The user's tier is set to 'premium' on checkout.session.completed (when they enter their card), so they get full access immediately. If they cancel before trial ends, customer.subscription.deleted fires and the webhook downgrades them to 'free'.

What happens if Stripe's webhook fails — do users lose access?

If the webhook fails (network error, Edge Function crash, or Stripe is down), Stripe retries delivery for up to 72 hours with exponential backoff. During that window, the user's tier in your DB may lag behind their Stripe subscription state. The stripe_events idempotency table prevents duplicate processing if a retry comes in after the first delivery eventually succeeds. For a safety net, add the sync-subscription-from-stripe Edge Function (a utility that calls stripe.subscriptions.retrieve and force-updates the DB) and wire a call to it from the /account page on load, as a fallback for the rare case where webhook and DB get out of sync.

How do I handle prorated upgrades from premium to founder?

Call stripe.subscriptions.update(subscriptionId, {items: [{id: existingItemId, price: founderPriceId}], proration_behavior: 'create_prorations'}) in a new upgrade-plan Edge Function. Stripe immediately issues a prorated invoice for the price difference. Your webhook handles customer.subscription.updated with the new plan and updates app_metadata.tier to 'founder'. Add an 'Upgrade to Founder' button on the /account page that calls this Edge Function and refreshes the session afterward.

When does it make sense to build this vs stay on Memberstack?

Stay on Memberstack Basic ($25/mo + 4% transaction fee) if you have fewer than 100 subscribers and want to validate the audience first — at 50 subscribers at $20/mo each, you pay about $65 in combined fees, which is cheaper than the Lovable credits and time to build. Build in Lovable when you have 200+ subscribers, the 4% Memberstack transaction fee starts to visibly drain MRR, or you need a custom content model (mixed video + article + audio + community) that Memberstack cannot represent. At 500 subscribers at $20/mo = $10,000 MRR, Memberstack Basic takes $425/mo in fees vs. Stripe's $320/mo through your own app — a $105/mo saving that pays back the build cost in under a year.

Can RapidDev build this membership site for me end-to-end?

If your build outgrows this prompt kit and you need custom architecture, RapidDev builds production-grade Lovable apps at $13K-$25K — book a free 30-minute consultation at rapidevelopers.com.

Need a production-grade version?

RapidDev builds production-grade Lovable apps at $13K–$25K. Book a free 30-minute consultation.

Book a free consultation

30-min call. No commitment.

Want this built for you?

We ship production apps at a fixed price — $13K–$25K, 6–10 weeks, source code yours. You've seen what it takes; we do it every week.

Get a fixed-price quote

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.