# How to Build a Shopping Cart with Lovable

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

## TL;DR

Build a persistent shopping cart with Lovable and Supabase in 2 hours. You'll get a product catalog with stock Badges, a slide-out cart Sheet, guest and authenticated cart support with merge-on-login via Edge Function, and a complete checkout Form — no code written manually.

## Before you start

- Lovable Pro account (the cart merge Edge Function needs enough credits to generate)
- Supabase project created at supabase.com (free tier works)
- Supabase URL and anon key ready for Cloud tab → Secrets
- A product list with names, prices, and stock quantities to seed the database
- Optional: product images hosted somewhere (Supabase Storage or a public URL)

## Step-by-step guide

### 1. Set up the database schema for products and carts

Start with a comprehensive schema prompt covering products, carts, cart_items, and orders. Lovable will generate the SQL migrations and TypeScript types. After generation, add your Supabase credentials to Cloud tab → Secrets.

```
Create a shopping cart application with Supabase. Set up these tables:

- products: id, name, description, price (numeric), image_url, stock_quantity (int), category, slug, created_at
- carts: id, user_id (nullable, references auth.users), guest_id (text, nullable), created_at, updated_at
- cart_items: id, cart_id (references carts), product_id (references products), quantity (int), created_at
- orders: id, user_id (references auth.users), status (pending|paid|shipped|delivered|cancelled), shipping_name, shipping_email, shipping_address, shipping_city, shipping_state, shipping_zip, subtotal, total, created_at
- order_items: id, order_id, product_id, quantity, unit_price, created_at

RLS rules:
- products: SELECT public (everyone can read), INSERT/UPDATE/DELETE admin only
- carts: users can only access their own cart (user_id = auth.uid() OR guest_id = requesting guest_id)
- cart_items: access through the parent cart RLS
- orders: users can read/write their own orders only
```

> Pro tip: Ask Lovable to seed the products table with 6 sample products including different categories, prices, and some with low stock (e.g. stock_quantity = 2) so you can test the stock Badge and disabled button behavior.

**Expected result:** Lovable generates all five tables, TypeScript types, and the Supabase client. The preview shows a basic app shell with navigation.

### 2. Build the product catalog grid

Ask Lovable to render the products table as a responsive grid of Cards. Each card shows the product image, name, price, category Badge, and stock indicator. The Add to Cart button is disabled when stock_quantity is 0.

```
Build a product catalog page at src/pages/Shop.tsx.

Requirements:
- Fetch all products from Supabase ordered by name
- Render a responsive grid: 1 column mobile, 2 tablet, 3 desktop, 4 large desktop
- Each product is a shadcn/ui Card with:
  - Product image (aspect-square, object-cover)
  - Category Badge (variant='secondary')
  - Product name (font-semibold)
  - Price formatted as currency ($)
  - Stock Badge: 'In Stock' (green) if stock_quantity > 5, 'Low Stock' (orange) if 1-5, 'Out of Stock' (red) if 0
  - Quantity selector (Input type number, min 1, max stock_quantity)
  - 'Add to Cart' Button, disabled if stock_quantity = 0
- Add to Cart calls a useCart hook that handles both guest and authenticated cart logic
- Show a category filter row above the grid using shadcn/ui Badge buttons
```

> Pro tip: Ask Lovable to add a skeleton loading state for the product grid that shows 8 Card-shaped Skeleton components while the Supabase query resolves.

**Expected result:** A responsive product grid appears with Cards, stock Badges, and functional quantity selectors. Adding items to cart triggers the cart Sheet to slide open.

### 3. Build the cart Sheet with item management

Create a slide-out Sheet component that renders the current cart items, lets users change quantities or remove items, and shows the running subtotal and total.

