# How to Build a Checkout Flow with Lovable

- Tool: How to Build with Lovable
- Difficulty: Advanced
- Compatibility: Lovable Pro or higher
- Last updated: April 2026

## 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.

## Before you start

- 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)

## Step-by-step guide

### 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.

```
Create a checkout system schema in Supabase:

Tables:
- 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
- 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)

RLS:
- orders: authenticated users can SELECT their own orders (user_id = auth.uid()). Service role full access.
- order_items: same as orders via join (use a policy that joins to orders table)

Also create a CartContext in src/contexts/CartContext.tsx:
- State: items array of { productId, quantity, name, price_cents, image_url }
- Actions: addItem, removeItem, updateQuantity, clearCart
- Persist cart to localStorage so it survives page refresh
- 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.

```
Build a multi-step checkout page at src/pages/Checkout.tsx.

Layout:
- Two-column layout on desktop (checkout steps on left 60%, order summary on right 40%)
- On mobile, summary collapses to an Accordion at the top

Stepper at the top:
- Three steps: 1. Cart Review, 2. Shipping, 3. Payment
- Use shadcn/ui Progress bar and step indicators showing current step number
- Previous step Button (disabled on step 1)

Step 1 - Cart Review:
- List all cart items from CartContext as rows: image, name, quantity Input (stepper), unit price, line total
- Below the list: subtotal, estimated shipping (show 'Calculated in next step'), total
- 'Continue to Shipping' Button, disabled if cart is empty

Step 2 - Shipping:
- react-hook-form + zod form with fields: firstName, lastName, email, address1, address2 (optional), city, state, postalCode, country (Select)
- Persist form values to state so navigating back preserves them
- 'Continue to Payment' Button triggers form validation before advancing

Step 3 - Payment:
- Show order summary: all items, shipping address, estimated total
- A 'Proceed to Stripe Checkout' Button that calls the create-checkout-session Edge Function
- Show a loading Spinner with 'Preparing secure checkout...' while waiting
- 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
import { serve } from 'https://deno.land/std@0.168.0/http/server.ts'
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'

const corsHeaders = {
  'Access-Control-Allow-Origin': '*',
  'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
  'Content-Type': 'application/json',
}

serve(async (req: Request) => {
  if (req.method === 'OPTIONS') return new Response('ok', { headers: corsHeaders })

  try {
    const { cartItems, shippingAddress, customerEmail } = await req.json()
    const stripeKey = Deno.env.get('STRIPE_SECRET_KEY') ?? ''
    const appUrl = Deno.env.get('APP_URL') ?? ''

    const supabase = createClient(Deno.env.get('SUPABASE_URL') ?? '', Deno.env.get('SUPABASE_SERVICE_ROLE_KEY') ?? '')

    const productIds = cartItems.map((item: { productId: string }) => item.productId)
    const { data: products, error } = await supabase
      .from('products')
      .select('id, name, price_cents, currency, stripe_price_id')
      .in('id', productIds)
      .eq('is_active', true)

    if (error || !products?.length) {
      return new Response(JSON.stringify({ error: 'Products not found' }), { status: 404, headers: corsHeaders })
    }

    const lineItems = cartItems.map((item: { productId: string; quantity: number }) => {
      const product = products.find((p) => p.id === item.productId)
      if (!product) throw new Error(`Product ${item.productId} not found`)
      return {
        price: product.stripe_price_id,
        quantity: item.quantity,
      }
    })

    const totalCents = products.reduce((sum: number, p) => {
      const cartItem = cartItems.find((i: { productId: string }) => i.productId === p.id)
      return sum + p.price_cents * (cartItem?.quantity ?? 1)
    }, 0)

    const { data: order } = await supabase.from('orders').insert({
      status: 'pending',
      subtotal_cents: totalCents,
      total_cents: totalCents,
      currency: 'usd',
      shipping_address: shippingAddress,
      customer_email: customerEmail,
    }).select().single()

    const params = new URLSearchParams()
    params.append('mode', 'payment')
    params.append('customer_email', customerEmail)
    params.append('success_url', `${appUrl}/order-success?session_id={CHECKOUT_SESSION_ID}`)
    params.append('cancel_url', `${appUrl}/checkout`)
    params.append('metadata[orderId]', order.id)
    lineItems.forEach((item: { price: string; quantity: number }, i: number) => {
      params.append(`line_items[${i}][price]`, item.price)
      params.append(`line_items[${i}][quantity]`, String(item.quantity))
    })

    const sessionRes = await fetch('https://api.stripe.com/v1/checkout/sessions', {
      method: 'POST',
      headers: {
        Authorization: `Basic ${btoa(stripeKey + ':')}`,
        'Content-Type': 'application/x-www-form-urlencoded',
      },
      body: params,
    })
    const session = await sessionRes.json()

    await supabase.from('orders').update({ stripe_session_id: session.id }).eq('id', order.id)

    return new Response(JSON.stringify({ url: session.url }), { headers: corsHeaders })
  } catch (err) {
    const message = err instanceof Error ? err.message : 'Internal error'
    return new Response(JSON.stringify({ error: message }), { status: 500, headers: corsHeaders })
  }
})
```

> 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.

```
Create two things:

