Skip to main content
RapidDev - Software Development Agency

How to Build a Subscription System with Lovable

Build a full Stripe Subscriptions system in Lovable with tiered plans, upgrade and downgrade flows, a subscription events audit trail, and a self-service Customer Portal. Users pick plans from comparison Cards, Supabase tracks every lifecycle event, and Edge Functions handle all Stripe interactions securely.

What you'll build

  • Plan comparison page with shadcn/ui Cards, feature lists, and a highlighted recommended tier
  • Stripe Subscription creation via Edge Function with trial period support
  • Subscription events audit table recording every status change with before/after state
  • Upgrade and downgrade flow with immediate proration via Stripe API
  • Stripe Customer Portal session creation for self-service billing management
  • Current plan banner with next renewal date, usage indicators, and Cancel Plan button
  • Webhook handler processing all customer.subscription.* and invoice.* lifecycle events
Book a free consultation
4.9Clutch rating
600+Happy partners
17+Countries served
190+Team members
Advanced15 min read3–4 hoursLovable Pro or higherApril 2026RapidDev Engineering Team
TL;DR

Build a full Stripe Subscriptions system in Lovable with tiered plans, upgrade and downgrade flows, a subscription events audit trail, and a self-service Customer Portal. Users pick plans from comparison Cards, Supabase tracks every lifecycle event, and Edge Functions handle all Stripe interactions securely.

What you're building