```
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetFooter } from '@/components/ui/sheet'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Separator } from '@/components/ui/separator'
import { Trash2, Plus, Minus } from 'lucide-react'
import { useCart } from '@/hooks/useCart'

export function CartSheet() {
  const { isOpen, setIsOpen, items, updateQuantity, removeItem, subtotal, itemCount } = useCart()

  const formatted = (n: number) =>
    new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(n)

  return (
    <Sheet open={isOpen} onOpenChange={setIsOpen}>
      <SheetContent className="w-[400px] sm:w-[480px] flex flex-col">
        <SheetHeader>
          <SheetTitle className="flex items-center gap-2">
            Cart
            {itemCount > 0 && (
              <Badge variant="secondary">{itemCount} {itemCount === 1 ? 'item' : 'items'}</Badge>
            )}
          </SheetTitle>
        </SheetHeader>
        <div className="flex-1 overflow-y-auto py-4 space-y-4">
          {items.length === 0 ? (
            <p className="text-center text-muted-foreground py-12">Your cart is empty</p>
          ) : (
            items.map((item) => (
              <div key={item.id} className="flex gap-3">
                <img src={item.product.image_url} alt={item.product.name} className="w-16 h-16 rounded-md object-cover" />
                <div className="flex-1 min-w-0">
                  <p className="text-sm font-medium truncate">{item.product.name}</p>
                  <p className="text-sm text-muted-foreground">{formatted(item.product.price)}</p>
                  <div className="flex items-center gap-2 mt-2">
                    <Button variant="outline" size="icon" className="h-6 w-6" onClick={() => updateQuantity(item.id, item.quantity - 1)}>
                      <Minus className="h-3 w-3" />
                    </Button>
                    <span className="text-sm w-6 text-center">{item.quantity}</span>
                    <Button variant="outline" size="icon" className="h-6 w-6" onClick={() => updateQuantity(item.id, item.quantity + 1)}>
                      <Plus className="h-3 w-3" />
                    </Button>
                  </div>
                </div>
                <div className="text-right">
                  <p className="text-sm font-medium">{formatted(item.product.price * item.quantity)}</p>
                  <Button variant="ghost" size="icon" className="h-7 w-7 mt-1" onClick={() => removeItem(item.id)}>
                    <Trash2 className="h-3 w-3 text-muted-foreground" />
                  </Button>
                </div>
              </div>
            ))
          )}
        </div>
        {items.length > 0 && (
          <SheetFooter className="flex-col gap-2">
            <Separator />
            <div className="flex justify-between text-sm font-medium">
              <span>Subtotal</span>
              <span>{formatted(subtotal)}</span>
            </div>
            <Button className="w-full" onClick={() => { setIsOpen(false) }}>Proceed to Checkout</Button>
          </SheetFooter>
        )}
      </SheetContent>
    </Sheet>
  )
}
```

**Expected result:** The cart Sheet slides in from the right. Items show with quantity controls and a remove button. Subtotal updates immediately when quantities change.

### 4. Build the guest-to-auth cart merge Edge Function

When a guest user signs in, their localStorage cart needs to merge with any existing authenticated cart. A Supabase Edge Function handles this securely: it finds both carts, merges items by adding quantities for duplicate products, and clears the guest cart.

```
Create a Supabase Edge Function at supabase/functions/merge-cart/index.ts that merges a guest cart into the authenticated user's cart.

The function receives: { guest_id: string } in the request body.
The Authorization header contains the user's JWT (authenticated request).

Logic:
1. Extract user ID from the JWT using Supabase Auth
2. Find the guest cart where guest_id = input guest_id
3. Find or create the user's cart where user_id = auth user id
4. For each item in the guest cart:
   - If a cart_item with the same product_id exists in the user's cart, add the quantities
   - Otherwise, insert the cart_item into the user's cart
5. Delete all items from the guest cart
6. Delete the guest cart record
7. Return { success: true, merged_items: count }

Use the Supabase service role key (from Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')) so the function can access both carts regardless of RLS.
```

> Pro tip: In the frontend useCart hook, call this Edge Function from a useEffect that runs when the auth state changes from null to a user session. Pass the guest_id from localStorage.

**Expected result:** Signing in with items in the guest cart calls the Edge Function. After merge, the authenticated cart contains all guest items. Refreshing the page still shows the merged cart.

### 5. Build the checkout form and order creation

Ask Lovable to create the checkout page with a react-hook-form + zod form for shipping details, an order summary sidebar, and a submit handler that creates the order in Supabase and clears the cart.

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

Requirements:
- Two-column layout: checkout form (left) and order summary (right)
- Form fields using react-hook-form + zod:
  - Full name (required, min 2 chars)
  - Email (required, valid email)
  - Address line 1 (required)
  - City (required)
  - State (required, 2-letter code)
  - ZIP code (required, 5 digits)
