Skip to main content
RapidDev - Software Development Agency

How to Build a Checkout Flow with Lovable

Build a multi-step checkout in Lovable with a cart summary, shipping details form, and payment step powered by a Stripe Checkout Session created in a Supabase Edge Function. Server-side price calculation prevents client-side tampering, and a success page confirms the order after Stripe redirects back.

What you'll build

  • Multi-step checkout Stepper UI with Cart, Shipping, and Payment steps using shadcn/ui Progress
  • Cart summary sidebar with line items, quantity controls, and subtotal calculation
  • Shipping details form with react-hook-form and Zod validation for address fields
  • Server-side price calculation in an Edge Function to prevent cart total manipulation
  • Stripe Checkout Session creation via Edge Function that returns a hosted checkout URL
  • Order confirmation page reading Stripe session data after redirect with order summary
  • Orders and order_items tables in Supabase to persist every completed checkout
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 multi-step checkout in Lovable with a cart summary, shipping details form, and payment step powered by a Stripe Checkout Session created in a Supabase Edge Function. Server-side price calculation prevents client-side tampering, and a success page confirms the order after Stripe redirects back.

What you're building

A multi-step checkout flow guides users through three stages: reviewing their cart, entering shipping details, and completing payment. The critical security step is server-side price calculation. When the user reaches the payment step, your Edge Function fetches product prices from Supabase (not from the cart payload sent by the client) and constructs the Stripe Checkout Session with verified amounts. This prevents users from manipulating prices in the browser.

Stripe Checkout is a Stripe-hosted payment page that handles card entry, Apple Pay, Google Pay, and 3D Secure authentication automatically. Your Edge Function creates the session with line_items built from server-side prices and a success_url pointing to your order confirmation page with the session ID in the query string. Stripe redirects back after payment, and the confirmation page fetches the session to display order details.

The orders and order_items tables in Supabase record every completed checkout. A webhook handler listens for checkout.session.completed to mark orders as paid, because the success_url redirect is client-side and can be bypassed — only the webhook is authoritative for payment confirmation.

Final result

A secure multi-step checkout with server-side price validation, Stripe-hosted payment, and reliable webhook-based order confirmation.

Tech stack

LovableFrontend app builder
Stripe CheckoutHosted payment page
SupabaseDatabase and Edge Functions
React Hook Form + ZodShipping form validation
shadcn/uiUI components
Zustand or React ContextCart state management

Prerequisites

  • Lovable Pro account for Edge Function generation
  • Stripe account with products and prices created in the Stripe Dashboard
  • STRIPE_SECRET_KEY and STRIPE_WEBHOOK_SECRET in Cloud tab → Secrets, VITE_STRIPE_PUBLISHABLE_KEY also in Secrets
  • Supabase project with service role key in Secrets
  • Products seeded in your Supabase products table with matching Stripe Price IDs
  • Deployed Lovable app URL (Stripe Checkout redirects require a real domain — does not work in preview)

Build steps

1

Set up the checkout schema and cart state

Create the orders tables in Supabase and build the cart state management using React Context. Cart state lives in memory during the session — it is not persisted to Supabase until checkout completes.

prompt.txt
1Create a checkout system schema in Supabase:
2
3Tables:
4- orders: id (uuid pk), user_id (uuid references auth.users nullable), stripe_session_id (text unique), stripe_payment_intent_id (text), status (text: pending|paid|failed|refunded), subtotal_cents (int), shipping_cents (int default 0), total_cents (int), currency (text default 'usd'), shipping_address (jsonb), customer_email (text), created_at, updated_at
5- order_items: id (uuid pk), order_id (uuid references orders), product_id (uuid references products), quantity (int), unit_price_cents (int), total_price_cents (int)
6
7RLS:
8- orders: authenticated users can SELECT their own orders (user_id = auth.uid()). Service role full access.
9- order_items: same as orders via join (use a policy that joins to orders table)
10
11Also create a CartContext in src/contexts/CartContext.tsx:
12- State: items array of { productId, quantity, name, price_cents, image_url }
13- Actions: addItem, removeItem, updateQuantity, clearCart
14- Persist cart to localStorage so it survives page refresh
15- Export a useCart hook

Pro tip: Store the cart in localStorage using JSON.stringify so users do not lose their cart if they accidentally navigate away. On CartContext initialization, read from localStorage with a try/catch in case the stored data is malformed.

