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
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
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.
1Create a checkout system schema in Supabase:23Tables: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_at5- 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)67RLS: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)1011Also 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, clearCart14- Persist cart to localStorage so it survives page refresh15- Export a useCart hookPro 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.
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.
1Build a multi-step checkout page at src/pages/Checkout.tsx.23Layout: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 top67Stepper at the top:8- Three steps: 1. Cart Review, 2. Shipping, 3. Payment9- Use shadcn/ui Progress bar and step indicators showing current step number10- Previous step Button (disabled on step 1)1112Step 1 - Cart Review:13- List all cart items from CartContext as rows: image, name, quantity Input (stepper), unit price, line total14- Below the list: subtotal, estimated shipping (show 'Calculated in next step'), total15- 'Continue to Shipping' Button, disabled if cart is empty1617Step 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 them20- 'Continue to Payment' Button triggers form validation before advancing2122Step 3 - Payment:23- Show order summary: all items, shipping address, estimated total24- A 'Proceed to Stripe Checkout' Button that calls the create-checkout-session Edge Function25- Show a loading Spinner with 'Preparing secure checkout...' while waiting26- On success, window.location.href = session.url to redirect to Stripe CheckoutPro 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.
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.
1// supabase/functions/create-checkout-session/index.ts2import { serve } from 'https://deno.land/std@0.168.0/http/server.ts'3import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'45const corsHeaders = {6 'Access-Control-Allow-Origin': '*',7 'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',8 'Content-Type': 'application/json',9}1011serve(async (req: Request) => {12 if (req.method === 'OPTIONS') return new Response('ok', { headers: corsHeaders })1314 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') ?? ''1819 const supabase = createClient(Deno.env.get('SUPABASE_URL') ?? '', Deno.env.get('SUPABASE_SERVICE_ROLE_KEY') ?? '')2021 const productIds = cartItems.map((item: { productId: string }) => item.productId)22 const { data: products, error } = await supabase23 .from('products')24 .select('id, name, price_cents, currency, stripe_price_id')25 .in('id', productIds)26 .eq('is_active', true)2728 if (error || !products?.length) {29 return new Response(JSON.stringify({ error: 'Products not found' }), { status: 404, headers: corsHeaders })30 }3132 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 })4041 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)4546 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()5455 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 })6566 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()7576 await supabase.from('orders').update({ stripe_session_id: session.id }).eq('id', order.id)7778 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.
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.
1Create two things:231. Supabase Edge Function at supabase/functions/checkout-webhook/index.ts:4- Verify Stripe webhook signature using constructEventAsync with SubtleCryptoProvider5- Handle checkout.session.completed event:6 a. Extract session.metadata.orderId7 b. Extract session.payment_intent (string)8 c. Update orders: status='paid', stripe_payment_intent_id=payment_intent9 d. Fetch line_items from Stripe: GET /v1/checkout/sessions/{id}/line_items10 e. For each line item, insert into order_items with product_id (from price metadata), quantity, and price11- Return 200 for all events including unhandled ones12132. Order success page at src/pages/OrderSuccess.tsx:14- Read session_id from URL query params15- Call a Supabase query to find the order by stripe_session_id16- 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 address18- Add 'View All Orders' Button linking to /orders and 'Continue Shopping' Button19- Call clearCart() from CartContext after showing the confirmationExpected 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
1import { serve } from 'https://deno.land/std@0.168.0/http/server.ts'2import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'34const corsHeaders = {5 'Access-Control-Allow-Origin': '*',6 'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',7 'Content-Type': 'application/json',8}910serve(async (req: Request) => {11 if (req.method === 'OPTIONS') return new Response('ok', { headers: corsHeaders })1213 const { cartItems, shippingAddress, customerEmail } = await req.json()14 const stripeKey = Deno.env.get('STRIPE_SECRET_KEY') ?? ''15 const appUrl = Deno.env.get('APP_URL') ?? ''1617 const supabase = createClient(18 Deno.env.get('SUPABASE_URL') ?? '',19 Deno.env.get('SUPABASE_SERVICE_ROLE_KEY') ?? ''20 )2122 const { data: products } = await supabase23 .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)2728 if (!products?.length) {29 return new Response(JSON.stringify({ error: 'No valid products' }), { status: 400, headers: corsHeaders })30 }3132 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.quantity35 }, 0)3637 const { data: order } = await supabase38 .from('orders')39 .insert({ status: 'pending', subtotal_cents: totalCents, total_cents: totalCents, shipping_address: shippingAddress, customer_email: customerEmail })40 .select().single()4142 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)4849 products.forEach((p, i) => {50 const item = cartItems.find((c: { productId: string }) => c.productId === p.id)51 if (!item) return52 params.append(`line_items[${i}][price]`, p.stripe_price_id)53 params.append(`line_items[${i}][quantity]`, String(item.quantity))54 })5556 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()6263 await supabase.from('orders').update({ stripe_session_id: session.id }).eq('id', order.id)6465 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.
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?
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.
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.
Talk to an Expert
Our team has built 600+ apps. Get personalized help with your project.
Book a free consultation