- Order summary shows: each cart item with quantity and price, subtotal, estimated tax (8%), and total
- On submit:
  1. Insert into orders table with all shipping fields, subtotal, and total
  2. For each cart item, insert into order_items with product_id, quantity, unit_price
  3. Delete all cart_items for the current cart
  4. Decrease each product's stock_quantity by the ordered quantity
  5. Navigate to /order-confirmation/[orderId]
- Show a loading spinner on the Submit button while the Supabase calls complete
- If stock is insufficient at submission time, show a toast error and highlight the affected item
```

**Expected result:** The checkout page shows the form and order summary side by side. Submitting a valid form creates an order in Supabase, clears the cart, and navigates to the confirmation page.

## Complete code example

File: `src/hooks/useCart.ts`

```typescript
import { useState, useEffect, useCallback } from 'react'
import { supabase } from '@/integrations/supabase/client'
import { useAuth } from '@/hooks/useAuth'

type CartItem = {
  id: string
  quantity: number
  product: { id: string; name: string; price: number; image_url: string; stock_quantity: number }
}

const GUEST_ID_KEY = 'cart_guest_id'

function getGuestId(): string {
  let id = localStorage.getItem(GUEST_ID_KEY)
  if (!id) {
    id = crypto.randomUUID()
    localStorage.setItem(GUEST_ID_KEY, id)
  }
  return id
}