Expected result: The orders and order_items tables are created. The CartContext is available in the app. Adding items to the cart persists to localStorage.

2

Build the multi-step checkout component

Ask Lovable to create the checkout page with a Progress stepper, step content area, and a persistent order summary sidebar.

prompt.txt
1Build a multi-step checkout page at src/pages/Checkout.tsx.
2
3Layout:
4- Two-column layout on desktop (checkout steps on left 60%, order summary on right 40%)
5- On mobile, summary collapses to an Accordion at the top
6
7Stepper at the top:
8- Three steps: 1. Cart Review, 2. Shipping, 3. Payment
9- Use shadcn/ui Progress bar and step indicators showing current step number
10- Previous step Button (disabled on step 1)
11
12Step 1 - Cart Review:
13- List all cart items from CartContext as rows: image, name, quantity Input (stepper), unit price, line total
14- Below the list: subtotal, estimated shipping (show 'Calculated in next step'), total
15- 'Continue to Shipping' Button, disabled if cart is empty
16
17Step 2 - Shipping:
18- react-hook-form + zod form with fields: firstName, lastName, email, address1, address2 (optional), city, state, postalCode, country (Select)
19- Persist form values to state so navigating back preserves them
20- 'Continue to Payment' Button triggers form validation before advancing
21
22Step 3 - Payment:
23- Show order summary: all items, shipping address, estimated total
24- A 'Proceed to Stripe Checkout' Button that calls the create-checkout-session Edge Function
25- Show a loading Spinner with 'Preparing secure checkout...' while waiting
26- On success, window.location.href = session.url to redirect to Stripe Checkout

Pro tip: Save shipping form values to sessionStorage when the user advances to step 3 so they can navigate back to step 2 and see their pre-filled address. Clear sessionStorage when the order completes.

Expected result: The three-step checkout renders. Navigating between steps shows step-specific content. The order summary sidebar updates as cart quantities change. Form validation blocks advancing with empty required fields.

3

Create the Stripe Checkout Session Edge Function

Build the Edge Function that verifies cart contents server-side, creates a Stripe Checkout Session with verified prices, and returns the redirect URL.

supabase/functions/create-checkout-session/index.ts
1// supabase/functions/create-checkout-session/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'
4
5const corsHeaders = {
6 'Access-Control-Allow-Origin': '*',
7 'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
8 'Content-Type': 'application/json',
9}
10
11serve(async (req: Request) => {
12 if (req.method === 'OPTIONS') return new Response('ok', { headers: corsHeaders })
13
14 try {
15 const { cartItems, shippingAddress, customerEmail } = await req.json()
16 const stripeKey = Deno.env.get('STRIPE_SECRET_KEY') ?? ''
17 const appUrl = Deno.env.get('APP_URL') ?? ''
18
19 const supabase = createClient(Deno.env.get('SUPABASE_URL') ?? '', Deno.env.get('SUPABASE_SERVICE_ROLE_KEY') ?? '')
20
21 const productIds = cartItems.map((item: { productId: string }) => item.productId)
22 const { data: products, error } = await supabase
23 .from('products')
24 .select('id, name, price_cents, currency, stripe_price_id')
25 .in('id', productIds)
26 .eq('is_active', true)
27
28 if (error || !products?.length) {
29 return new Response(JSON.stringify({ error: 'Products not found' }), { status: 404, headers: corsHeaders })
30 }
31
32 const lineItems = cartItems.map((item: { productId: string; quantity: number }) => {
33 const product = products.find((p) => p.id === item.productId)
34 if (!product) throw new Error(`Product ${item.productId} not found`)
35 return {
36 price: product.stripe_price_id,
37 quantity: item.quantity,
38 }
39 })
40
41 const totalCents = products.reduce((sum: number, p) => {
42 const cartItem = cartItems.find((i: { productId: string }) => i.productId === p.id)
43 return sum + p.price_cents * (cartItem?.quantity ?? 1)
44 }, 0)
45
46 const { data: order } = await supabase.from('orders').insert({
47 status: 'pending',
48 subtotal_cents: totalCents,
49 total_cents: totalCents,
50 currency: 'usd',
51 shipping_address: shippingAddress,
52 customer_email: customerEmail,
53 }).select().single()
54
55 const params = new URLSearchParams()
56 params.append('mode', 'payment')
57 params.append('customer_email', customerEmail)
58 params.append('success_url', `${appUrl}/order-success?session_id={CHECKOUT_SESSION_ID}`)
59 params.append('cancel_url', `${appUrl}/checkout`)
60 params.append('metadata[orderId]', order.id)
61 lineItems.forEach((item: { price: string; quantity: number }, i: number) => {
62 params.append(`line_items[${i}][price]`, item.price)
63 params.append(`line_items[${i}][quantity]`, String(item.quantity))
64 })
65
66 const sessionRes = await fetch('https://api.stripe.com/v1/checkout/sessions', {
67 method: 'POST',
68 headers: {
69 Authorization: `Basic ${btoa(stripeKey + ':')}`,
70 'Content-Type': 'application/x-www-form-urlencoded',
71 },
72 body: params,
73 })
74 const session = await sessionRes.json()
75
76 await supabase.from('orders').update({ stripe_session_id: session.id }).eq('id', order.id)
77
78 return new Response(JSON.stringify({ url: session.url }), { headers: corsHeaders })
79 } catch (err) {
80 const message = err instanceof Error ? err.message : 'Internal error'
81 return new Response(JSON.stringify({ error: message }), { status: 500, headers: corsHeaders })
82 }
83})

