# How to Build a Marketplace with Lovable

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

## TL;DR

Build a full two-sided marketplace in Lovable where sellers list products and buyers place orders. You get seller onboarding with Stripe Connect, split payment disbursements, Supabase Storage for listing images, a Command palette search, and a complete order lifecycle — all without writing backend code from scratch.

## Before you start

- Lovable Pro account (Stripe Connect Edge Functions are credit-intensive to generate)
- Supabase project created at supabase.com — free tier works for development
- Stripe account with Connect enabled and your platform's client_id from the Stripe Dashboard
- Stripe secret key and webhook signing secret added to Cloud tab → Secrets
- A rough list of product categories your marketplace will support

## Step-by-step guide

### 1. Define the full database schema

Start with a single comprehensive schema prompt. Getting the schema right at this stage saves significant rework. The core tables are sellers, listings, listing_images, orders, order_items, and reviews. RLS must be defined at this stage.

```
Create a two-sided marketplace app with Supabase. Set up these tables:

- sellers: id (uuid, references auth.users), stripe_account_id (text), stripe_onboarding_complete (bool default false), display_name, bio, avatar_url, commission_rate (numeric default 0.10), status (pending|approved|suspended), created_at
- listings: id, seller_id (references sellers), title, description, price (numeric), stock_quantity (int), category (text), status (draft|active|paused|sold_out), created_at, updated_at
- listing_images: id, listing_id (references listings), storage_path (text), position (int), is_primary (bool default false)
- orders: id, buyer_id (references auth.users), seller_id (references sellers), status (pending|paid|shipped|delivered|disputed|refunded), stripe_payment_intent_id (text), subtotal (numeric), platform_fee (numeric), seller_payout (numeric), shipping_address (jsonb), created_at, updated_at
- order_items: id, order_id, listing_id, quantity, unit_price
- reviews: id, listing_id, order_id (unique — one review per order), reviewer_id (references auth.users), rating (int 1-5), body (text), created_at

RLS:
- sellers: users can read any approved seller, can insert/update their own seller row
- listings: anyone can read active listings, sellers can CRUD their own listings
- listing_images: same as listings
- orders: buyers see their own orders (buyer_id = auth.uid()), sellers see orders where seller_id matches their seller row
- reviews: anyone can read, only verified buyers can insert (enforce via Edge Function check)

Create a Supabase Storage bucket called 'listings' (public reads, authenticated uploads).
```

> Pro tip: Ask Lovable to add a listings_search_vector tsvector column computed from title and description, and create a GIN index on it. This enables fast full-text search without an external search service.

**Expected result:** All seven tables are created with proper foreign keys and RLS. The listings bucket appears in Supabase Storage. TypeScript types are generated for all tables.

### 2. Build seller onboarding with Stripe Connect

Sellers must connect their Stripe account before they can receive payouts. This requires an Edge Function that generates the Stripe Connect OAuth URL and another that handles the OAuth callback to store the connected account ID.

```
Create two Supabase Edge Functions:

1. supabase/functions/stripe-connect-onboard/index.ts
Generates a Stripe Connect OAuth URL.
Receives: { return_url: string } in body.
Logic: call Stripe OAuth authorize URL with client_id from Deno.env.get('STRIPE_CONNECT_CLIENT_ID'), scope=read_write, state=user JWT sub claim, redirect_uri pointing to the callback function URL.
Returns: { url: string }

2. supabase/functions/stripe-connect-callback/index.ts
Handles the OAuth redirect from Stripe.
Receives: code and state query params.
Logic:
- Exchange code for access_token and stripe_user_id via Stripe's token endpoint
- Decode state to get user_id
- Upsert sellers row: { id: user_id, stripe_account_id: stripe_user_id, stripe_onboarding_complete: true }
- Redirect browser to /seller/dashboard?connected=true

Store STRIPE_SECRET_KEY and STRIPE_CONNECT_CLIENT_ID in Cloud tab → Secrets.
```

> Pro tip: After the seller connects their Stripe account, also create their seller record with status='pending' and send an admin notification. This gives you a manual review step before listings go live, preventing fraud from day one.

**Expected result:** Clicking 'Become a Seller' redirects to Stripe, connects the account, and returns the user to /seller/dashboard with a success Banner. The sellers table row is created with the Stripe account ID.

### 3. Build the listing creation form with image upload

