# How to Build Shopping cart with V0

- Tool: v0
- Difficulty: Intermediate
- Compatibility: V0 Free
- Last updated: April 2026

## TL;DR

Build a full shopping cart with V0 featuring persistent cart state for anonymous and logged-in users, quantity management, real-time price calculation, and Stripe Checkout integration. You'll create product cards, a slide-out mini cart, webhook-verified payment processing, and automatic stock management — all in about 1-2 hours.

## Before you start

- A V0 account (free tier works for this project)
- A Supabase project (free tier works — connect via V0's Connect panel)
- A Stripe account (test mode is free — connect via Vercel Marketplace in V0)
- Product images hosted somewhere accessible (Supabase Storage or any URL)

## Step-by-step guide

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

Open V0 and create a new project. Use the Connect panel to add Supabase and Stripe via Vercel Marketplace. This auto-provisions database keys and Stripe keys into the Vars tab. Then prompt V0 to create the product catalog and cart schema.

```
// Paste this prompt into V0's AI chat:
// Create a Supabase schema for a shopping cart:
// 1. products table: id (uuid PK), name (text), description (text), price_cents (int NOT NULL), image_url (text), stock (int DEFAULT 0), is_active (boolean DEFAULT true)
// 2. carts table: id (uuid PK), user_id (uuid FK nullable), session_id (text), created_at (timestamptz), updated_at (timestamptz)
// 3. cart_items table: id (uuid PK), cart_id (uuid FK), product_id (uuid FK), quantity (int DEFAULT 1), UNIQUE(cart_id, product_id)
// 4. orders table: id (uuid PK), user_id (uuid FK), stripe_session_id (text), total_cents (int), status (text DEFAULT 'pending' — 'pending', 'paid', 'shipped', 'cancelled'), created_at (timestamptz)
// Add RLS: public read on products, users see own carts and orders.
// Seed 8 sample products with names, descriptions, prices, and placeholder image URLs.
```

> Pro tip: After connecting Stripe via the Vercel Marketplace in V0's Connect panel, check the Vars tab to confirm STRIPE_SECRET_KEY and NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY are auto-provisioned with the correct prefixes.

**Expected result:** Four tables created with RLS policies, 8 sample products seeded, and Stripe keys automatically available in the Vars tab.

### 2. Build the product grid and add-to-cart functionality

Create the main product page as a Server Component that fetches products from Supabase. Each product displays in a Card with an add-to-cart Button. The cart mutation uses a Server Action for instant, secure updates.

```
import { createClient } from '@/lib/supabase/server'
import { Card, CardContent, CardFooter } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { addToCart } from '@/app/actions/cart'
import Image from 'next/image'

export default async function ProductsPage() {
  const supabase = await createClient()
  const { data: products } = await supabase
    .from('products')
    .select('*')
    .eq('is_active', true)
    .order('name')

  return (
    <div className="p-6">
      <h1 className="text-3xl font-bold mb-6">Shop</h1>
      <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6">
        {products?.map((product) => (
          <Card key={product.id}>
            <CardContent className="p-4">
              <div className="aspect-square relative mb-3 rounded-lg overflow-hidden">
                <Image
                  src={product.image_url}
                  alt={product.name}
                  fill
                  className="object-cover"
                />
              </div>
              <h2 className="font-semibold">{product.name}</h2>
              <p className="text-muted-foreground text-sm">{product.description}</p>
              <p className="text-lg font-bold mt-2">
                ${(product.price_cents / 100).toFixed(2)}
              </p>
              {product.stock < 5 && product.stock > 0 && (
                <Badge variant="destructive" className="mt-1">Only {product.stock} left</Badge>
              )}
            </CardContent>
            <CardFooter>
              <form action={addToCart}>
                <input type="hidden" name="productId" value={product.id} />
                <Button type="submit" className="w-full" disabled={product.stock === 0}>
                  {product.stock === 0 ? 'Out of Stock' : 'Add to Cart'}
                </Button>
              </form>
            </CardFooter>
          </Card>
        ))}
      </div>
    </div>
  )
}
```

**Expected result:** A responsive product grid with Card components showing product images, names, prices, stock Badges, and add-to-cart Buttons.

### 3. Create Server Actions for cart mutations

Build Server Actions for adding items to cart, updating quantities, and removing items. The actions handle both anonymous users (via session_id cookie) and authenticated users, with cart merging on login.

```
'use server'

import { cookies } from 'next/headers'
import { createClient } from '@/lib/supabase/server'
import { revalidatePath } from 'next/cache'

async function getOrCreateCart() {
  const supabase = await createClient()
  const { data: { user } } = await supabase.auth.getUser()
  const cookieStore = await cookies()
  let sessionId = cookieStore.get('cart_session')?.value

  if (user) {
    const { data: cart } = await supabase
      .from('carts')
      .select('id')
      .eq('user_id', user.id)
      .single()
    if (cart) return cart.id
  }

  if (sessionId) {
    const { data: cart } = await supabase
      .from('carts')
      .select('id')
      .eq('session_id', sessionId)
      .single()
    if (cart) return cart.id
  }

  sessionId = crypto.randomUUID()
  cookieStore.set('cart_session', sessionId, { httpOnly: true, maxAge: 60 * 60 * 24 * 30 })

  const { data: newCart } = await supabase
    .from('carts')
    .insert({ user_id: user?.id, session_id: sessionId })
    .select('id')
    .single()

  return newCart!.id
}

export async function addToCart(formData: FormData) {
  const productId = formData.get('productId') as string
  const cartId = await getOrCreateCart()
  const supabase = await createClient()

  const { data: existing } = await supabase
    .from('cart_items')
    .select('id, quantity')
    .eq('cart_id', cartId)
    .eq('product_id', productId)
    .single()

  if (existing) {
    await supabase
      .from('cart_items')
      .update({ quantity: existing.quantity + 1 })
      .eq('id', existing.id)
  } else {
    await supabase
      .from('cart_items')
      .insert({ cart_id: cartId, product_id: productId, quantity: 1 })
  }

  revalidatePath('/')
}

export async function updateQuantity(itemId: string, quantity: number) {
  const supabase = await createClient()
  if (quantity <= 0) {
    await supabase.from('cart_items').delete().eq('id', itemId)
  } else {
    await supabase.from('cart_items').update({ quantity }).eq('id', itemId)
  }
  revalidatePath('/cart')
}

export async function removeFromCart(itemId: string) {
  const supabase = await createClient()
  await supabase.from('cart_items').delete().eq('id', itemId)
  revalidatePath('/cart')
}
```

> Pro tip: Use Design Mode (Option+D) to visually adjust product card spacing, image aspect ratios, and button sizing at zero credit cost after V0 generates the initial layout.

**Expected result:** Adding a product creates or finds the user's cart and adds the item. Quantity increments for duplicate adds. Anonymous users get a session_id cookie that persists for 30 days.

### 4. Build the Stripe Checkout API route

Create an API route that converts cart items into Stripe Checkout line items and creates a checkout session. The customer is redirected to Stripe's hosted checkout page for secure payment.

```
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.NEXT_PUBLIC_SUPABASE_URL!,
  process.env.SUPABASE_SERVICE_ROLE_KEY!
)

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

  const { data: items } = await supabase
    .from('cart_items')
    .select('quantity, products(name, price_cents, image_url)')
    .eq('cart_id', cartId)

  if (!items || items.length === 0) {
    return NextResponse.json({ error: 'Cart is empty' }, { status: 400 })
  }

  const lineItems = items.map((item: any) => ({
    price_data: {
      currency: 'usd',
      product_data: {
        name: item.products.name,
        images: item.products.image_url ? [item.products.image_url] : [],
      },
      unit_amount: item.products.price_cents,
    },
    quantity: item.quantity,
  }))

  const session = await stripe.checkout.sessions.create({
    payment_method_types: ['card'],
    line_items: lineItems,
    mode: 'payment',
    success_url: `${req.nextUrl.origin}/order/success?session_id={CHECKOUT_SESSION_ID}`,
    cancel_url: `${req.nextUrl.origin}/cart`,
    metadata: { cart_id: cartId },
  })

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

**Expected result:** POST to /api/checkout with a cartId returns a Stripe Checkout URL. The customer is redirected to Stripe's hosted page where they enter payment details.

### 5. Handle payment confirmation with Stripe webhook

Create a webhook handler that listens for checkout.session.completed events from Stripe. On successful payment, it creates an order record and decrements product stock. Uses 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.NEXT_PUBLIC_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 })
  }

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

    const { data: cartItems } = await supabase
      .from('cart_items')
      .select('product_id, quantity')
      .eq('cart_id', cartId)

    await supabase.from('orders').insert({
      stripe_session_id: session.id,
      total_cents: session.amount_total,
      status: 'paid',
    })

    for (const item of cartItems ?? []) {
      await supabase.rpc('decrement_stock', {
        p_product_id: item.product_id,
        p_quantity: item.quantity,
      })
    }

    await supabase.from('cart_items').delete().eq('cart_id', cartId)
  }

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

**Expected result:** After successful Stripe payment, an order is created in Supabase, product stock is decremented, and the cart is cleared. The webhook verifies Stripe's signature for security.

## 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.NEXT_PUBLIC_SUPABASE_URL!,
  process.env.SUPABASE_SERVICE_ROLE_KEY!
)

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

  const { data: items } = await supabase
    .from('cart_items')
    .select('quantity, products(name, price_cents, image_url)')
    .eq('cart_id', cartId)

  if (!items || items.length === 0) {
    return NextResponse.json({ error: 'Cart is empty' }, { status: 400 })
  }

  const lineItems = items.map((item: any) => ({
    price_data: {
      currency: 'usd',
      product_data: {
        name: item.products.name,
        images: item.products.image_url ? [item.products.image_url] : [],
      },
      unit_amount: item.products.price_cents,
    },
    quantity: item.quantity,
  }))

  const session = await stripe.checkout.sessions.create({
    payment_method_types: ['card'],
    line_items: lineItems,
    mode: 'payment',
    success_url: `${req.nextUrl.origin}/order/success?session_id={CHECKOUT_SESSION_ID}`,
    cancel_url: `${req.nextUrl.origin}/cart`,
    metadata: { cart_id: cartId },
  })

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

## Common mistakes

- **Using request.json() instead of request.text() in the Stripe webhook handler** — Stripe's webhook signature is computed against the raw request body. Parsing as JSON first changes the byte representation, causing signature verification to always fail. Fix: Always use request.text() to get the raw body, pass it to stripe.webhooks.constructEvent() for verification, then access the parsed event object from the result.
- **Storing prices as floating-point dollars instead of integer cents** — Floating-point arithmetic causes rounding errors. $19.99 * 3 might equal $59.969999... instead of $59.97. Stripe requires amounts in cents. Fix: Store all prices as integers in cents (e.g., 1999 for $19.99). Only convert to dollars for display using (price_cents / 100).toFixed(2).
- **Not handling the anonymous-to-authenticated cart merge** — If a user adds items anonymously then logs in, they expect to see their items. Without merging, the anonymous cart is abandoned and the user sees an empty cart. Fix: On login, check for a cart_session cookie. If it exists, move those cart_items to the authenticated user's cart, then delete the anonymous cart.
- **Decrementing stock without checking availability first** — Two users could buy the last item simultaneously. Without an atomic check-and-decrement, you can sell more items than you have in stock. Fix: Use a Supabase RPC function that decrements stock only WHERE stock >= quantity. If the update affects 0 rows, the item is out of stock and the order should be rejected.

## Best practices

- Use Stripe via Vercel Marketplace in V0's Connect panel for automatic key provisioning — STRIPE_SECRET_KEY (server-only) and NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY (client)
- Store prices in cents as integers, not dollars as floats, to avoid rounding errors in cart totals
- Use request.text() in the Stripe webhook handler for raw body signature verification — never request.json()
- Set STRIPE_WEBHOOK_SECRET in V0's Vars tab (no NEXT_PUBLIC_ prefix) after deploying to production and registering the webhook URL
- Use Design Mode (Option+D) to visually adjust product Card spacing, image sizes, and the mini cart Sheet width at zero credit cost
- Implement optimistic add-to-cart with useOptimistic from React 19 so the cart count updates instantly before the Server Action completes
- Use Server Components for the product grid to get server-side rendering — product pages are SEO-friendly and load fast
- Create an atomic decrement_stock RPC function in Supabase to prevent overselling when multiple customers check out simultaneously

## Frequently asked questions

### How does the cart persist for users who are not logged in?

When an anonymous user adds their first item, a session_id is generated using crypto.randomUUID() and stored in an httpOnly cookie. The cart is linked to this session_id in Supabase. The cookie persists for 30 days, so the cart survives browser restarts.

### What happens to the anonymous cart when a user logs in?

On login, a Server Action checks for the cart_session cookie. If found, it moves all cart_items from the anonymous cart to the authenticated user's cart, then deletes the anonymous cart. The user sees all their items without interruption.

### Do I need a paid Stripe account?

No. Stripe test mode is free and fully functional. You can use test card number 4242 4242 4242 4242 to simulate successful payments. You only need to activate your Stripe account when you are ready to accept real payments.

### What V0 plan do I need for this shopping cart?

V0 Free tier works. The shopping cart uses standard Server Components, Server Actions, API routes, and shadcn/ui components. Stripe and Supabase integrations are available via the Connect panel on all plans.

### How do I prevent overselling when two customers buy the last item?

Create a Supabase RPC function that atomically decrements stock only when stock >= requested quantity. If the update affects zero rows, the item is out of stock. Call this function in the webhook handler after payment confirmation, not before checkout.

### Can RapidDev help build a custom e-commerce cart?

Yes. RapidDev has built 600+ apps including full e-commerce platforms with multi-currency support, tax calculation, shipping integration, and inventory management. Book a free consultation to discuss your specific store requirements.

### How do I deploy and set up the Stripe webhook?

First publish to production via Share > Publish in V0. Then go to Stripe Dashboard > Developers > Webhooks, add your production URL (https://your-domain.vercel.app/api/webhooks/stripe), and copy the webhook signing secret. Add it as STRIPE_WEBHOOK_SECRET in V0's Vars tab (no NEXT_PUBLIC_ prefix).

---

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