export function useCart() {
  const { user } = useAuth()
  const [items, setItems] = useState<CartItem[]>([])
  const [isOpen, setIsOpen] = useState(false)
  const [cartId, setCartId] = useState<string | null>(null)

  const fetchCart = useCallback(async () => {
    const query = user
      ? supabase.from('carts').select('id').eq('user_id', user.id).single()
      : supabase.from('carts').select('id').eq('guest_id', getGuestId()).single()

    const { data: cart } = await query
    if (!cart) return
    setCartId(cart.id)

    const { data } = await supabase
      .from('cart_items')
      .select('id, quantity, product:products(id, name, price, image_url, stock_quantity)')
      .eq('cart_id', cart.id)
    setItems((data as unknown as CartItem[]) ?? [])
  }, [user])

  useEffect(() => { fetchCart() }, [fetchCart])

  const addItem = async (productId: string, quantity: number) => {
    if (!cartId) return
    const existing = items.find((i) => i.product.id === productId)
    if (existing) {
      await supabase.from('cart_items').update({ quantity: existing.quantity + quantity }).eq('id', existing.id)
    } else {
      await supabase.from('cart_items').insert({ cart_id: cartId, product_id: productId, quantity })
    }
    await fetchCart()
    setIsOpen(true)
  }

  const removeItem = async (itemId: string) => {
    await supabase.from('cart_items').delete().eq('id', itemId)
    setItems((prev) => prev.filter((i) => i.id !== itemId))
  }

  const updateQuantity = async (itemId: string, quantity: number) => {
    if (quantity < 1) return removeItem(itemId)
    await supabase.from('cart_items').update({ quantity }).eq('id', itemId)
    setItems((prev) => prev.map((i) => i.id === itemId ? { ...i, quantity } : i))
  }

  const subtotal = items.reduce((sum, i) => sum + i.product.price * i.quantity, 0)
  const itemCount = items.reduce((sum, i) => sum + i.quantity, 0)

  return { items, isOpen, setIsOpen, cartId, addItem, removeItem, updateQuantity, subtotal, itemCount }
}
```

## Common mistakes

- **Letting the anon key bypass RLS on the carts table** — Without RLS, any authenticated user can read every other user's cart by querying the carts table directly. Fix: Add a SELECT policy on carts: user_id = auth.uid(). For guest carts, rely on the guest_id matching a value only the client knows. The Edge Function for merging should use the service role key stored in Secrets, never exposed to the client.
- **Not handling the case where a user signs in on a device with no guest cart** — If guest_id is null or returns no cart, the merge Edge Function throws an error and the auth flow breaks. Fix: Wrap the Edge Function call in a try-catch. If no guest cart exists, return early with { success: true, merged_items: 0 }. In the frontend, only call the merge function if localStorage contains a guest_id and the local cart has at least one item.
- **Not decrementing stock_quantity in a transaction** — If two users check out the last item simultaneously, both decrease stock independently and the quantity can go negative. Fix: Ask Lovable to use a Supabase RPC function for the stock decrement that runs UPDATE products SET stock_quantity = stock_quantity - qty WHERE id = product_id AND stock_quantity >= qty RETURNING id. If no row is returned, the item is out of stock and the checkout should be rejected.
- **Storing the full cart in a React context without syncing back to Supabase** — If the cart context is the source of truth and the user closes the tab before syncing, items are lost for authenticated users. Fix: Treat Supabase as the source of truth for authenticated users. Every addItem, removeItem, and updateQuantity call should update Supabase first, then refetch. Use TanStack Query with invalidateQueries to keep the UI in sync.

## Best practices

- Generate the guest_id with crypto.randomUUID() on first page load and persist it in localStorage. Never send it as a URL parameter — keep it client-side only.
- Use a single useCart hook that abstracts the guest vs authenticated logic. The rest of the app only calls addItem, removeItem, and updateQuantity without knowing the underlying storage mechanism.
- Always validate stock availability server-side in the checkout submission, even if you check it client-side on the product page. Stock can change between page load and checkout.
- Run the cart merge Edge Function with the service role key so it can access both the guest cart and the user cart regardless of RLS. Never expose the service role key to the browser.
- Add optimistic UI updates in the cart Sheet for quantity changes. Update the local state immediately and revert if the Supabase update fails, so the UX feels instant.
- Store unit_price in order_items at the time of purchase, not a reference to the current product price. Product prices can change, but historical order prices must be immutable.

## Frequently asked questions

### What happens to the guest cart if the user never logs in?

The guest cart persists in Supabase as long as the guest_id exists in localStorage. It will remain until the user clears their browser data or you add a scheduled Supabase Edge Function that deletes guest carts older than 30 days. Ask Lovable to set up that cleanup function.

### Can I add Stripe payments to this cart?

Yes. After the order is created in Supabase, call a Supabase Edge Function that creates a Stripe Checkout Session using the order items and total. The function returns a session URL and the frontend redirects the user to Stripe's hosted page. On success, Stripe calls a webhook Edge Function that updates the order status to paid. Store your Stripe keys in Cloud tab → Secrets.

### Does the cart work on mobile?

Yes. The Sheet component from shadcn/ui is mobile-optimized and slides in from the bottom on small screens when configured with side='bottom'. Ask Lovable to add side='bottom' on mobile using a responsive Tailwind check, and side='right' on desktop. The product grid's responsive classes handle column count automatically.

### Can two users add the same last item simultaneously?

The client-side check on stock_quantity is optimistic and can be raced. To prevent overselling, ask Lovable to create a Supabase RPC function for the checkout step that uses a conditional UPDATE (WHERE stock_quantity >= qty) inside a transaction. If the update returns 0 rows, the checkout should be rejected with a clear error message.

### How do I add product images to Supabase Storage?

In your Supabase dashboard, go to Storage → New Bucket → name it 'products' → make it public. Upload images there and copy the public URL into the image_url field of your products table. Ask Lovable to add an image upload feature to your admin product management page that handles the file upload to this bucket automatically.

### Can I deploy this as a full e-commerce site?

Yes. Click the Publish icon in Lovable's top-right corner to get a public URL. For a custom domain, go to Publish → Settings. For production e-commerce, make sure you have added proper RLS policies, Stripe webhook verification in your Edge Function, and HTTPS (Lovable provides this automatically). Consider also adding a robots.txt and sitemap for SEO.

### Is there help available if I need custom cart logic or third-party integrations?

RapidDev builds production-grade Lovable apps including e-commerce stores with complex cart rules, multi-currency support, and payment provider integrations. Reach out if you need hands-on help.

### How do I show order history to logged-in users?

Ask Lovable to add an /orders page that fetches from the orders table where user_id = auth.uid(). Show each order as an Accordion: the summary row shows order number, date, status Badge, and total. Expanding it reveals the order_items joined with products showing name, quantity, and unit price.

---

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