Stripe Subscriptions are built around three objects: Products (what you sell), Prices (how much and how often), and Subscriptions (a customer's active recurring purchase). You create Products and Prices once in the Stripe Dashboard or via API, then reference their IDs in your billing_plans table in Supabase. When a user selects a plan, your Edge Function creates a Stripe Subscription using the price ID.

The subscription lifecycle generates events that Stripe sends to your webhook: trial starts, trial ends, payment succeeds, payment fails, subscription is canceled, subscription is paused. Your subscription_events audit table records each event with the old status, new status, and a timestamp — giving you a complete history of every subscription change.

Upgrade and downgrade flows use Stripe's subscription update API with proration_behavior: create_prorations. When a user upgrades mid-cycle, Stripe charges the prorated difference immediately. When they downgrade, Stripe applies a credit to the next invoice. Your webhook handler captures the customer.subscription.updated event and syncs the new plan to Supabase.

Final result

A complete subscription management system with plan selection, lifecycle tracking, self-service upgrades and downgrades, and a full audit trail.

Tech stack

LovableFrontend app builder
Stripe SubscriptionsRecurring billing and plan management
SupabaseDatabase and Edge Functions
shadcn/uiUI components including Cards and Tabs
React Hook Form + ZodPayment method collection form
RechartsSubscription growth chart

Prerequisites

  • Lovable Pro account for Edge Function generation
  • Stripe account with at least two Products and Prices created in the Stripe Dashboard
  • STRIPE_SECRET_KEY, VITE_STRIPE_PUBLISHABLE_KEY, and STRIPE_WEBHOOK_SECRET in Cloud tab → Secrets
  • Supabase project with service role key in Secrets
  • Stripe Customer Portal enabled in Stripe Dashboard → Settings → Billing → Customer portal
  • Deployed Lovable app URL for setting up webhook endpoints (Stripe does not work in preview)

Build steps

1

Set up the subscription schema and seed plans

Create the Supabase tables for plans, subscriptions, and the audit trail. Then ask Lovable to seed the billing_plans table with your Stripe Price IDs.

prompt.txt
1Create a subscription system schema in Supabase:
2
3Tables:
4- billing_plans: id (uuid pk), name (text), description (text), stripe_price_id (text unique), stripe_product_id (text), amount_cents (int), currency (text default 'usd'), interval (text: month|year), features (jsonb array of strings), is_recommended (bool default false), is_active (bool default true), sort_order (int), created_at
5
6- subscriptions: id (uuid pk), user_id (uuid references auth.users unique), stripe_subscription_id (text unique), stripe_customer_id (text), current_plan_id (uuid references billing_plans), status (text: active|trialing|past_due|canceled|unpaid|paused), trial_end (timestamptz), current_period_start (timestamptz), current_period_end (timestamptz), cancel_at_period_end (bool default false), canceled_at (timestamptz), created_at, updated_at
7
8- subscription_events: id (uuid pk), subscription_id (uuid references subscriptions), user_id (uuid references auth.users), event_type (text), from_plan_id (uuid references billing_plans nullable), to_plan_id (uuid references billing_plans nullable), from_status (text), to_status (text), stripe_event_id (text unique), metadata (jsonb default '{}'), occurred_at (timestamptz)
9
10RLS: users can SELECT their own subscription and subscription_events. Service role full access.
11
12Seed three billing plans with realistic feature sets: Starter ($9/mo), Pro ($29/mo, is_recommended=true), Business ($79/mo).

Pro tip: Add a unique constraint on subscriptions.user_id so each user can only have one active subscription. Add a partial unique index for non-canceled subscriptions: CREATE UNIQUE INDEX one_active_sub_per_user ON subscriptions(user_id) WHERE status != 'canceled'.

Expected result: All three tables are created. Three billing_plans rows are seeded. TypeScript types are generated. The subscription table has the unique constraint on user_id.

2

Build the plan comparison page

Ask Lovable to build the pricing page with plan Cards, feature lists, a recommended badge, and a monthly/annual toggle that updates prices.

prompt.txt
1Build a pricing page at src/pages/Pricing.tsx.
2
3Requirements:
4- Fetch all is_active billing_plans ordered by sort_order
5- Show a monthly/annual Toggle at the top (shadcn/ui Switch with label)
6- Display each plan as a shadcn/ui Card with:
7 - Plan name and description
8 - Price (format as $X/month or $X/year based on toggle)
9 - If is_recommended=true, add a 'Most Popular' Badge above the card with accent border
10 - Feature list: iterate the features array, each item with a CheckCircle icon
11 - A 'Get Started' Button (primary for recommended, outline for others)
12- If the user already has an active subscription:
13 - The current plan's button shows 'Current Plan' and is disabled
14 - Other plans show 'Upgrade' or 'Downgrade' based on price comparison
15 - Clicking Upgrade/Downgrade opens a Confirmation Dialog explaining proration
16- If no subscription, 'Get Started' opens a payment method collection Dialog
17- Below the plans, add a FAQ Accordion with 4 questions about billing, cancellation, and upgrades

Pro tip: For the annual toggle, show the monthly price with a strikethrough and the discounted annual price. Calculate the annual savings percentage and show it as a Badge: 'Save 20%'. This increases annual plan conversion.

Expected result: The pricing page renders three plan cards with feature lists. The recommended plan has a highlighted border. The monthly/annual toggle updates displayed prices. Users with an existing subscription see their current plan labeled.

3

Create the subscription lifecycle Edge Functions

Build Edge Functions for creating subscriptions, upgrading/downgrading plans, canceling, and creating Customer Portal sessions.

prompt.txt
1Create four Supabase Edge Functions:
2
31. supabase/functions/create-subscription/index.ts
4- Accept: { planId: string, paymentMethodId: string, trialDays?: number }
5- Create Stripe customer if not exists, attach payment method, set as default
6- Create Stripe subscription with price ID from billing_plans, optional trial_end
7- Insert into subscriptions table with all Stripe IDs and status
8- Return: { subscriptionId, status, clientSecret? } (clientSecret if requires payment action)
9
102. supabase/functions/update-subscription/index.ts
11- Accept: { newPlanId: string }
12- Get user's current subscription from Supabase
13- Call Stripe API: PATCH /v1/subscriptions/{id} with items[0][id]=existing_item_id, items[0][price]=new_price_id, proration_behavior='create_prorations'
14- Update subscriptions.current_plan_id in Supabase
15- Insert a subscription_events row with event_type='plan_changed', from_plan_id, to_plan_id
16- Return: { success: true }
17
183. supabase/functions/cancel-subscription/index.ts
19- Accept: { immediately?: boolean }
20- If immediately=false: PATCH /v1/subscriptions/{id} with cancel_at_period_end=true
21- If immediately=true: DELETE /v1/subscriptions/{id}
22- Update Supabase subscriptions row accordingly
23- Return: { canceledAt }
24
254. supabase/functions/create-portal-session/index.ts
26- No request body needed (reads user from JWT)
27- Call POST /v1/billing_portal/sessions with customer_id and return_url
28- Return: { url } frontend redirects to this URL

Pro tip: When calling the Stripe subscription update API, always pass the items array with the existing subscription item ID to replace the price. Omitting the item ID creates a new subscription item instead of replacing the old one, resulting in two active prices on one subscription.

Expected result: All four Edge Functions deploy. Creating a subscription in test mode works. The update function changes the plan and creates an audit event. The portal session function returns a Stripe Customer Portal URL.

4

Build the subscription webhook handler

Create the comprehensive webhook Edge Function that processes all subscription lifecycle events and maintains the audit trail in subscription_events.

supabase/functions/subscription-webhook/index.ts
1// supabase/functions/subscription-webhook/index.ts
2import { serve } from 'https://deno.land/std@0.168.0/http/server.ts'
3import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'
4import Stripe from 'https://esm.sh/stripe@14?target=deno'
5
6const stripe = new Stripe(Deno.env.get('STRIPE_SECRET_KEY') ?? '', {
7 apiVersion: '2023-10-16',
8 httpClient: Stripe.createFetchHttpClient(),
9})
10const cryptoProvider = Stripe.createSubtleCryptoProvider()
11
12serve(async (req: Request) => {
13 const body = await req.text()
14 const sig = req.headers.get('stripe-signature') ?? ''
15 let event: Stripe.Event
16 try {
17 event = await stripe.webhooks.constructEventAsync(body, sig, Deno.env.get('STRIPE_WEBHOOK_SECRET') ?? '', undefined, cryptoProvider)
18 } catch {
19 return new Response('Signature failed', { status: 400 })
20 }
21
22 const supabase = createClient(Deno.env.get('SUPABASE_URL') ?? '', Deno.env.get('SUPABASE_SERVICE_ROLE_KEY') ?? '')
23
24 const { error: dupErr } = await supabase.from('subscription_events').insert({ stripe_event_id: event.id, event_type: event.type, occurred_at: new Date().toISOString() })
25 if (dupErr?.code === '23505') return new Response(JSON.stringify({ received: true }))
26
27 if (event.type.startsWith('customer.subscription.')) {
28 const stripeSub = event.data.object as Stripe.Subscription
29 const { data: sub } = await supabase.from('subscriptions').select('id, user_id, status, current_plan_id').eq('stripe_subscription_id', stripeSub.id).single()
30
31 if (sub) {
32 const newStatus = stripeSub.status
33 await supabase.from('subscriptions').update({
34 status: newStatus,
35 cancel_at_period_end: stripeSub.cancel_at_period_end,
36 current_period_start: new Date(stripeSub.current_period_start * 1000).toISOString(),
37 current_period_end: new Date(stripeSub.current_period_end * 1000).toISOString(),
38 canceled_at: stripeSub.canceled_at ? new Date(stripeSub.canceled_at * 1000).toISOString() : null,
39 updated_at: new Date().toISOString(),
40 }).eq('id', sub.id)
41
42 await supabase.from('subscription_events').update({
43 subscription_id: sub.id,
44 user_id: sub.user_id,
45 from_status: sub.status,
46 to_status: newStatus,
47 metadata: { stripe_subscription_id: stripeSub.id },
48 }).eq('stripe_event_id', event.id)
49 }
50 }
51
52 return new Response(JSON.stringify({ received: true }), { status: 200 })
53})

Pro tip: Register the subscription-webhook endpoint in Stripe for these events: customer.subscription.created, customer.subscription.updated, customer.subscription.deleted, customer.subscription.trial_will_end. Add invoice.paid and invoice.payment_failed if you want invoice sync in the same handler.

Expected result: Stripe test events flow through the webhook and update the subscriptions table. The subscription_events table has a growing audit trail of each event with before and after status.

5

Build the subscription management dashboard

Ask Lovable to build the subscription status page where users see their current plan, next renewal date, manage billing via the Customer Portal, and view their event history.

prompt.txt
1Build a subscription management page at src/pages/Subscription.tsx.
2
3Requirements:
4- Fetch the current user's subscription joined with billing_plans (for plan name and features)
5- If no subscription, show a full-width Card: 'You are not subscribed' with a 'View Plans' Button linking to /pricing
6- If subscribed, show:
7 - A status Banner: 'Active' (green), 'Trial ends in X days' (blue), 'Payment past due' (red), 'Cancels on {date}' (yellow)
8 - Current plan Card with: plan name, features list, amount, billing interval
9 - Next renewal date formatted as 'Next billing date: March 1, 2026'
10 - Three Buttons: 'Change Plan' (links to /pricing), 'Manage Billing' (calls create-portal-session Edge Function, redirects), 'Cancel Subscription' (opens Confirmation Dialog)
11 - Cancel Dialog explains: 'Your plan stays active until {current_period_end}. You can reactivate before then.'
12- Below the status section, show an Accordion: 'Billing History' fetch last 10 subscription_events and display as a timeline list with event type, date, and from/to status badges
13- For trialing users, show a countdown Card: 'X days left in your trial' with a progress bar

Expected result: The subscription page shows the user's current plan and status. The Manage Billing button redirects to the Stripe Customer Portal. The event history accordion shows lifecycle events.

Complete code

supabase/functions/update-subscription/index.ts
1import { serve } from 'https://deno.land/std@0.168.0/http/server.ts'
2import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'
3
4const corsHeaders = {
5 'Access-Control-Allow-Origin': '*',
6 'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
7 'Content-Type': 'application/json',
8}
9
10serve(async (req: Request) => {
11 if (req.method === 'OPTIONS') return new Response('ok', { headers: corsHeaders })
12
13 try {
14 const authHeader = req.headers.get('Authorization') ?? ''
15 const supabase = createClient(Deno.env.get('SUPABASE_URL') ?? '', Deno.env.get('SUPABASE_SERVICE_ROLE_KEY') ?? '')
16 const { data: { user } } = await createClient(Deno.env.get('SUPABASE_URL') ?? '', Deno.env.get('SUPABASE_ANON_KEY') ?? '', {
17 global: { headers: { Authorization: authHeader } }
18 }).auth.getUser()
19
20 if (!user) return new Response(JSON.stringify({ error: 'Unauthorized' }), { status: 401, headers: corsHeaders })
21
22 const { newPlanId } = await req.json()
23
24 const { data: newPlan } = await supabase.from('billing_plans').select('stripe_price_id').eq('id', newPlanId).single()
25 if (!newPlan) return new Response(JSON.stringify({ error: 'Plan not found' }), { status: 404, headers: corsHeaders })
26
27 const { data: sub } = await supabase.from('subscriptions').select('stripe_subscription_id, current_plan_id').eq('user_id', user.id).single()
28 if (!sub) return new Response(JSON.stringify({ error: 'No active subscription' }), { status: 404, headers: corsHeaders })
29
30 const stripeKey = Deno.env.get('STRIPE_SECRET_KEY') ?? ''
31 const getSubRes = await fetch(`https://api.stripe.com/v1/subscriptions/${sub.stripe_subscription_id}`, {
32 headers: { Authorization: `Basic ${btoa(stripeKey + ':')}` },
33 })
34 const stripeSub = await getSubRes.json()
35 const itemId = stripeSub.items.data[0].id
36
37 const body = new URLSearchParams({
38 'items[0][id]': itemId,
39 'items[0][price]': newPlan.stripe_price_id,
40 proration_behavior: 'create_prorations',
41 })
42
43 const updateRes = await fetch(`https://api.stripe.com/v1/subscriptions/${sub.stripe_subscription_id}`, {
44 method: 'POST',
45 headers: {
46 Authorization: `Basic ${btoa(stripeKey + ':')}`,
47 'Content-Type': 'application/x-www-form-urlencoded',
48 },
49 body,
50 })
51 const updatedSub = await updateRes.json()
52
53 await supabase.from('subscriptions').update({ current_plan_id: newPlanId, status: updatedSub.status, updated_at: new Date().toISOString() }).eq('user_id', user.id)
54 await supabase.from('subscription_events').insert({ user_id: user.id, event_type: 'plan_changed', from_plan_id: sub.current_plan_id, to_plan_id: newPlanId, from_status: 'active', to_status: 'active', occurred_at: new Date().toISOString() })
55
56 return new Response(JSON.stringify({ success: true, status: updatedSub.status }), { headers: corsHeaders })
57 } catch (err) {
58 const message = err instanceof Error ? err.message : 'Internal error'
59 return new Response(JSON.stringify({ error: message }), { status: 500, headers: corsHeaders })
60 }
61})

Customization ideas

Subscription pause and resume

Add a Pause Subscription button that uses Stripe's subscription pause collection feature (pause_collection: { behavior: 'keep_as_draft' }). This stops billing without canceling the subscription. Add a Resume button that removes the pause. Display a paused status Badge and estimated resume date on the subscription management page.

Trial extension for at-risk users

When a trial is nearing expiration and the user has not added a payment method, trigger an Edge Function via cron to send an email offering a 7-day trial extension. Include a one-click extension link that calls an Edge Function to update the Stripe trial_end date. Track extension usage in a trial_extensions table to limit abuse.

Seat-based team subscriptions

Add a team_members table and a seat_count column to subscriptions. When a user invites a team member, increment the Stripe subscription quantity. When they remove a member, decrement. The billing amount scales automatically. Add a team management page showing current seats used versus allowed per plan.

Subscription analytics for founders

Build an admin analytics page with MRR, churn rate, trial conversion rate, and average subscription length metrics. Calculate churn as (canceled_this_month / active_last_month) * 100. Pull all data from your Supabase subscriptions and subscription_events tables — no Stripe API calls needed for the dashboard.

Reactivation flow for canceled subscriptions

When a user with a canceled subscription returns to the pricing page, show a personalized banner: 'Welcome back. Reactivate your plan to regain access.' When they click, skip the payment method step (use their saved Stripe payment method) and create a new subscription with their previous plan pre-selected.

Common pitfalls

Pitfall: Not passing the subscription item ID when updating a plan

How to avoid: Always fetch the current subscription from Stripe first to get the items.data[0].id, then pass it as items[0][id] in the update request along with the new price ID.

Pitfall: Canceling subscriptions from the frontend directly

How to avoid: Always route cancellation through a Supabase Edge Function. The frontend calls the Edge Function endpoint, which uses STRIPE_SECRET_KEY from Deno.env to call the Stripe API.

Pitfall: Not handling the trialing subscription status

How to avoid: Treat trialing as equivalent to active for feature access purposes. In your subscription status checks, use: const hasAccess = ['active', 'trialing'].includes(subscription.status).

Pitfall: Redirecting users away from the app after the Customer Portal

How to avoid: Set the return_url to your subscription management page: https://yourapp.com/subscription. This returns users directly to their subscription status after managing billing.

Best practices

  • Store all Stripe subscription data locally in Supabase. Sync via webhooks and treat your local database as a read cache. Never query Stripe directly from the frontend.
  • Build an idempotency guard in your webhook handler using the subscription_events table. Insert the stripe_event_id first and skip processing if it already exists.
  • Always cancel with cancel_at_period_end: true by default. Only cancel immediately if the user explicitly chooses immediate cancellation — most users expect access until period end.
  • Use Stripe's Customer Portal for payment method updates and plan changes whenever possible. Stripe handles the complex 3D Secure and SCA flows for you.
  • Record every subscription state change in the subscription_events audit table. This gives you a complete history for customer support, debugging, and revenue analytics.
  • Handle the customer.subscription.trial_will_end event to send reminder emails 3 days before trial expiration. This is one of the highest-impact actions for trial-to-paid conversion.
  • Add a UNIQUE constraint on subscriptions.user_id with a partial index (WHERE status != 'canceled') to prevent duplicate active subscriptions for the same user.

AI prompts to try

Copy these prompts to build this project faster.

ChatGPT Prompt

I'm building a Stripe Subscriptions integration in a Lovable app using Supabase Edge Functions. Explain how proration works when a customer upgrades mid-billing-cycle. Show me the exact Stripe API call (using fetch with Basic Auth) to update a subscription's price with create_prorations, and explain what the proration_behavior options create_prorations, none, and always_invoice do differently.

Lovable Prompt

Add a subscription upgrade confirmation Dialog to the pricing page. When a user clicks Upgrade on a higher-tier plan, show a Dialog with: current plan name and price, new plan name and price, estimated proration charge for the remaining days in the billing cycle (calculate as: (new_price - old_price) * remaining_days / 30), a warning that the charge happens immediately, and a 'Confirm Upgrade' Button that calls the update-subscription Edge Function. Show a loading Spinner on the button while waiting for the response.

Build Prompt

In Supabase, create a view subscription_summary that joins subscriptions with billing_plans and auth.users. Return: user email, plan name, status, current_period_end, cancel_at_period_end, and trial_end. Create a second view subscription_event_timeline that joins subscription_events with billing_plans twice (for from_plan and to_plan names) and orders by occurred_at DESC. Grant SELECT on both views to the authenticated role with RLS filtering by user_id.

Frequently asked questions

How do I give users access to premium features based on their subscription plan?

Fetch the user's subscription from Supabase on the frontend using a React hook. Check subscription.status is active or trialing and subscription.current_plan_id matches a paid plan. Use a context provider to share subscription state across the app. On the backend, in Edge Functions, query the subscriptions table with the service role to verify access before returning premium data.

What is proration and how does it work for mid-cycle plan changes?

Proration is a credit or charge calculated for the unused portion of a billing period when a plan changes. If a customer upgrades from $10/month to $30/month halfway through their cycle, Stripe charges $10 immediately (half the $20 difference) and starts billing $30/month next cycle. For downgrades, Stripe applies a credit to the next invoice. Setting proration_behavior to create_prorations handles this automatically.

Can users have multiple subscriptions simultaneously?

By default in this architecture, each user has one active subscription. Add a unique partial index on subscriptions.user_id where status not in canceled to enforce this. If your product requires multiple subscriptions per user (like separate add-ons), remove the unique constraint and adjust your feature access logic to check any of the user's active subscriptions.

How does the Stripe Customer Portal work and what can users do in it?

The Customer Portal is a Stripe-hosted page where users manage their own billing. You configure what they can do in Stripe Dashboard → Settings → Billing → Customer portal: update payment methods, view invoice history, cancel subscriptions, or change plans. Your app redirects to the portal URL, the user makes changes, and Stripe sends webhook events back to your app reflecting the changes.

How do I handle 3D Secure authentication for subscription payments?

When creating a subscription, expand latest_invoice.payment_intent in the Stripe API response. If the payment intent status is requires_action, the card requires 3D Secure authentication. Return the client_secret to the frontend and use stripe.confirmCardPayment(clientSecret) to trigger the 3D Secure flow. After confirmation, Stripe sends a payment_intent.succeeded webhook to complete the subscription activation.

Can I offer annual subscriptions at a discounted rate?

Yes. Create separate Stripe Prices for monthly and annual billing intervals (interval: year). Add both stripe_price_id values to your billing_plans table with an interval column. Show the monthly/annual toggle on the pricing page. When the user selects a plan, pass the appropriate price ID based on the toggle. Annual pricing typically saves 15-20% compared to monthly to incentivize the commitment.

Is there help available for building a more complex subscription system?

RapidDev builds production subscription systems in Lovable, including usage-based billing, team seat management, and complex trial flows. Contact us if your subscription architecture requires more than this guide covers.

How do I reactivate a subscription after cancellation?

If the subscription is set to cancel at period end (cancel_at_period_end: true) but has not expired yet, you can reactivate it by updating the subscription to set cancel_at_period_end: false. This is a PATCH request to the Stripe subscriptions API. If the subscription has already fully canceled, you must create a new subscription — the old one cannot be reactivated.

RapidDev

Talk to an Expert

Our team has built 600+ apps. Get personalized help with your project.

Book a free consultation

Need help building your app?

Our experts have built 600+ apps and can accelerate your development. Book a free consultation — no strings attached.

Book a free consultation

We put the rapid in RapidDev

Need a dedicated strategic tech and growth partner? Discover what RapidDev can do for your business! Book a call with our team to schedule a free, no-obligation consultation. We'll discuss your project and provide a custom quote at no cost.