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.
What you're building
A two-sided marketplace has three actor types: buyers, sellers, and admins. The database schema centers on a sellers table that extends auth.users with Stripe Connect account IDs and verification status. Each seller owns listings — products with images stored in Supabase Storage, price, stock, and a category.
Buyers browse listings using a shadcn/ui Command palette for instant search, or drill into categories. Adding to cart and checking out triggers a Supabase Edge Function that creates a Stripe PaymentIntent with an application_fee_amount so your platform takes its cut while the remainder routes to the seller's connected account.
Orders flow through status states (pending → paid → shipped → delivered → disputed). Sellers see their pending orders in a dashboard and mark them shipped. Buyers confirm delivery, which releases any held funds. Reviews can only be written after an order reaches the delivered state, preventing fake reviews.
Final result
A production-ready marketplace with real seller payouts, image uploads, search, order lifecycle management, and verified buyer reviews.
Tech stack
Prerequisites
- 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
Build steps
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.
1Create a two-sided marketplace app with Supabase. Set up these tables:23- 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_at4- listings: id, seller_id (references sellers), title, description, price (numeric), stock_quantity (int), category (text), status (draft|active|paused|sold_out), created_at, updated_at5- listing_images: id, listing_id (references listings), storage_path (text), position (int), is_primary (bool default false)6- 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_at7- order_items: id, order_id, listing_id, quantity, unit_price8- reviews: id, listing_id, order_id (unique — one review per order), reviewer_id (references auth.users), rating (int 1-5), body (text), created_at910RLS:11- sellers: users can read any approved seller, can insert/update their own seller row12- listings: anyone can read active listings, sellers can CRUD their own listings13- listing_images: same as listings14- orders: buyers see their own orders (buyer_id = auth.uid()), sellers see orders where seller_id matches their seller row15- reviews: anyone can read, only verified buyers can insert (enforce via Edge Function check)1617Create 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.
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.
1Create two Supabase Edge Functions:231. supabase/functions/stripe-connect-onboard/index.ts4Generates a Stripe Connect OAuth URL.5Receives: { return_url: string } in body.6Logic: 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.7Returns: { url: string }892. supabase/functions/stripe-connect-callback/index.ts10Handles the OAuth redirect from Stripe.11Receives: code and state query params.12Logic:13- Exchange code for access_token and stripe_user_id via Stripe's token endpoint14- Decode state to get user_id15- Upsert sellers row: { id: user_id, stripe_account_id: stripe_user_id, stripe_onboarding_complete: true }16- Redirect browser to /seller/dashboard?connected=true1718Store 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.
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.
1import { useCallback, useState } from 'react'2import { useForm } from 'react-hook-form'3import { zodResolver } from '@hookform/resolvers/zod'4import { z } from 'zod'5import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form'6import { Input } from '@/components/ui/input'7import { Textarea } from '@/components/ui/textarea'8import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'9import { Button } from '@/components/ui/button'10import { supabase } from '@/integrations/supabase/client'1112const schema = z.object({13 title: z.string().min(5).max(120),14 description: z.string().min(20).max(2000),15 price: z.coerce.number().positive(),16 stock_quantity: z.coerce.number().int().min(0),17 category: z.string().min(1),18})1920type ListingFormValues = z.infer<typeof schema>2122export function CreateListingForm({ sellerId }: { sellerId: string }) {23 const [images, setImages] = useState<File[]>([])24 const [uploading, setUploading] = useState(false)25 const form = useForm<ListingFormValues>({ resolver: zodResolver(schema) })2627 const onDrop = useCallback((e: React.DragEvent<HTMLDivElement>) => {28 e.preventDefault()29 const files = Array.from(e.dataTransfer.files).filter((f) => f.type.startsWith('image/'))30 setImages((prev) => [...prev, ...files].slice(0, 8))31 }, [])3233 const onSubmit = async (values: ListingFormValues) => {34 setUploading(true)35 const { data: listing } = await supabase36 .from('listings')37 .insert({ ...values, seller_id: sellerId, status: 'active' })38 .select('id')39 .single()40 if (!listing) { setUploading(false); return }41 for (let i = 0; i < images.length; i++) {42 const path = `${sellerId}/${listing.id}/${Date.now()}-${images[i].name}`43 await supabase.storage.from('listings').upload(path, images[i])44 await supabase.from('listing_images').insert({45 listing_id: listing.id,46 storage_path: path,47 position: i,48 is_primary: i === 0,49 })50 }51 setUploading(false)52 }5354 return (55 <Form {...form}>56 <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">57 <div58 onDrop={onDrop}59 onDragOver={(e) => e.preventDefault()}60 className="border-2 border-dashed rounded-lg p-8 text-center text-muted-foreground cursor-pointer hover:border-primary transition-colors"61 >62 {images.length === 0 ? 'Drag images here or click to select' : `${images.length} image(s) selected`}63 </div>64 <FormField control={form.control} name="title" render={({ field }) => (65 <FormItem><FormLabel>Title</FormLabel><FormControl><Input {...field} /></FormControl><FormMessage /></FormItem>66 )} />67 <FormField control={form.control} name="description" render={({ field }) => (68 <FormItem><FormLabel>Description</FormLabel><FormControl><Textarea rows={5} {...field} /></FormControl><FormMessage /></FormItem>69 )} />70 <div className="grid grid-cols-2 gap-4">71 <FormField control={form.control} name="price" render={({ field }) => (72 <FormItem><FormLabel>Price ($)</FormLabel><FormControl><Input type="number" step="0.01" {...field} /></FormControl><FormMessage /></FormItem>73 )} />74 <FormField control={form.control} name="stock_quantity" render={({ field }) => (75 <FormItem><FormLabel>Stock</FormLabel><FormControl><Input type="number" {...field} /></FormControl><FormMessage /></FormItem>76 )} />77 </div>78 <FormField control={form.control} name="category" render={({ field }) => (79 <FormItem><FormLabel>Category</FormLabel>80 <Select onValueChange={field.onChange}>81 <FormControl><SelectTrigger><SelectValue placeholder="Select category" /></SelectTrigger></FormControl>82 <SelectContent>83 <SelectItem value="electronics">Electronics</SelectItem>84 <SelectItem value="clothing">Clothing</SelectItem>85 <SelectItem value="home">Home & Garden</SelectItem>86 <SelectItem value="art">Art & Crafts</SelectItem>87 </SelectContent>88 </Select>89 <FormMessage /></FormItem>90 )} />91 <Button type="submit" disabled={uploading} className="w-full">92 {uploading ? 'Publishing...' : 'Publish Listing'}93 </Button>94 </form>95 </Form>96 )97}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.
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.
1Build a marketplace storefront page at src/pages/Marketplace.tsx.23Requirements:4- Header with a shadcn/ui Command component (open with Cmd+K or clicking a search bar).5 On input change, query Supabase: .textSearch('listings_search_vector', query, { type: 'websearch' })6 Show results as CommandItem rows with listing title, price, and seller name.7 Clicking a result navigates to /listing/[id].8- Category filter tabs below the header using shadcn/ui Tabs. 'All' plus one tab per category.9 Switching tabs re-fetches listings with .eq('category', selected) filter.10- Listing grid: 2 columns mobile, 3 tablet, 4 desktop. Each Card shows:11 - Primary image from listing_images where is_primary = true (Supabase Storage public URL)12 - Title (2-line clamp), price, seller display_name, average rating (from reviews aggregate), stock Badge13 - 'Add to Cart' button14- 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.15- Sort dropdown: Newest, Price Low to High, Price High to Low, Top RatedPro 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.
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.
1Create a Supabase Edge Function at supabase/functions/create-payment-intent/index.ts.23The function receives: { order_id: string } in the request body.4Requires authentication (validate JWT).56Logic:71. Fetch the order with its order_items and seller from Supabase using service role key.82. Calculate: subtotal, platform_fee = subtotal * seller.commission_rate, seller_payout = subtotal - platform_fee.93. Create a Stripe PaymentIntent:10 - amount: subtotal in cents (multiply by 100)11 - currency: 'usd'12 - application_fee_amount: platform_fee in cents13 - transfer_data: { destination: seller.stripe_account_id }14 - metadata: { order_id, seller_id }154. Update the orders row: stripe_payment_intent_id = intent.id, platform_fee, seller_payout.165. Return: { client_secret: intent.client_secret }1718In the frontend checkout page:19- Load Stripe.js with your publishable key.20- Call the Edge Function to get client_secret.21- Mount a Stripe PaymentElement inside a shadcn/ui Card.22- 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'.
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.
1Create a Supabase Edge Function at supabase/functions/submit-review/index.ts.23Receives: { listing_id: string, order_id: string, rating: number, body: string }4Requires authentication.56Logic:71. Get user_id from JWT.82. Verify the order exists where id = order_id AND buyer_id = user_id AND status = 'delivered'.93. Verify no existing review where order_id = order_id (one review per order).104. Verify the order contains an order_item with listing_id = listing_id.115. If all checks pass, insert review: { listing_id, order_id, reviewer_id: user_id, rating, body }.126. Return { success: true }.1314In the frontend, after a review is submitted, re-fetch the listing's review aggregate.15On the listing page, show:16- Average rating as filled/half/empty Star icons17- Review count in parentheses18- List of reviews as Cards with reviewer avatar (from auth.users), rating, body, and relative date19- 'Leave a Review' Button only shown if the current user has a delivered order for this listing and has not yet reviewed itExpected 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
1import { useState, useEffect, useCallback } from 'react'2import { supabase } from '@/integrations/supabase/client'34type Listing = {5 id: string6 title: string7 price: number8 stock_quantity: number9 category: string10 status: string11 seller: { display_name: string; avatar_url: string | null }12 primary_image_url: string | null13 avg_rating: number | null14 review_count: number15}1617type Filters = {18 category?: string19 sort?: 'newest' | 'price_asc' | 'price_desc' | 'top_rated'20 search?: string21}2223const PAGE_SIZE = 202425export function useListings(filters: Filters = {}) {26 const [listings, setListings] = useState<Listing[]>([])27 const [loading, setLoading] = useState(false)28 const [hasMore, setHasMore] = useState(true)29 const [offset, setOffset] = useState(0)3031 const fetchListings = useCallback(async (reset = false) => {32 setLoading(true)33 const currentOffset = reset ? 0 : offset3435 let query = supabase36 .from('listings')37 .select(`38 id, title, price, stock_quantity, category, status,39 sellers!inner(display_name, avatar_url),40 listing_images(storage_path, is_primary),41 reviews(rating)42 `)43 .eq('status', 'active')44 .range(currentOffset, currentOffset + PAGE_SIZE - 1)4546 if (filters.category) query = query.eq('category', filters.category)47 if (filters.search) query = query.textSearch('listings_search_vector', filters.search, { type: 'websearch' })4849 switch (filters.sort) {50 case 'price_asc': query = query.order('price', { ascending: true }); break51 case 'price_desc': query = query.order('price', { ascending: false }); break52 case 'newest': query = query.order('created_at', { ascending: false }); break53 default: query = query.order('created_at', { ascending: false })54 }5556 const { data } = await query5758 const mapped: Listing[] = (data ?? []).map((row: any) => {59 const primaryImg = row.listing_images?.find((img: any) => img.is_primary)60 const ratings = row.reviews?.map((r: any) => r.rating) ?? []61 const avg = ratings.length > 0 ? ratings.reduce((a: number, b: number) => a + b, 0) / ratings.length : null62 const { data: urlData } = primaryImg63 ? supabase.storage.from('listings').getPublicUrl(primaryImg.storage_path)64 : { data: null }65 return {66 id: row.id,67 title: row.title,68 price: row.price,69 stock_quantity: row.stock_quantity,70 category: row.category,71 status: row.status,72 seller: row.sellers,73 primary_image_url: urlData?.publicUrl ?? null,74 avg_rating: avg,75 review_count: ratings.length,76 }77 })7879 setListings(reset ? mapped : (prev) => [...prev, ...mapped])80 setHasMore((data ?? []).length === PAGE_SIZE)81 setOffset(currentOffset + PAGE_SIZE)82 setLoading(false)83 }, [filters, offset])8485 useEffect(() => { fetchListings(true) }, [filters.category, filters.search, filters.sort])8687 return { listings, loading, hasMore, loadMore: () => fetchListings(false) }88}Customization ideas
Offer management (counteroffers and best-offer listings)
Add an offers table (listing_id, buyer_id, seller_id, amount, status: pending|accepted|declined|expired, expires_at). Listings can enable 'Accept Offers'. Buyers send offers, sellers accept or counter. Accepting an offer auto-creates an order at the offer price. Implement expiry with a Supabase scheduled Edge Function.
Escrow-style held payouts
Instead of immediate transfer to the seller, hold funds in your Stripe platform account by removing transfer_data from the PaymentIntent. When the buyer marks an order delivered, a separate Edge Function calls the Stripe Transfers API to release the seller payout. This gives you a dispute window.
Featured listings and promoted slots
Add a featured boolean and featured_until timestamp to listings. Create a Stripe Checkout flow where sellers pay a flat fee to feature their listing for 7 or 30 days. A Supabase Edge Function webhook handler activates the feature on payment success. Featured listings appear first in the storefront grid with a Badge.
Seller analytics dashboard
Create a /seller/analytics page that queries orders, order_items, and reviews for the logged-in seller. Show a Recharts line chart of daily revenue for the past 30 days, top 5 listings by sales volume, average review rating over time, and a conversion rate estimate based on listing views tracked in a listing_views table.
Dispute resolution workflow
Add a disputes table (order_id, opened_by, reason, status: open|under_review|resolved, resolution). Buyers can open a dispute within 14 days of delivery. Both parties upload evidence to Supabase Storage. An admin dashboard lists open disputes with a resolution form that can trigger a Stripe refund Edge Function.
Common pitfalls
Pitfall: Exposing the Stripe secret key on the frontend
How to avoid: 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().
Pitfall: Not enforcing seller status before allowing listing creation
How to avoid: 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.
Pitfall: Storing listing images directly in the database as base64
How to avoid: 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.
Pitfall: Allowing self-reviews from sellers
How to avoid: 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.
Pitfall: Not setting up Stripe Connect webhook event handling
How to avoid: 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.
AI prompts to try
Copy these prompts to build this project faster.
I'm building a two-sided marketplace in Lovable (React + Supabase + Stripe Connect). I have tables: sellers (id, stripe_account_id, commission_rate), listings (seller_id, price, status), orders (buyer_id, seller_id, status, stripe_payment_intent_id). Write a TypeScript Supabase Edge Function (Deno) called create-payment-intent that takes { order_id } from the request body, fetches the order and seller, calculates the platform fee using commission_rate, and creates a Stripe PaymentIntent with application_fee_amount and transfer_data.destination set to the seller's stripe_account_id. Return the client_secret.
Add a 'Save for Later' feature to the marketplace. Add a saved_listings table (user_id, listing_id, created_at) with RLS allowing users to manage their own saved listings. On each listing card, add a heart icon Button that toggles the saved state. Create a /saved page showing all saved listings in the same grid layout as the main storefront. Show the count of saves on each listing card as a muted text under the price.
In Supabase, write a PostgreSQL function called get_seller_dashboard_stats(p_seller_id uuid) that returns a JSON object with: total_revenue (sum of seller_payout from paid/shipped/delivered orders), pending_orders (count where status=paid), avg_rating (average from reviews joined through listings), total_listings (count of active listings), revenue_last_30_days (sum of seller_payout from orders in the past 30 days). Run this as a single RPC call from the seller dashboard.
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.
Talk to an Expert
Our team has built 600+ apps. Get personalized help with your project.
Book a free consultation