Sellers create listings with a multi-field form and drag-and-drop image upload. Images go to Supabase Storage. The primary image path is stored in listing_images with is_primary = true.

```
import { useCallback, useState } from 'react'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Button } from '@/components/ui/button'
import { supabase } from '@/integrations/supabase/client'

const schema = z.object({
  title: z.string().min(5).max(120),
  description: z.string().min(20).max(2000),
  price: z.coerce.number().positive(),
  stock_quantity: z.coerce.number().int().min(0),
  category: z.string().min(1),
})

type ListingFormValues = z.infer<typeof schema>

export function CreateListingForm({ sellerId }: { sellerId: string }) {
  const [images, setImages] = useState<File[]>([])
  const [uploading, setUploading] = useState(false)
  const form = useForm<ListingFormValues>({ resolver: zodResolver(schema) })

  const onDrop = useCallback((e: React.DragEvent<HTMLDivElement>) => {
    e.preventDefault()
    const files = Array.from(e.dataTransfer.files).filter((f) => f.type.startsWith('image/'))
    setImages((prev) => [...prev, ...files].slice(0, 8))
  }, [])

  const onSubmit = async (values: ListingFormValues) => {
    setUploading(true)
    const { data: listing } = await supabase
      .from('listings')
      .insert({ ...values, seller_id: sellerId, status: 'active' })
      .select('id')
      .single()
    if (!listing) { setUploading(false); return }
    for (let i = 0; i < images.length; i++) {
      const path = `${sellerId}/${listing.id}/${Date.now()}-${images[i].name}`
      await supabase.storage.from('listings').upload(path, images[i])
      await supabase.from('listing_images').insert({
        listing_id: listing.id,
        storage_path: path,
        position: i,
        is_primary: i === 0,
      })
    }
    setUploading(false)
  }

  return (
    <Form {...form}>
      <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
        <div
          onDrop={onDrop}
          onDragOver={(e) => e.preventDefault()}
          className="border-2 border-dashed rounded-lg p-8 text-center text-muted-foreground cursor-pointer hover:border-primary transition-colors"
        >
          {images.length === 0 ? 'Drag images here or click to select' : `${images.length} image(s) selected`}
        </div>
        <FormField control={form.control} name="title" render={({ field }) => (
          <FormItem><FormLabel>Title</FormLabel><FormControl><Input {...field} /></FormControl><FormMessage /></FormItem>
        )} />
        <FormField control={form.control} name="description" render={({ field }) => (
          <FormItem><FormLabel>Description</FormLabel><FormControl><Textarea rows={5} {...field} /></FormControl><FormMessage /></FormItem>
        )} />
        <div className="grid grid-cols-2 gap-4">
          <FormField control={form.control} name="price" render={({ field }) => (
            <FormItem><FormLabel>Price ($)</FormLabel><FormControl><Input type="number" step="0.01" {...field} /></FormControl><FormMessage /></FormItem>
          )} />
          <FormField control={form.control} name="stock_quantity" render={({ field }) => (
            <FormItem><FormLabel>Stock</FormLabel><FormControl><Input type="number" {...field} /></FormControl><FormMessage /></FormItem>
          )} />
        </div>
        <FormField control={form.control} name="category" render={({ field }) => (
          <FormItem><FormLabel>Category</FormLabel>
            <Select onValueChange={field.onChange}>
              <FormControl><SelectTrigger><SelectValue placeholder="Select category" /></SelectTrigger></FormControl>
              <SelectContent>
                <SelectItem value="electronics">Electronics</SelectItem>
                <SelectItem value="clothing">Clothing</SelectItem>
                <SelectItem value="home">Home & Garden</SelectItem>
                <SelectItem value="art">Art & Crafts</SelectItem>
              </SelectContent>
            </Select>
          <FormMessage /></FormItem>
        )} />
        <Button type="submit" disabled={uploading} className="w-full">
          {uploading ? 'Publishing...' : 'Publish Listing'}
        </Button>
      </form>
    </Form>
  )
}
```

**Expected result:** Sellers can drag images, fill in details, and submit. The listing appears in the storefront immediately. Images load from Supabase Storage public URLs.

### 4. Build the storefront with Command search

The buyer-facing storefront needs a responsive grid of listing cards and a Command palette for instant search. The Command component searches the listings_search_vector column using Supabase full-text search.