1. Supabase Edge Function at supabase/functions/checkout-webhook/index.ts:
- Verify Stripe webhook signature using constructEventAsync with SubtleCryptoProvider
- Handle checkout.session.completed event:
  a. Extract session.metadata.orderId
  b. Extract session.payment_intent (string)
  c. Update orders: status='paid', stripe_payment_intent_id=payment_intent
  d. Fetch line_items from Stripe: GET /v1/checkout/sessions/{id}/line_items
  e. For each line item, insert into order_items with product_id (from price metadata), quantity, and price
- Return 200 for all events including unhandled ones

2. Order success page at src/pages/OrderSuccess.tsx:
- Read session_id from URL query params
- Call a Supabase query to find the order by stripe_session_id
- Show a loading Skeleton while the webhook may still be processing (poll every 2 seconds up to 10 seconds until status='paid')
- Display: large green checkmark icon, 'Order Confirmed!', order ID, list of items, total paid, shipping address
- Add 'View All Orders' Button linking to /orders and 'Continue Shopping' Button
- 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 example

File: `supabase/functions/create-checkout-session/index.ts`

```typescript
import { serve } from 'https://deno.land/std@0.168.0/http/server.ts'
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'

const corsHeaders = {
  'Access-Control-Allow-Origin': '*',
  'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
  'Content-Type': 'application/json',
}

serve(async (req: Request) => {
  if (req.method === 'OPTIONS') return new Response('ok', { headers: corsHeaders })

  const { cartItems, shippingAddress, customerEmail } = await req.json()
  const stripeKey = Deno.env.get('STRIPE_SECRET_KEY') ?? ''
  const appUrl = Deno.env.get('APP_URL') ?? ''

  const supabase = createClient(
    Deno.env.get('SUPABASE_URL') ?? '',
    Deno.env.get('SUPABASE_SERVICE_ROLE_KEY') ?? ''
  )

  const { data: products } = await supabase
    .from('products')
    .select('id, price_cents, stripe_price_id')
    .in('id', cartItems.map((i: { productId: string }) => i.productId))
    .eq('is_active', true)

  if (!products?.length) {
    return new Response(JSON.stringify({ error: 'No valid products' }), { status: 400, headers: corsHeaders })
  }

  const totalCents = cartItems.reduce((sum: number, item: { productId: string; quantity: number }) => {
    const p = products.find((p) => p.id === item.productId)
    return sum + (p?.price_cents ?? 0) * item.quantity
  }, 0)

  const { data: order } = await supabase
    .from('orders')
    .insert({ status: 'pending', subtotal_cents: totalCents, total_cents: totalCents, shipping_address: shippingAddress, customer_email: customerEmail })
    .select().single()

  const params = new URLSearchParams()
  params.append('mode', 'payment')
  params.append('customer_email', customerEmail)
  params.append('success_url', `${appUrl}/order-success?session_id={CHECKOUT_SESSION_ID}`)
  params.append('cancel_url', `${appUrl}/checkout`)
  params.append('metadata[orderId]', order.id)

  products.forEach((p, i) => {
    const item = cartItems.find((c: { productId: string }) => c.productId === p.id)
    if (!item) return
    params.append(`line_items[${i}][price]`, p.stripe_price_id)
    params.append(`line_items[${i}][quantity]`, String(item.quantity))
  })

  const res = await fetch('https://api.stripe.com/v1/checkout/sessions', {
    method: 'POST',
    headers: { Authorization: `Basic ${btoa(stripeKey + ':')}`, 'Content-Type': 'application/x-www-form-urlencoded' },
    body: params,
  })
  const session = await res.json()

  await supabase.from('orders').update({ stripe_session_id: session.id }).eq('id', order.id)

  return new Response(JSON.stringify({ url: session.url }), { headers: corsHeaders })
})
```

## Common mistakes

- **Calculating the total on the frontend and passing it to the Checkout Session** — Client-side totals can be manipulated in the browser. A user can change the price in JavaScript before the cart is sent to the Edge Function, creating a Checkout Session with a fraudulent amount. Fix: 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.
- **Relying on the success_url redirect to confirm payment** — The success_url redirect fires client-side after Stripe Checkout completes. It is not a reliable confirmation because the user could close the browser before being redirected, or navigate directly to the URL. Fix: 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.
- **Not storing the stripe_session_id on the order before redirecting** — If you redirect to Stripe Checkout before saving the session ID to Supabase, and the webhook fires before your update query completes, the webhook handler cannot find the order to update. Fix: 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.
- **Not handling the cancel_url case when users abandon checkout** — When a user clicks Back on the Stripe Checkout page, they are redirected to cancel_url. If this page does not handle the partial order state, users see a confusing empty page. Fix: 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.

## 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.

---

Source: https://www.rapidevelopers.com/how-to-build-lovable/checkout-flow
© RapidDev — https://www.rapidevelopers.com/how-to-build-lovable/checkout-flow
