# How to Build Checkout flow with V0

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

## TL;DR

Build a multi-step checkout flow with V0 using Next.js, Stripe, and Supabase. You'll get a shopping cart review, shipping address form, coupon code validation, Stripe Checkout payment, webhook-verified order creation, and automatic stock reservation — all in about 1-2 hours.

## Before you start

- A V0 account (Premium plan recommended for multi-step builds)
- A Supabase project (free tier works — connect via V0's Connect panel)
- A Stripe account (test mode works for development)
- Products configured in your Supabase database with prices and stock levels

## Step-by-step guide

### 1. Set up the database schema for products, orders, and coupons

Create a new V0 project, connect Supabase and Stripe via the Connect panel. Create the products, orders, order items, and coupons tables for the checkout system.

```
// Paste this prompt into V0's AI chat:
// Build an e-commerce checkout with Supabase and Stripe. Create these tables:
// 1. products: id (uuid PK), name (text), description (text), price (numeric), images (text[]), stock (int), is_active (boolean default true)
// 2. orders: id (uuid PK), user_id (uuid FK), status (text CHECK in 'pending','paid','shipped','delivered','cancelled'), subtotal (numeric), discount (numeric default 0), tax (numeric), total (numeric), shipping_address (jsonb), stripe_checkout_session_id (text unique), created_at (timestamptz)
// 3. order_items: id (uuid PK), order_id (uuid FK), product_id (uuid FK), quantity (int), unit_price (numeric)
// 4. coupons: id (uuid PK), code (text unique), discount_type (text CHECK in 'percentage','fixed'), discount_value (numeric), min_order (numeric), max_uses (int), used_count (int default 0), expires_at (timestamptz)
// Add RLS so users can only see their own orders.
```

> Pro tip: Use the Connect panel to add Stripe via Vercel Marketplace — it auto-provisions STRIPE_SECRET_KEY and NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY without any manual key copying.

**Expected result:** Supabase and Stripe are connected. Product, order, and coupon tables are created with proper constraints.

### 2. Build the multi-step checkout form

Create the checkout page with a stepper that guides users through cart review, shipping address, and order summary before payment. Use react-hook-form with zod for validation.

```
// Paste this prompt into V0's AI chat:
// Build a multi-step checkout at app/checkout/page.tsx ('use client').
// Requirements:
// - Step 1 (Cart Review): Show cart items in Cards with image, name, quantity selector, unit price, line total. Remove Button. Subtotal at bottom.
// - Step 2 (Shipping): Form with Input fields for name, address line 1, address line 2, city, state, zip, country. Use react-hook-form + zod validation.
// - Step 3 (Review & Pay): Order summary showing items, shipping address, subtotal, coupon discount, tax, total. Input for coupon code with Apply Button. "Pay with Stripe" Button.
// - Stepper pattern: horizontal steps indicator showing current step (1, 2, 3) with Progress bar
// - Continue and Back Buttons for navigation between steps
// - Store cart state in React state (loaded from props or context)
// - On "Pay with Stripe", POST to /api/checkout with cart items, shipping address, and coupon code
// - Use shadcn/ui Card, Input, Button, Separator, Badge, Select for quantity
```

**Expected result:** The checkout page shows a 3-step flow: Cart Review, Shipping, Review and Pay. Each step validates before proceeding. The final step redirects to Stripe.

### 3. Create the Stripe Checkout Session with stock reservation

Build the API route that validates the cart, reserves stock atomically, creates a Stripe Checkout Session, and returns the redirect URL. Stock is reserved before payment to prevent overselling.

```
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 { items, shippingAddress, couponCode, userId } = await req.json()

  // Reserve stock atomically
  for (const item of items) {
    const { error } = await supabase.rpc('reserve_stock', {
      p_product_id: item.productId,
      p_quantity: item.quantity,
    })
    if (error) {
      return NextResponse.json({ error: `${item.name} is out of stock` }, { status: 409 })
    }
  }

  const lineItems = items.map((item: { name: string; price: number; quantity: number }) => ({
    price_data: {
      currency: 'usd',
      product_data: { name: item.name },
      unit_amount: Math.round(item.price * 100),
    },
    quantity: item.quantity,
  }))

  const session = await stripe.checkout.sessions.create({
    mode: 'payment',
    line_items: lineItems,
    success_url: `${req.nextUrl.origin}/orders/confirmation?session_id={CHECKOUT_SESSION_ID}`,
    cancel_url: `${req.nextUrl.origin}/checkout`,
    metadata: {
      user_id: userId,
      shipping_address: JSON.stringify(shippingAddress),
      items: JSON.stringify(items),
      coupon_code: couponCode || '',
    },
  })

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

> Pro tip: The stock reservation RPC function should use SELECT FOR UPDATE to lock the product row and check stock >= quantity before decrementing. If stock is insufficient, the function raises an exception that the API route catches.

**Expected result:** The checkout API reserves stock, creates a Stripe Checkout Session with the cart items, and returns the Stripe redirect URL.

### 4. Build the webhook handler for order creation

Create the Stripe webhook handler that processes checkout.session.completed events to create the order in Supabase with all items, shipping address, and payment details.

```
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 meta = session.metadata!
    const items = JSON.parse(meta.items)
    const total = (session.amount_total ?? 0) / 100

    const { data: order } = await supabase.from('orders').insert({
      user_id: meta.user_id,
      status: 'paid',
      subtotal: total,
      tax: 0,
      total,
      shipping_address: JSON.parse(meta.shipping_address),
      stripe_checkout_session_id: session.id,
    }).select().single()

    if (order) {
      await supabase.from('order_items').insert(
        items.map((item: { productId: string; quantity: number; price: number }) => ({
          order_id: order.id,
          product_id: item.productId,
          quantity: item.quantity,
          unit_price: item.price,
        }))
      )
    }
  }

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