Pro tip: Add APP_URL to Cloud tab → Secrets (your deployed Lovable URL without trailing slash). The {CHECKOUT_SESSION_ID} placeholder in success_url is replaced by Stripe automatically — do not replace it yourself.

Expected result: The Edge Function creates a Stripe Checkout Session and returns a URL. The frontend redirects to the Stripe-hosted checkout page. A pending order row appears in Supabase.

4

Build the webhook handler and order confirmation page

Create the checkout.session.completed webhook that marks orders as paid, and the success page that shows the completed order.

prompt.txt
1Create two things:
2
31. Supabase Edge Function at supabase/functions/checkout-webhook/index.ts:
4- Verify Stripe webhook signature using constructEventAsync with SubtleCryptoProvider
5- Handle checkout.session.completed event:
6 a. Extract session.metadata.orderId
7 b. Extract session.payment_intent (string)
8 c. Update orders: status='paid', stripe_payment_intent_id=payment_intent
9 d. Fetch line_items from Stripe: GET /v1/checkout/sessions/{id}/line_items
10 e. For each line item, insert into order_items with product_id (from price metadata), quantity, and price
11- Return 200 for all events including unhandled ones
12
132. Order success page at src/pages/OrderSuccess.tsx:
14- Read session_id from URL query params
15- Call a Supabase query to find the order by stripe_session_id
16- Show a loading Skeleton while the webhook may still be processing (poll every 2 seconds up to 10 seconds until status='paid')
17- Display: large green checkmark icon, 'Order Confirmed!', order ID, list of items, total paid, shipping address
18- Add 'View All Orders' Button linking to /orders and 'Continue Shopping' Button
19- Call clearCart() from CartContext after showing the confirmation

Expected result: A completed Stripe test checkout updates the order status to paid via webhook. The success page shows the order details and clears the cart.

Complete code

supabase/functions/create-checkout-session/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 const { cartItems, shippingAddress, customerEmail } = await req.json()
14 const stripeKey = Deno.env.get('STRIPE_SECRET_KEY') ?? ''
15 const appUrl = Deno.env.get('APP_URL') ?? ''
16
17 const supabase = createClient(
18 Deno.env.get('SUPABASE_URL') ?? '',
19 Deno.env.get('SUPABASE_SERVICE_ROLE_KEY') ?? ''
20 )
21
22 const { data: products } = await supabase
23 .from('products')
24 .select('id, price_cents, stripe_price_id')
25 .in('id', cartItems.map((i: { productId: string }) => i.productId))
26 .eq('is_active', true)
27
28 if (!products?.length) {
29 return new Response(JSON.stringify({ error: 'No valid products' }), { status: 400, headers: corsHeaders })
30 }
31
32 const totalCents = cartItems.reduce((sum: number, item: { productId: string; quantity: number }) => {
33 const p = products.find((p) => p.id === item.productId)
34 return sum + (p?.price_cents ?? 0) * item.quantity
35 }, 0)
36
37 const { data: order } = await supabase
38 .from('orders')
39 .insert({ status: 'pending', subtotal_cents: totalCents, total_cents: totalCents, shipping_address: shippingAddress, customer_email: customerEmail })
40 .select().single()
41
42 const params = new URLSearchParams()
43 params.append('mode', 'payment')
44 params.append('customer_email', customerEmail)
45 params.append('success_url', `${appUrl}/order-success?session_id={CHECKOUT_SESSION_ID}`)
46 params.append('cancel_url', `${appUrl}/checkout`)
47 params.append('metadata[orderId]', order.id)
48
49 products.forEach((p, i) => {
50 const item = cartItems.find((c: { productId: string }) => c.productId === p.id)
51 if (!item) return
52 params.append(`line_items[${i}][price]`, p.stripe_price_id)
53 params.append(`line_items[${i}][quantity]`, String(item.quantity))
54 })
55
56 const res = await fetch('https://api.stripe.com/v1/checkout/sessions', {
57 method: 'POST',
58 headers: { Authorization: `Basic ${btoa(stripeKey + ':')}`, 'Content-Type': 'application/x-www-form-urlencoded' },
59 body: params,
60 })
61 const session = await res.json()
62
63 await supabase.from('orders').update({ stripe_session_id: session.id }).eq('id', order.id)
64
65 return new Response(JSON.stringify({ url: session.url }), { headers: corsHeaders })
66})

