# How to Build Marketplace with V0

- Tool: v0
- Difficulty: Advanced
- Compatibility: V0 Premium or higher
- Last updated: April 2026

## TL;DR

Build a two-sided marketplace with V0 using Next.js, Supabase, Stripe Connect, and shadcn/ui. Features buyer and seller flows, split payments with platform fees, listing management, order tracking, reviews, and in-order messaging — all with webhook-verified payment processing. Takes about 2-4 hours.

## Before you start

- A V0 account (Premium or higher — this is a complex build)
- A Supabase project (free tier works — connect via V0's Connect panel)
- A Stripe account with Connect enabled (test mode works — connect via Vercel Marketplace)
- Your marketplace concept (what buyers and sellers will trade)

## Step-by-step guide

### 1. Set up the database schema for profiles, listings, and orders

Create the Supabase schema with role-based profiles, listings, orders with payment tracking, reviews, and messages. RLS policies ensure buyers and sellers only access their own data.

```
// Paste this prompt into V0's AI chat:
// Build a two-sided marketplace. Create a Supabase schema:
// 1. profiles: id (uuid PK FK to auth.users), role (text CHECK IN 'buyer','seller','both'), display_name (text), avatar_url (text), stripe_account_id (text), onboarding_complete (boolean DEFAULT false)
// 2. listings: id (uuid PK), seller_id (uuid FK to profiles), title (text), description (text), price_cents (int), category (text), images (text[]), status (text DEFAULT 'active'), created_at (timestamptz)
// 3. orders: id (uuid PK), listing_id (uuid FK to listings), buyer_id (uuid FK to profiles), seller_id (uuid FK to profiles), amount_cents (int), platform_fee_cents (int), status (text DEFAULT 'pending'), stripe_payment_intent (text), created_at (timestamptz)
// 4. reviews: id (uuid PK), order_id (uuid FK to orders), reviewer_id (uuid FK to profiles), rating (int CHECK 1-5), comment (text), created_at (timestamptz)
// 5. messages: id (uuid PK), order_id (uuid FK to orders), sender_id (uuid FK to profiles), body (text), created_at (timestamptz)
// Add RLS policies: buyers see their orders, sellers see their listings and orders.
```

> Pro tip: Use V0's Connect panel for Supabase setup and the Vercel Marketplace for Stripe Connect — both auto-provision keys into the Vars tab.

**Expected result:** Supabase is connected with all tables, RLS policies for role-based access, and Stripe Connect keys in the Vars tab.

### 2. Build the listing feed and detail pages

Create the public marketplace pages with listing cards, category filters, and individual listing pages with seller info, images, and purchase flow.

```
// Paste this prompt into V0's AI chat:
// Create marketplace pages:
// 1. app/page.tsx — listing feed with shadcn/ui Card grid: listing image, title, price, seller Avatar + name, category Badge. Add Tabs for categories, search Input, and sort Select (newest, price low-high, price high-low).
// 2. app/listings/[id]/page.tsx — listing detail: image gallery, full description, price in large text, seller Card with Avatar + rating stars + listing count. Add 'Buy Now' Button that creates a Stripe Checkout session. Add 'Message Seller' Button. Show reviews from completed orders below.
// Use Server Components for both pages. The 'Buy Now' button calls a Server Action.
```

**Expected result:** The listing feed shows a filterable grid of items. Detail pages show full listing info with buy and message buttons.

### 3. Create the Stripe Connect onboarding flow

Build the seller onboarding that creates a Stripe Connect account and redirects them to Stripe's hosted onboarding. After completing onboarding, the webhook updates the seller's profile.

```
import { NextRequest, NextResponse } from 'next/server'
import Stripe from 'stripe'
import { createClient } from '@supabase/supabase-js'

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)
const supabase = createClient(
  process.env.SUPABASE_URL!,
  process.env.SUPABASE_SERVICE_ROLE_KEY!
)

export async function POST(req: NextRequest) {
  const { user_id, email } = await req.json()

  const account = await stripe.accounts.create({
    type: 'express',
    email,
    metadata: { user_id },
  })

  await supabase
    .from('profiles')
    .update({ stripe_account_id: account.id })
    .eq('id', user_id)

  const accountLink = await stripe.accountLinks.create({
    account: account.id,
    refresh_url: `${process.env.NEXT_PUBLIC_APP_URL}/seller/onboarding?refresh=true`,
    return_url: `${process.env.NEXT_PUBLIC_APP_URL}/seller/dashboard`,
    type: 'account_onboarding',
  })

  return NextResponse.json({ url: accountLink.url })
}
```

**Expected result:** Sellers click 'Start Selling', get a Stripe Connect account created, and are redirected to complete onboarding on Stripe's hosted page.

### 4. Set up the payment flow with platform fees

Create the payment processing that charges the buyer, sends funds to the seller's connected account, and keeps the platform fee. Use destination charges for simplicity.

```
import { NextRequest, NextResponse } from 'next/server'
import Stripe from 'stripe'
import { createClient } from '@supabase/supabase-js'

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)
const supabase = createClient(
  process.env.SUPABASE_URL!,
  process.env.SUPABASE_SERVICE_ROLE_KEY!
)

export async function POST(req: NextRequest) {
  const { listing_id, buyer_id } = await req.json()

  const { data: listing } = await supabase
    .from('listings')
    .select('*, seller:profiles!seller_id(stripe_account_id)')
    .eq('id', listing_id)
    .single()

  if (!listing?.seller?.stripe_account_id) {
    return NextResponse.json({ error: 'Seller not onboarded' }, { status: 400 })
  }

  const platformFee = Math.round(listing.price_cents * 0.1)

  const session = await stripe.checkout.sessions.create({
    mode: 'payment',
    line_items: [{
      price_data: {
        currency: 'usd',
        product_data: { name: listing.title },
        unit_amount: listing.price_cents,
      },
      quantity: 1,
    }],
    payment_intent_data: {
      application_fee_amount: platformFee,
      transfer_data: { destination: listing.seller.stripe_account_id },
    },
    metadata: { listing_id, buyer_id, seller_id: listing.seller_id },
    success_url: `${process.env.NEXT_PUBLIC_APP_URL}/orders?success=true`,
    cancel_url: `${process.env.NEXT_PUBLIC_APP_URL}/listings/${listing_id}`,
  })

  return NextResponse.json({ url: session.url })
}
```

> Pro tip: Use application_fee_amount with transfer_data.destination for destination charges. Stripe automatically splits the payment: the platform fee goes to your account, the rest to the seller.

**Expected result:** Buyers are redirected to Stripe Checkout. Payment is split: 10% platform fee, 90% to the seller's connected account.

### 5. Build the webhook handler for orders and onboarding

Create the webhook handler that processes checkout completions (creating orders) and account updates (tracking seller onboarding). Use request.text() for raw body verification.

```
import { NextRequest, NextResponse } from 'next/server'
import Stripe from 'stripe'
import { createClient } from '@supabase/supabase-js'

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)
const supabase = createClient(
  process.env.SUPABASE_URL!,
  process.env.SUPABASE_SERVICE_ROLE_KEY!
)

export async function POST(req: NextRequest) {
  const rawBody = await req.text()
  const sig = req.headers.get('stripe-signature')!

  let event: Stripe.Event
  try {
    event = stripe.webhooks.constructEvent(rawBody, sig, process.env.STRIPE_WEBHOOK_SECRET!)
  } catch {
    return NextResponse.json({ error: 'Invalid signature' }, { status: 400 })
  }

  switch (event.type) {
    case 'checkout.session.completed': {
      const session = event.data.object as Stripe.Checkout.Session
      const { listing_id, buyer_id, seller_id } = session.metadata || {}
      if (listing_id && buyer_id) {
        const fee = Math.round((session.amount_total || 0) * 0.1)
        await supabase.from('orders').insert({
          listing_id, buyer_id, seller_id,
          amount_cents: session.amount_total,
          platform_fee_cents: fee,
          status: 'paid',
          stripe_payment_intent: session.payment_intent,
        })
      }
      break
    }
    case 'account.updated': {
      const account = event.data.object as Stripe.Account
      if (account.charges_enabled) {
        await supabase.from('profiles')
          .update({ onboarding_complete: true })
          .eq('stripe_account_id', account.id)
      }
      break
    }
  }

  return NextResponse.json({ received: true })
}
```

**Expected result:** Successful checkouts create order records. Completed onboarding updates seller profiles. Both events are webhook-verified.

### 6. Build the seller dashboard and deploy

Create the seller-facing dashboard for managing listings, viewing orders, and tracking earnings. Then deploy and register Stripe webhooks.

```
// Paste this prompt into V0's AI chat:
// Create a seller dashboard at app/seller/dashboard/page.tsx.
// Requirements:
// - Summary Cards: Total Earnings, Active Listings, Pending Orders, Average Rating
// - Tabs: Listings, Orders, Earnings
// - Listings tab: Table with title, price, status Badge, views count, edit/delete actions. Add 'New Listing' Button with Dialog form.
// - Orders tab: Table with order ID, buyer name, listing title, amount, status Badge (pending/paid/shipped/completed), date. Status Select to update order status.
// - Earnings tab: summary of total paid, platform fees, net earnings. Table of completed orders.
// - If seller not onboarded with Stripe Connect, show an onboarding Card with 'Complete Setup' Button.
// Use Server Components with Supabase queries filtered by seller_id = current user.
```

> Pro tip: After deploying, register your webhook URL in Stripe Dashboard. Select both checkout.session.completed and account.updated events.

**Expected result:** The seller dashboard shows listings, orders, and earnings. Sellers without Stripe Connect see an onboarding prompt.

## Complete code example

File: `app/api/webhooks/stripe/route.ts`

```typescript
import { NextRequest, NextResponse } from 'next/server'
import Stripe from 'stripe'
import { createClient } from '@supabase/supabase-js'

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)
const supabase = createClient(
  process.env.SUPABASE_URL!,
  process.env.SUPABASE_SERVICE_ROLE_KEY!
)

export async function POST(req: NextRequest) {
  const rawBody = await req.text()
  const sig = req.headers.get('stripe-signature')!

  let event: Stripe.Event
  try {
    event = stripe.webhooks.constructEvent(
      rawBody, sig, process.env.STRIPE_WEBHOOK_SECRET!
    )
  } catch {
    return NextResponse.json({ error: 'Signature failed' }, { status: 400 })
  }

  if (event.type === 'checkout.session.completed') {
    const session = event.data.object as Stripe.Checkout.Session
    const meta = session.metadata || {}
    if (meta.listing_id) {
      await supabase.from('orders').insert({
        listing_id: meta.listing_id,
        buyer_id: meta.buyer_id,
        seller_id: meta.seller_id,
        amount_cents: session.amount_total,
        platform_fee_cents: Math.round((session.amount_total || 0) * 0.1),
        status: 'paid',
        stripe_payment_intent: session.payment_intent as string,
      })
    }
  }

  if (event.type === 'account.updated') {
    const acct = event.data.object as Stripe.Account
    if (acct.charges_enabled && acct.details_submitted) {
      await supabase.from('profiles')
        .update({ onboarding_complete: true })
        .eq('stripe_account_id', acct.id)
    }
  }

  return NextResponse.json({ received: true })
}
```

## Common mistakes

- **Using request.json() instead of request.text() for Stripe webhook verification** — Stripe signature verification requires the raw body. Parsing then re-stringifying changes the format and breaks verification every time. Fix: Always use request.text() to get the raw body, then pass it to stripe.webhooks.constructEvent().
- **Not checking if the seller has completed Stripe Connect onboarding before allowing purchases** — Payments to unverified Connect accounts fail or get held indefinitely, creating a bad buyer experience and potential refund issues. Fix: Check the seller's onboarding_complete flag before showing the Buy button. If not onboarded, show a message that the seller is setting up payments.
- **Storing platform fee as a fixed amount instead of calculating from the order total** — Hard-coded fees do not scale with different listing prices and can result in incorrect splits or even losses on low-priced items. Fix: Calculate platform_fee_cents as a percentage of the listing price (e.g., Math.round(price_cents * 0.1) for 10%) in the checkout creation endpoint.

## Best practices

- Use Stripe Connect destination charges with application_fee_amount for simple payment splitting between platform and sellers
- Always verify Stripe webhook signatures with request.text() before processing any events
- Check seller onboarding_complete before enabling purchases to prevent failed payment attempts
- Use RLS policies to ensure buyers only see their orders and sellers only see their listings and incoming orders
- Register both checkout.session.completed and account.updated webhook events in Stripe Dashboard
- Use V0's Design Mode (Option+D) to adjust listing Card layouts and seller profile styling without spending credits
- Store all amounts in cents (integers) to avoid floating-point math errors in currency calculations
- Use Server Components for the listing feed for SEO — search engines see fully rendered listing titles and descriptions

## Frequently asked questions

### How does the payment split work between platform and sellers?

Stripe Connect destination charges handle the split automatically. You set application_fee_amount (e.g., 10% of the listing price) and transfer_data.destination (seller's connected account). Stripe charges the buyer, deducts your platform fee, and sends the rest to the seller.

### What is Stripe Connect Express?

Express is the simplest Connect account type. Sellers complete onboarding on Stripe's hosted page (not your site), and Stripe handles identity verification, tax forms, and payouts. You just create the account and redirect them.

### Do I need a paid V0 plan?

Yes, Premium ($20/month) at minimum. The marketplace has many complex pages (listing feed, detail, seller dashboard, onboarding, webhook handler) that require numerous prompts to build.

### How do I handle refunds and disputes?

Use the Stripe Refund API in a Server Action. For Connect payments, you can refund from your platform account or reverse the transfer to the seller. Handle the charge.dispute.created webhook to track disputes.

### Can sellers manage their own payouts?

Yes. Stripe Express accounts include a seller dashboard hosted by Stripe where sellers can view their balance, upcoming payouts, and transaction history. Generate a login link with stripe.accounts.createLoginLink().

### How do I deploy the marketplace?

Click Share in V0, then Publish to Production. Set STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET, and NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY in the Vars tab. Register webhook events (checkout.session.completed, account.updated) with your production URL in Stripe Dashboard.

### Can RapidDev help build a custom marketplace?

Yes. RapidDev has built over 600 apps including two-sided marketplaces with Stripe Connect, escrow payments, and real-time messaging. Book a free consultation to discuss your marketplace concept and get a production-ready platform.

---

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