```
Build a marketplace storefront page at src/pages/Marketplace.tsx.

Requirements:
- Header with a shadcn/ui Command component (open with Cmd+K or clicking a search bar).
  On input change, query Supabase: .textSearch('listings_search_vector', query, { type: 'websearch' })
  Show results as CommandItem rows with listing title, price, and seller name.
  Clicking a result navigates to /listing/[id].
- Category filter tabs below the header using shadcn/ui Tabs. 'All' plus one tab per category.
  Switching tabs re-fetches listings with .eq('category', selected) filter.
- Listing grid: 2 columns mobile, 3 tablet, 4 desktop. Each Card shows:
  - Primary image from listing_images where is_primary = true (Supabase Storage public URL)
  - Title (2-line clamp), price, seller display_name, average rating (from reviews aggregate), stock Badge
  - 'Add to Cart' button
- Infinite scroll: fetch 20 listings at a time using .range(offset, offset+19). Increment offset when the user scrolls near the bottom using an IntersectionObserver.
- Sort dropdown: Newest, Price Low to High, Price High to Low, Top Rated
```

> Pro tip: Ask Lovable to add a shadcn/ui Drawer component on mobile that contains the category filters and sort options, triggered by a filter icon in the header. This keeps the mobile layout clean.

**Expected result:** The storefront loads with paginated listing cards. Opening the Command palette and typing returns matching results instantly. Switching category tabs filters the grid.

### 5. Build split payment checkout with Stripe Connect

The checkout Edge Function creates a Stripe PaymentIntent that charges the buyer and automatically routes the seller's share to their connected account, withholding the platform fee.

```
Create a Supabase Edge Function at supabase/functions/create-payment-intent/index.ts.

The function receives: { order_id: string } in the request body.
Requires authentication (validate JWT).

Logic:
1. Fetch the order with its order_items and seller from Supabase using service role key.
2. Calculate: subtotal, platform_fee = subtotal * seller.commission_rate, seller_payout = subtotal - platform_fee.
3. Create a Stripe PaymentIntent:
   - amount: subtotal in cents (multiply by 100)
   - currency: 'usd'
   - application_fee_amount: platform_fee in cents
   - transfer_data: { destination: seller.stripe_account_id }
   - metadata: { order_id, seller_id }
4. Update the orders row: stripe_payment_intent_id = intent.id, platform_fee, seller_payout.
5. Return: { client_secret: intent.client_secret }

In the frontend checkout page:
- Load Stripe.js with your publishable key.
- Call the Edge Function to get client_secret.
- Mount a Stripe PaymentElement inside a shadcn/ui Card.
- On payment confirmation, update order status to 'paid' and navigate to /order-confirmation/[orderId].
```

> Pro tip: Also create a Supabase Edge Function as a Stripe webhook handler that listens for payment_intent.succeeded and payment_intent.payment_failed events. This is more reliable than relying on the frontend redirect to update order status — the redirect can fail if the user closes the tab.

**Expected result:** Completing checkout charges the buyer. The Stripe Dashboard shows the payment with an automatic transfer to the seller's connected account minus the platform fee. Order status updates to 'paid'.

### 6. Build the reviews system with purchase verification

Only buyers who have a delivered order for a listing can leave a review. An Edge Function validates this before inserting the review, preventing fake reviews.

```
Create a Supabase Edge Function at supabase/functions/submit-review/index.ts.

Receives: { listing_id: string, order_id: string, rating: number, body: string }
Requires authentication.

Logic:
1. Get user_id from JWT.
2. Verify the order exists where id = order_id AND buyer_id = user_id AND status = 'delivered'.
3. Verify no existing review where order_id = order_id (one review per order).
4. Verify the order contains an order_item with listing_id = listing_id.
5. If all checks pass, insert review: { listing_id, order_id, reviewer_id: user_id, rating, body }.
6. Return { success: true }.

In the frontend, after a review is submitted, re-fetch the listing's review aggregate.
On the listing page, show:
- Average rating as filled/half/empty Star icons
- Review count in parentheses
- List of reviews as Cards with reviewer avatar (from auth.users), rating, body, and relative date
- 'Leave a Review' Button only shown if the current user has a delivered order for this listing and has not yet reviewed it
```