Customization ideas

Shipping rate calculation

Add a shipping_rates table in Supabase with rates by country and weight bracket. In the create-checkout-session Edge Function, calculate the shipping rate based on the order's destination country and total weight. Pass it as a separate line item in the Stripe Checkout Session. Display the calculated shipping cost in the step 3 order summary before redirecting.

Discount codes and coupons

Add a discount_codes table in Supabase with code, type (percent or fixed), value, and usage limits. On step 1 of the checkout, add an Input for promo codes with a Validate button. Check the code server-side in an Edge Function, apply the discount to the Checkout Session using Stripe's discounts array, and show the savings in the order summary.

Guest checkout and account creation

Allow users to check out without an account by not requiring authentication for the checkout flow. On the success page, offer 'Create an account to track your orders' with a pre-filled email form. On account creation, link the order to the new user_id. This reduces friction while still capturing customer data.

Order tracking page

Build an orders page at /orders that lists all past orders with status Badges, totals, and a Details button per order. The detail page shows order_items, shipping address, and a timeline of status changes. For physical products, add a tracking_number column and display it with a link to the carrier's tracking page.

Abandoned cart recovery

Add an abandoned_carts table that records cart contents when a user starts checkout but does not complete it within 30 minutes. Use a Supabase cron Edge Function to query abandoned carts and trigger an email via Resend with the cart contents and a one-click return link. Include a small discount code as an incentive.

Common pitfalls

Pitfall: Calculating the total on the frontend and passing it to the Checkout Session

How to avoid: Always look up prices from your Supabase products table inside the Edge Function using the product IDs from the cart. Never accept price amounts from the client payload. The Edge Function is the authoritative source of all prices.

Pitfall: Relying on the success_url redirect to confirm payment

How to avoid: Use the checkout.session.completed webhook as the authoritative payment confirmation. The webhook fires server-side regardless of what happens in the browser. Mark orders as paid only in the webhook handler, not in the success page.

Pitfall: Not storing the stripe_session_id on the order before redirecting

How to avoid: Always create the order row in Supabase first, then create the Stripe Checkout Session with the order ID in the metadata, then update the order with the session ID. Use the metadata.orderId in the webhook handler to find and update the order.

Pitfall: Not handling the cancel_url case when users abandon checkout

How to avoid: Point cancel_url to /checkout so users return to the payment step with their cart and shipping details preserved in state. In the checkout page, detect the return from a canceled session and show a friendly message: 'Your payment was not completed. Your cart is saved.'

Best practices

  • Always verify prices server-side in the Edge Function before creating the Checkout Session. Product IDs from the cart are acceptable, but prices must come from your database.
  • Create the order row in Supabase before creating the Stripe Checkout Session. Store the order ID in the session metadata so your webhook can find it without needing to match on email or other fields.
  • Use the checkout.session.completed webhook as the sole authoritative signal for payment confirmation. The success_url page is for user experience only, not business logic.
  • Add APP_URL to your Secrets so the Edge Function constructs the success_url dynamically. Never hardcode your domain in the Edge Function — it breaks when you deploy to a different URL.
  • Clear the cart state and localStorage only after the order is confirmed (either after the success page loads with a confirmed order, or in the webhook handler via a Realtime subscription).
  • Handle Stripe Checkout Session expiration. Sessions expire after 24 hours. If a user returns with an expired session ID, show a friendly error and redirect them back to checkout.
  • Use Stripe's automatic_payment_methods: { enabled: true } on the Checkout Session to accept Apple Pay, Google Pay, and regional payment methods without additional configuration.

