# How to Build Auction platform with V0

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

## TL;DR

Build a real-time auction platform with V0 using Next.js, Supabase Realtime, and Stripe. You'll get live bidding with instant updates, anti-sniping countdown extensions, secure payment processing for winners, and admin auction management — all in about 2-4 hours without local setup.

## Before you start

- A V0 account (Premium plan recommended for complex multi-page builds)
- A Supabase project with Realtime enabled (free tier works — connect via V0's Connect panel)
- A Stripe account (test mode works for development)
- Understanding of auction mechanics (reserve prices, bid increments, time extensions)

## Step-by-step guide

### 1. Set up the database schema with auction tables and bid constraints

Create a new V0 project, connect Supabase and Stripe via the Connect panel. Then prompt V0 to create the auction, bid, and payment tables with proper constraints. The schema includes a database trigger for anti-sniping that extends the auction end time when late bids arrive.

```
// Paste this prompt into V0's AI chat:
// Build a real-time auction platform with Supabase. Create these tables:
// 1. auctions: id (uuid PK), seller_id (uuid FK to auth.users), title (text), description (text), images (text[]), starting_price (numeric), reserve_price (numeric), current_price (numeric), status (text CHECK in 'draft','active','ended','sold'), starts_at (timestamptz), ends_at (timestamptz), winner_id (uuid FK)
// 2. bids: id (uuid PK), auction_id (uuid FK), bidder_id (uuid FK to auth.users), amount (numeric), created_at (timestamptz), CONSTRAINT bid_positive CHECK (amount > 0)
// 3. payments: id (uuid PK), auction_id (uuid FK), buyer_id (uuid FK), stripe_payment_intent_id (text), amount (numeric), status (text), created_at (timestamptz)
// Add a database trigger: on bids INSERT, update auctions.current_price to the bid amount, and if the bid is within 5 minutes of ends_at, extend ends_at by 2 minutes (anti-sniping).
// Enable Realtime on the bids table.
// Add RLS policies.
```

> Pro tip: Enable Realtime on the bids table in the Supabase Dashboard under Database > Replication. This is required for the real-time bid feed to work on the auction detail page.

**Expected result:** Supabase is connected with tables created, the anti-sniping trigger is active, and Realtime is enabled on the bids table.

### 2. Build the bid submission API route with race condition prevention

Create the API route that validates and inserts bids. The key challenge is preventing two users from bidding on the same stale price simultaneously. Use a Supabase RPC function with SELECT FOR UPDATE to lock the auction row during bid validation.

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

const supabase = createClient(
  process.env.SUPABASE_URL!,
  process.env.SUPABASE_SERVICE_ROLE_KEY!
)

export async function POST(req: NextRequest) {
  const { auctionId, bidderId, amount } = await req.json()

  const { data, error } = await supabase.rpc('place_bid', {
    p_auction_id: auctionId,
    p_bidder_id: bidderId,
    p_amount: amount,
    p_min_increment: 1.0,
  })

  if (error) {
    const message = error.message.includes('too low')
      ? 'Your bid is below the current price plus minimum increment'
      : error.message.includes('not active')
        ? 'This auction is not currently active'
        : error.message
    return NextResponse.json({ error: message }, { status: 400 })
  }

  return NextResponse.json({ bid: data })
}

// The RPC function in Supabase SQL editor:
// CREATE OR REPLACE FUNCTION place_bid(
//   p_auction_id uuid, p_bidder_id uuid, p_amount numeric, p_min_increment numeric
// ) RETURNS uuid AS $$
// DECLARE
//   v_auction record;
//   v_bid_id uuid;
// BEGIN
//   SELECT * INTO v_auction FROM auctions WHERE id = p_auction_id FOR UPDATE;
//   IF v_auction.status != 'active' THEN RAISE EXCEPTION 'Auction not active';
//   END IF;
//   IF p_amount < v_auction.current_price + p_min_increment THEN
//     RAISE EXCEPTION 'Bid too low';
//   END IF;
//   INSERT INTO bids (auction_id, bidder_id, amount)
//   VALUES (p_auction_id, p_bidder_id, p_amount) RETURNING id INTO v_bid_id;
//   UPDATE auctions SET current_price = p_amount WHERE id = p_auction_id;
//   RETURN v_bid_id;
// END; $$ LANGUAGE plpgsql;
```

**Expected result:** The bid API route calls a Supabase RPC function that locks the auction row, validates the bid amount is above the current price plus minimum increment, and inserts the bid atomically.

### 3. Build the real-time auction detail page with live bid feed

Create the auction detail page that shows the current price, countdown timer, bid history, and a bid input. Subscribe to Supabase Realtime on the bids table so all viewers see new bids within 200ms without polling.

```
// Paste this prompt into V0's AI chat:
// Build a real-time auction detail page at app/auctions/[id]/page.tsx.
// Requirements:
// - Server Component fetches initial auction data and last 20 bids
// - Client Component wraps the bid feed with Supabase Realtime subscription
// - Subscribe to INSERT events on the bids table filtered by auction_id
// - Show current price in large font with animated update on new bids
// - Countdown timer that updates every second showing time remaining
// - Timer extends in real-time when anti-sniping trigger fires (subscribe to auction row changes)
// - Bid history in a ScrollArea showing bidder avatar, amount, and timestamp
// - Bid input with shadcn/ui Input for amount and Button to submit
// - AlertDialog for bid confirmation showing the amount before submitting
// - Skeleton loading states while initial data loads
// - Badge for auction status (active, ended, sold)
// - Image carousel for auction images using shadcn/ui Carousel
```

> Pro tip: Subscribe to both the bids table (for new bid inserts) and the auctions table (for end time extensions from the anti-sniping trigger) on the same channel. This keeps the countdown timer and bid feed in sync.

**Expected result:** The auction page shows live bid updates, a countdown timer that extends on late bids, an image carousel, and a bid submission form with confirmation dialog.

### 4. Create the Stripe payment flow for auction winners

When an auction ends, the winner needs to pay. Build the payment flow using Stripe Checkout — create a session with the winning bid amount and handle the webhook to confirm payment and update the auction status to 'sold'.

```
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 body = await req.text()
  const sig = req.headers.get('stripe-signature')!

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

  if (event.type === 'checkout.session.completed') {
    const session = event.data.object as Stripe.Checkout.Session
    const auctionId = session.metadata?.auction_id

    if (auctionId) {
      await supabase.from('payments').insert({
        auction_id: auctionId,
        buyer_id: session.metadata?.buyer_id,
        stripe_payment_intent_id: session.payment_intent as string,
        amount: (session.amount_total ?? 0) / 100,
        status: 'paid',
      })

      await supabase
        .from('auctions')
        .update({ status: 'sold' })
        .eq('id', auctionId)
    }
  }

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

> Pro tip: Always use request.text() — not request.json() — for the Stripe webhook body. Stripe signature verification requires the raw body, and parsing it first corrupts the signature check.

**Expected result:** When an auction winner completes Stripe Checkout, the webhook creates a payment record and updates the auction status to sold.

### 5. Build the auction listing grid and seller creation form

Create the public auction listing page and the seller's auction creation form. The listing page shows active auctions in a responsive grid with countdown timers, current prices, and bid counts. Sellers can create new auctions with images, descriptions, reserve prices, and scheduling.

```
// Paste this prompt into V0's AI chat:
// Build two pages:
// 1. Auction listing at app/auctions/page.tsx (Server Component):
//    - Grid of auction Cards with image, title, current price, time remaining countdown, bid count
//    - Badge for auction status (active, ending soon for < 1 hour)
//    - Select for category filter and sort (ending soonest, highest price, newest)
//    - Skeleton loading states
//    - Link to detail page
// 2. Create auction at app/auctions/new/page.tsx ('use client'):
//    - Form with Input for title, Textarea for description
//    - Image upload (multiple) to Supabase Storage
//    - Input for starting_price and reserve_price
//    - DatePicker for starts_at and ends_at
//    - Button to submit via Server Action
//    - Form validation with zod
```

**Expected result:** The listing page shows a responsive grid of active auctions with countdown timers. Sellers can create new auctions with images, pricing, and scheduled start/end times.

## Complete code example

File: `app/api/bids/route.ts`

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

const supabase = createClient(
  process.env.SUPABASE_URL!,
  process.env.SUPABASE_SERVICE_ROLE_KEY!
)

export async function POST(req: NextRequest) {
  const { auctionId, bidderId, amount } = await req.json()

  if (!auctionId || !bidderId || !amount) {
    return NextResponse.json(
      { error: 'Missing required fields' },
      { status: 400 }
    )
  }

  const { data, error } = await supabase.rpc('place_bid', {
    p_auction_id: auctionId,
    p_bidder_id: bidderId,
    p_amount: amount,
    p_min_increment: 1.0,
  })

  if (error) {
    const status = error.message.includes('not active') ? 409 : 400
    return NextResponse.json({ error: error.message }, { status })
  }

  return NextResponse.json({ bid_id: data })
}

export async function GET(req: NextRequest) {
  const { searchParams } = new URL(req.url)
  const auctionId = searchParams.get('auction_id')

  if (!auctionId) {
    return NextResponse.json(
      { error: 'auction_id is required' },
      { status: 400 }
    )
  }

  const { data, error } = await supabase
    .from('bids')
    .select('id, amount, created_at, bidder_id')
    .eq('auction_id', auctionId)
    .order('created_at', { ascending: false })
    .limit(50)

  if (error) {
    return NextResponse.json({ error: error.message }, { status: 500 })
  }

  return NextResponse.json({ bids: data })
}
```

## Common mistakes

- **Validating bid amounts in JavaScript instead of the database** — Client-side or API-route validation without database locking creates race conditions where two users can bid on the same stale price, both getting accepted. Fix: Use a Supabase RPC function with SELECT FOR UPDATE that locks the auction row, validates the bid is above current_price + min_increment, and inserts atomically — all within a single transaction.
- **Using request.json() for Stripe webhook body parsing** — Stripe signature verification requires the raw request body as a string. Parsing it as JSON first changes the byte representation, causing signature verification to fail every time. Fix: Always use request.text() to get the raw body, then pass it to stripe.webhooks.constructEvent() along with the stripe-signature header and your webhook secret.
- **Not enabling Supabase Realtime on the bids table** — Supabase Realtime is not enabled on tables by default. Without enabling it, the client subscription will connect but never receive any events. Fix: Go to Supabase Dashboard, navigate to Database, then Replication, and enable Realtime for the bids table. Also enable it for the auctions table to sync countdown timer extensions.
- **Not handling the anti-sniping timer extension on the frontend** — The database trigger extends ends_at, but if the frontend countdown timer only uses the original end time, users see the auction as ended even though it was extended. Fix: Subscribe to changes on the auctions table via Supabase Realtime. When an UPDATE event arrives with a new ends_at value, update the countdown timer's target time.

## Best practices

- Use Supabase RPC functions with SELECT FOR UPDATE for bid validation to prevent race conditions at the database level
- Enable Realtime on both the bids and auctions tables — bids for the live feed, auctions for anti-sniping timer extensions
- Always use request.text() for Stripe webhook body parsing to preserve the raw bytes for signature verification
- Store STRIPE_SECRET_KEY and STRIPE_WEBHOOK_SECRET in V0's Vars tab without NEXT_PUBLIC_ prefix
- Implement anti-sniping as a database trigger so it fires automatically regardless of how bids are inserted
- Use Server Components for the auction listing page and Client Components only for the real-time bid feed and countdown timer
- Use Design Mode (Option+D) to visually adjust auction card layouts, countdown timer styling, and bid feed density without spending credits
- Add Skeleton loading states to all auction pages since real-time data may take a moment to hydrate

## Frequently asked questions

### How does the anti-sniping protection work?

A database trigger fires on every bid insert. If the bid arrives within 5 minutes of the auction's end time, the trigger automatically extends ends_at by 2 minutes. This prevents last-second sniping and gives all bidders a fair chance to respond. The frontend detects the time extension via Supabase Realtime.

### How do I prevent two people from placing the same bid simultaneously?

Use a Supabase RPC function with SELECT FOR UPDATE that locks the auction row within a transaction. It checks that the new bid exceeds current_price plus the minimum increment, inserts the bid, and updates current_price atomically. If two bids arrive simultaneously, one waits for the lock and then re-checks the price.

### What V0 plan do I need for an auction platform?

V0 Premium is recommended because the auction platform requires multiple complex pages (listing, detail with real-time, creation form), API routes (bids, payments, webhooks), and Supabase Realtime integration. The free plan's credits will not be sufficient.

### Can the free Supabase tier handle real-time auctions?

Yes for development and small-scale use. The free tier supports 200 concurrent Realtime connections, which is enough for testing and early launches. For production auctions with many simultaneous viewers, upgrade to Supabase Pro.

### How do I deploy the auction platform?

Click Share then Publish to Production in V0 for instant Vercel deployment. After publishing, register the Stripe webhook URL at https://yourdomain.vercel.app/api/webhooks/stripe in the Stripe Dashboard. Select the checkout.session.completed event.

### Can RapidDev help build a custom auction platform?

Yes. RapidDev has built 600+ apps including complex real-time platforms with bidding systems, escrow payments, and fraud detection. Book a free consultation to discuss your specific auction platform requirements.

---

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