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.
- 1Click the + button next to Preview in the top toolbar to open the Cloud tab
- 2Click Database — you will see an empty Table Editor
- 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.
- 1Cloud tab → Users & Auth
- 2Under Sign-in methods, confirm Email is enabled (it is by default)
- 3Enable Magic Link if you want passwordless sign-in for members
- 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.
- 1Cloud tab → Storage
- 2Click Create bucket, name it 'content-assets', leave Public OFF
- 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).
- 1Cloud tab → Edge Functions
- 2No manual setup needed — the starter prompt will scaffold all three function files
- 3After the prompt runs, add your Stripe secrets before deploying (see Secrets section)
Secrets (Cloud tab → Secrets)
STRIPE_SECRET_KEYPurpose: 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_SECRETPurpose: 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_PREMIUMPurpose: 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_FOUNDERPurpose: 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_KEYPurpose: 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.
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.sql5 tables + tier-aware RLS policies + stripe_events idempotency table
src/layouts/PublicLayout.tsxPublic header + footer wrapper
src/layouts/MemberLayout.tsxAuthenticated header with tier badge + account dropdown
src/components/ContentCard.tsxCard with tier-lock badge and excerpt
src/components/Paywall.tsxUpgrade CTA shown when content.min_tier exceeds user tier
src/components/PricingTable.tsx3-column tier comparison with checkout buttons
src/components/TierBadge.tsxColor-coded pill: free (gray) / premium (violet) / founder (amber)
src/components/MarkdownRenderer.tsxreact-markdown with Tailwind prose classes
src/pages/Home.tsxLanding page with featured free content cards
src/pages/Library.tsxPaginated content list with tier filtering
src/pages/ContentDetail.tsxArticle reader with paywall gate
src/pages/Pricing.tsx3-tier pricing page with Stripe checkout CTAs
src/pages/Account.tsxCurrent plan + manage subscription link
src/pages/admin/Content.tsxAdmin CRUD for articles with min_tier dropdown
supabase/functions/create-checkout-session/index.tsStripe Checkout session creator (stubbed)
supabase/functions/create-portal-session/index.tsStripe Customer Portal session creator (stubbed)
supabase/functions/stripe-webhook/index.tsWebhook handler stub with correct import structure
Routes
Public landing page with value prop and free content preview
Paginated content list with tier-lock badges
Article reader with server-enforced paywall
3-column tier table with Stripe checkout buttons
Current subscription status and Manage Subscription link
Email+password registration
Email+password sign-in
Admin article CRUD with min_tier dropdown
DB Tables
profiles| Column | Type |
|---|---|
| id | uuid primary key |
| full_name | text |
| text not null | |
| tier | text not null default 'free' |
| created_at | timestamptz 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| Column | Type |
|---|---|
| id | uuid primary key |
| user_id | uuid not null references auth.users(id) |
| stripe_customer_id | text not null |
| stripe_subscription_id | text unique |
| plan | text |
| status | text |
| current_period_end | timestamptz |
| cancel_at_period_end | bool default false |
RLS: Own row SELECT only. No client INSERT or UPDATE — all writes via webhook.
content| Column | Type |
|---|---|
| id | uuid primary key |
| 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' |
| published_at | timestamptz |
| author_id | uuid 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| Column | Type |
|---|---|
| id | uuid primary key |
| content_id | uuid references content(id) |
| user_id | uuid references auth.users(id) |
| viewed_at | timestamptz default now() |
RLS: Authenticated INSERT (own user_id). Admin-only SELECT for analytics.
stripe_events| Column | Type |
|---|---|
| id | uuid primary key |
| stripe_event_id | text unique not null |
| received_at | timestamptz default now() |
RLS: No client access. Used by webhook handler for idempotency (prevents double-processing the same event).
Components
ContentCardLibrary card with tier-lock badge and 120-char excerpt
PaywallUpgrade CTA shown when user's tier is below content.min_tier
PricingTable3-column feature matrix with checkout CTA buttons
TierBadgeColor-coded tier pill used on cards and account page
MarkdownRendererreact-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.
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
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
Wire Stripe webhook with raw body + constructEventAsync + idempotency
Working Stripe subscription billing: checkout, customer portal, and webhook handler with raw-body verification and idempotency
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
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
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
Send welcome, renewal, and churn emails via Resend
Transactional emails for welcome, renewal, payment failure, and cancellation events via Resend
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
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
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
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
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 showingContent 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.
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 loadThe 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.
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.
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 redirectsLovable'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+ minutescancel_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.
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 createdStripe 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.
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| Item | Cost |
|---|---|
| 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.
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 consultation30-min call. No commitment.