**Expected result:** When payment completes, the webhook creates an order with all items and shipping details in Supabase.

### 5. Add coupon validation and order confirmation page

Create the coupon validation Server Action and the order confirmation page that shows order details after successful payment.

```
'use server'

import { createClient } from '@supabase/supabase-js'

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

export async function validateCoupon(code: string, subtotal: number) {
  const { data: coupon } = await supabase
    .from('coupons')
    .select('*')
    .eq('code', code.toUpperCase())
    .single()

  if (!coupon) return { valid: false, error: 'Coupon not found' }
  if (coupon.expires_at && new Date(coupon.expires_at) < new Date())
    return { valid: false, error: 'Coupon expired' }
  if (coupon.max_uses && coupon.used_count >= coupon.max_uses)
    return { valid: false, error: 'Coupon usage limit reached' }
  if (coupon.min_order && subtotal < coupon.min_order)
    return { valid: false, error: `Minimum order $${coupon.min_order}` }

  const discount = coupon.discount_type === 'percentage'
    ? subtotal * (coupon.discount_value / 100)
    : coupon.discount_value

  return { valid: true, discount: Math.min(discount, subtotal), coupon }
}
```

**Expected result:** Coupon codes are validated against the database with expiry, usage limit, and minimum order checks. The confirmation page shows order details.

## Complete code example

File: `app/api/checkout/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 { items, shippingAddress, userId } = await req.json()

  for (const item of items) {
    const { error } = await supabase.rpc('reserve_stock', {
      p_product_id: item.productId,
      p_quantity: item.quantity,
    })
    if (error) {
      return NextResponse.json(
        { error: `${item.name} is out of stock` },
        { status: 409 }
      )
    }
  }

  const lineItems = items.map(
    (item: { name: string; price: number; quantity: number }) => ({
      price_data: {
        currency: 'usd',
        product_data: { name: item.name },
        unit_amount: Math.round(item.price * 100),
      },
      quantity: item.quantity,
    })
  )

  const session = await stripe.checkout.sessions.create({
    mode: 'payment',
    line_items: lineItems,
    success_url: `${req.nextUrl.origin}/orders/confirmation?session_id={CHECKOUT_SESSION_ID}`,
    cancel_url: `${req.nextUrl.origin}/checkout`,
    metadata: {
      user_id: userId,
      shipping_address: JSON.stringify(shippingAddress),
      items: JSON.stringify(items),
    },
  })

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

## Common mistakes

- **Not reserving stock before creating the Stripe Checkout Session** — Without stock reservation, two customers can both see an item in stock and both complete checkout, but only one item exists. This creates overselling. Fix: Use a Supabase RPC function with SELECT FOR UPDATE that atomically checks and decrements stock. If stock is insufficient, return an error before creating the Stripe session.
- **Using request.json() for the Stripe webhook body** — Stripe signature verification requires the raw body as a string. Parsing as JSON alters the bytes and causes constructEvent to fail. Fix: Always use request.text() for the webhook body. Pass the raw string to stripe.webhooks.constructEvent().
- **Not handling abandoned checkout sessions** — If stock is reserved when the Checkout Session is created but the customer abandons payment, the stock remains reserved and unavailable for other customers. Fix: Listen for the checkout.session.expired Stripe webhook event and release the reserved stock by incrementing the product stock values back.

## Best practices

- Reserve stock atomically with a Supabase RPC function using SELECT FOR UPDATE before creating the Stripe Checkout Session
- Always use request.text() for Stripe webhook body parsing to preserve the raw bytes for signature verification
- Handle checkout.session.expired webhook to release reserved stock for abandoned checkouts
- Use react-hook-form with zod validation for the shipping address form to catch errors before submission
- Store all secret keys in V0's Vars tab without NEXT_PUBLIC_ prefix
- Use Design Mode (Option+D) to adjust the checkout stepper layout, cart card styling, and form field spacing without credits
- Pass all order data in Stripe Checkout Session metadata so the webhook has everything needed to create the order
- Set export const runtime = 'nodejs' in the webhook route for Stripe signature verification compatibility

## Frequently asked questions

### How does stock reservation prevent overselling?

A Supabase RPC function locks the product row with SELECT FOR UPDATE, checks that available stock is sufficient, and decrements it atomically. If two checkouts race, one gets the lock first and the other waits — when it gets the lock, it sees the updated stock and fails if insufficient.

### What happens if a customer abandons checkout after stock is reserved?

Listen for the checkout.session.expired Stripe webhook event (sessions expire after 24 hours by default). The webhook handler should restore the reserved stock by incrementing the product stock values back.

### What V0 plan do I need for a checkout flow?

V0 Premium is recommended because the checkout requires a multi-step form, API routes, Stripe integration, and webhook handling — more credits than the free plan provides.

### Can I use Stripe Elements instead of Stripe Checkout?

Yes, but Stripe Checkout is recommended for V0 projects because it is a hosted page that handles PCI compliance, mobile optimization, and multiple payment methods automatically. Stripe Elements requires more custom UI code.

### How do I deploy the checkout flow?

Click Share then Publish to Production in V0. After deploying, register the webhook URL at https://yourdomain.vercel.app/api/webhooks/stripe in the Stripe Dashboard. Select checkout.session.completed and checkout.session.expired events.

### Can RapidDev help build a custom checkout flow?

Yes. RapidDev has built 600+ apps including complex e-commerce checkouts with multi-currency support, tax calculation, and inventory management. Book a free consultation to discuss your checkout requirements.

---

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