**Expected result:** The listing page shows reviews with star ratings and verified buyer labels. The review form only appears for eligible buyers. Attempting to submit a review for an undelivered order returns an error.

## Complete code example

File: `src/hooks/useListings.ts`

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

type Listing = {
  id: string
  title: string
  price: number
  stock_quantity: number
  category: string
  status: string
  seller: { display_name: string; avatar_url: string | null }
  primary_image_url: string | null
  avg_rating: number | null
  review_count: number
}

type Filters = {
  category?: string
  sort?: 'newest' | 'price_asc' | 'price_desc' | 'top_rated'
  search?: string
}

const PAGE_SIZE = 20

export function useListings(filters: Filters = {}) {
  const [listings, setListings] = useState<Listing[]>([])
  const [loading, setLoading] = useState(false)
  const [hasMore, setHasMore] = useState(true)
  const [offset, setOffset] = useState(0)

  const fetchListings = useCallback(async (reset = false) => {
    setLoading(true)
    const currentOffset = reset ? 0 : offset

    let query = supabase
      .from('listings')
      .select(`
        id, title, price, stock_quantity, category, status,
        sellers!inner(display_name, avatar_url),
        listing_images(storage_path, is_primary),
        reviews(rating)
      `)
      .eq('status', 'active')
      .range(currentOffset, currentOffset + PAGE_SIZE - 1)

    if (filters.category) query = query.eq('category', filters.category)
    if (filters.search) query = query.textSearch('listings_search_vector', filters.search, { type: 'websearch' })

    switch (filters.sort) {
      case 'price_asc': query = query.order('price', { ascending: true }); break
      case 'price_desc': query = query.order('price', { ascending: false }); break
      case 'newest': query = query.order('created_at', { ascending: false }); break
      default: query = query.order('created_at', { ascending: false })
    }

    const { data } = await query

    const mapped: Listing[] = (data ?? []).map((row: any) => {
      const primaryImg = row.listing_images?.find((img: any) => img.is_primary)
      const ratings = row.reviews?.map((r: any) => r.rating) ?? []
      const avg = ratings.length > 0 ? ratings.reduce((a: number, b: number) => a + b, 0) / ratings.length : null
      const { data: urlData } = primaryImg
        ? supabase.storage.from('listings').getPublicUrl(primaryImg.storage_path)
        : { data: null }
      return {
        id: row.id,
        title: row.title,
        price: row.price,
        stock_quantity: row.stock_quantity,
        category: row.category,
        status: row.status,
        seller: row.sellers,
        primary_image_url: urlData?.publicUrl ?? null,
        avg_rating: avg,
        review_count: ratings.length,
      }
    })

    setListings(reset ? mapped : (prev) => [...prev, ...mapped])
    setHasMore((data ?? []).length === PAGE_SIZE)
    setOffset(currentOffset + PAGE_SIZE)
    setLoading(false)
  }, [filters, offset])

  useEffect(() => { fetchListings(true) }, [filters.category, filters.search, filters.sort])

  return { listings, loading, hasMore, loadMore: () => fetchListings(false) }
}
```

## Common mistakes

- **Exposing the Stripe secret key on the frontend** — PaymentIntents must be created server-side with your secret key. If the key leaks, anyone can charge arbitrary amounts to your Stripe account. Fix: Always create PaymentIntents in a Supabase Edge Function. Store STRIPE_SECRET_KEY only in Cloud tab → Secrets where it is encrypted and accessible only to Edge Functions via Deno.env.get().
- **Not enforcing seller status before allowing listing creation** — A seller whose account is 'suspended' or 'pending' could still insert listings if the RLS policy only checks seller_id = auth.uid() without checking status. Fix: Add a check in the listings INSERT policy: EXISTS (SELECT 1 FROM sellers WHERE id = auth.uid() AND status = 'approved'). This blocks suspended sellers at the database level, not just the frontend.
- **Storing listing images directly in the database as base64** — Base64 images bloat the database, slow down queries, and cannot be resized or cached by a CDN. Fix: Upload images to Supabase Storage and store only the storage_path string in listing_images. Use supabase.storage.from('listings').getPublicUrl(path) in the frontend to generate URLs. Supabase Storage serves files via CDN automatically.
- **Allowing self-reviews from sellers** — Without verification, a seller could create a buyer account and leave five-star reviews on their own listings. Fix: The submit-review Edge Function should verify that the reviewer_id is not the seller_id of the listing, and that there is a real delivered order linking the buyer to the listing.
- **Not setting up Stripe Connect webhook event handling** — Relying solely on frontend redirect after payment means orders can stay in 'pending' status forever if the user closes their browser mid-redirect. Fix: Register a Supabase Edge Function URL as a Stripe webhook endpoint listening for payment_intent.succeeded and account.updated. Update order status and seller onboarding status from these webhook events, which are reliable even when the browser is closed.

## Best practices

- Always validate that a seller's stripe_onboarding_complete = true before allowing listing creation. An incomplete Connect onboarding means payouts will fail.
- Use Supabase Storage transformation URLs for thumbnail generation. Append ?width=400&height=400&resize=cover to any Storage URL to get a resized version without storing multiple copies.
- Structure listing image storage paths as {seller_id}/{listing_id}/{timestamp}-{filename}. This makes it easy to delete all images for a listing with a prefix delete operation.
- Keep commission_rate on the sellers table rather than hardcoding it in Edge Functions. This lets you negotiate different rates with high-volume sellers without redeploying code.
- Index the listings table on (category, status, created_at) to keep category-filtered queries fast as the table grows.
- Use Supabase Realtime on the orders table so sellers see new orders appear in their dashboard instantly without polling.
- Implement idempotency keys on all Stripe API calls in Edge Functions. Pass the order_id as the idempotency key so retried requests don't create duplicate charges.
- Paginate all listing queries with .range() and never use .limit() alone without an offset — it makes infinite scroll impossible to implement correctly.

## Frequently asked questions

### How does Stripe Connect handle taxes?

Stripe Connect itself does not calculate taxes — that is your platform's responsibility. For simple cases, add a tax_rate field to your listings and calculate tax in the Edge Function before adding it to the PaymentIntent amount. For production marketplaces, Stripe Tax can be enabled on the PaymentIntent by adding automatic_payment_methods and tax configuration. Store the tax amount on the orders row for accounting.

### Can sellers withdraw their earnings at any time?

With Stripe Connect, you can configure payouts to happen automatically on a schedule (daily, weekly, or monthly) or manually on demand. Standard Connect accounts handle their own payout schedule. Express or Custom accounts let your platform trigger payouts via the Stripe Transfers API. Choose the Connect account type in your Stripe Dashboard based on how much control you want.

### How do I handle international sellers and currencies?

Stripe Connect supports multi-currency payouts. The buyer pays in your platform's currency, and Stripe handles the conversion to the seller's bank currency. In the PaymentIntent, set currency to your base currency. Sellers in different countries receive payouts in their local currency automatically. No extra Edge Function code is required.

### What happens if a buyer disputes a charge?

Stripe notifies your platform via a charge.dispute.created webhook event. Create an Edge Function to handle this event, update the disputed order's status to 'disputed' in Supabase, and notify the seller via email. Stripe automatically debits the disputed amount from your platform account. Your dispute workflow should collect evidence from both parties and submit it to Stripe within the response deadline.

### How do I add search filters like price range?

Supabase supports range filters natively. In your listings query, add .gte('price', minPrice).lte('price', maxPrice). For the UI, ask Lovable to add a shadcn/ui Slider component with two thumbs for min and max price. Debounce the slider's onChange handler by 300ms before re-fetching to avoid hammering Supabase on every slider move.

### Can listings have variants (size, color)?

Yes. Add a listing_variants table (id, listing_id, name, options jsonb, price_adjustment numeric, stock_quantity). A 'T-Shirt' listing might have a 'Size' variant with options ['S','M','L','XL'] and individual stock per variant. In the listing detail page, show variant selectors that update the add-to-cart target to a specific variant_id. Store variant_id in order_items for fulfillment.

### How do I moderate listings before they go live?

Set new listing status to 'draft' by default and add an admin dashboard page that fetches listings where status = 'draft'. Admins can preview the listing and approve it (update status to 'active') or reject it (update to 'rejected' with a rejection_reason). Add an RLS policy so only users with an admin role can update any listing's status field.

### Is there help available if the Stripe Connect setup gets complex?

RapidDev specializes in production marketplace builds with Stripe Connect, including escrow workflows, dispute handling, and multi-currency configurations. Reach out if you need expert help with your Lovable marketplace.

---

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