AI prompts to try

Copy these prompts to build this project faster.

ChatGPT Prompt

I'm building a Stripe Checkout integration in a Lovable app using Supabase Edge Functions. Explain how Stripe Checkout Sessions work: what is the difference between mode 'payment' and mode 'subscription'? How does the success_url template variable {CHECKOUT_SESSION_ID} work? And why should I never rely on the success_url redirect alone to confirm payment — what is the correct pattern for using webhooks alongside the success redirect?

Lovable Prompt

Add a cart drawer to the main layout that slides in from the right side using a shadcn/ui Sheet. The drawer shows all cart items from CartContext with product images, names, quantities (+ and - buttons), and line totals. Show the cart total at the bottom. Add a 'Checkout' Button that navigates to /checkout. Add a cart icon in the header with a Badge showing the number of items. The drawer should open when the cart icon is clicked or when an item is added.

Build Prompt

In Supabase, create a SQL function get_order_details(p_session_id text) that returns a single JSON object with: the order fields, an array of order_items joined with products (for product name and image), and the formatted shipping address. Grant execute to authenticated and service roles. Use this function from the OrderSuccess page instead of multiple separate queries to reduce round trips.

Frequently asked questions

What is the difference between Stripe Checkout and Stripe Elements?

Stripe Checkout redirects users to a Stripe-hosted payment page with a pre-built UI. It is faster to implement, handles 3D Secure and Apple Pay automatically, and requires no frontend payment form code. Stripe Elements embeds payment inputs directly into your page for a seamless, branded experience but requires more frontend code. This guide uses Checkout for simplicity. Use Elements if you need full control over the payment UI.

Why must I calculate prices in the Edge Function instead of the frontend?

Prices calculated on the frontend can be modified in the browser by any user with developer tools. If you pass prices directly to the Checkout Session, a user could change $99.99 to $0.01. By looking up prices from your Supabase products table inside the Edge Function using only the product IDs (not prices) from the cart, you guarantee that Stripe always charges your actual prices.

Can I add shipping cost as a separate line item in Stripe Checkout?

Yes. Create a shipping rate using the Stripe API (POST to /v1/shipping_rates) with your shipping price, then pass shipping_rate in the Checkout Session creation. Alternatively, add it as a regular line item with a descriptive name like 'Standard Shipping'. The shipping amount appears as a separate row in the Stripe Checkout UI.

How long does a Stripe Checkout Session remain valid?

By default, Stripe Checkout Sessions expire after 24 hours. After expiration, users who navigate back to the Stripe Checkout URL see an error page. You can customize the expiration duration with the expires_after_seconds parameter. Handle expired sessions gracefully by showing a message like 'This checkout session has expired' and a button to restart the checkout.

Can guest users without accounts check out?

Yes. Remove the authentication requirement from the checkout flow. Pass customer_email in the Checkout Session so Stripe can send a receipt. Create the order row without a user_id (make it nullable). After checkout, show an option on the success page to create an account — if the user signs up, update the order with their new user_id.

How do I test the checkout flow including webhooks?

Deploy your app using the Publish button in Lovable. Register the checkout-webhook Edge Function URL in Stripe Dashboard → Webhooks. Use test card 4242 4242 4242 4242 with any future expiry and any CVC to complete a test payment. Stripe will fire the checkout.session.completed webhook to your deployed Edge Function. Check the orders table in Supabase to confirm the status updated to paid.

Is there help available for building a more complex checkout with shipping and taxes?

RapidDev builds production e-commerce checkouts in Lovable including dynamic shipping rates, Stripe Tax integration, and multi-currency support. Reach out if your checkout requires more complexity than this guide covers.

How do I handle checkout for both one-time products and subscriptions in the same cart?

Stripe Checkout Sessions only support a single mode: payment (one-time) or subscription. You cannot mix them in one session. The common approach is to either separate products and subscriptions into different checkout flows, or use Stripe Payment Links for subscriptions alongside a Checkout Session for one-time products.

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.