Skip to main content
RapidDev - Software Development Agency

How to Build Shopping cart with V0

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.

What you'll build

  • Product grid with shadcn/ui Card components showing images, prices, and add-to-cart Button with optimistic updates
  • Slide-out mini cart using shadcn/ui Sheet component with quantity Input and real-time total calculation
  • Persistent cart for anonymous users via session_id cookie that merges into authenticated user cart on login
  • Stripe Checkout integration via API route that maps cart items to Stripe line items
  • Webhook handler at app/api/webhooks/stripe/route.ts for checkout.session.completed to mark orders paid
  • Automatic stock decrement on successful payment with Badge indicators for low stock
Book a free consultation
4.9Clutch rating
600+Happy partners
17+Countries served
190+Team members
Intermediate12 min read1-2 hoursV0 FreeApril 2026RapidDev Engineering Team
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.

What you're building

A shopping cart is the core of any e-commerce experience. Customers expect to add items, adjust quantities, see real-time totals, and check out seamlessly. If the cart doesn't persist when they close the browser or if checkout fails silently, you lose the sale.

V0 makes building this straightforward — prompt it to generate the product grid, cart Sheet, and checkout flow, and it scaffolds the full Next.js implementation with shadcn/ui. Stripe integration is one click via the Vercel Marketplace in V0's Connect panel, which auto-provisions your API keys. Supabase handles products, carts, and orders.

The architecture uses Server Components for the product grid (fast, SEO-friendly), Server Actions for cart mutations (add, update quantity, remove), an API route for creating Stripe Checkout sessions, and a webhook handler for payment confirmation. Cart state persists in Supabase, keyed by either user_id or an anonymous session_id stored in a cookie.

Final result

A complete shopping cart with product browsing, persistent cart state, quantity management, Stripe Checkout, webhook-verified payments, and automatic stock tracking.

Tech stack

V0AI Code Generator
Next.jsFull-Stack Framework
Tailwind CSSStyling
shadcn/uiComponent Library
SupabaseDatabase
StripePayments

Prerequisites

  • 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)

Build steps

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.

prompt.txt
1// Paste this prompt into V0's AI chat:
2// Create a Supabase schema for a shopping cart:
3// 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)
4// 2. carts table: id (uuid PK), user_id (uuid FK nullable), session_id (text), created_at (timestamptz), updated_at (timestamptz)
5// 3. cart_items table: id (uuid PK), cart_id (uuid FK), product_id (uuid FK), quantity (int DEFAULT 1), UNIQUE(cart_id, product_id)
6// 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)
7// Add RLS: public read on products, users see own carts and orders.
8// 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.

app/page.tsx
1import { createClient } from '@/lib/supabase/server'
2import { Card, CardContent, CardFooter } from '@/components/ui/card'
3import { Button } from '@/components/ui/button'
4import { Badge } from '@/components/ui/badge'
5import { addToCart } from '@/app/actions/cart'
6import Image from 'next/image'
7
8export default async function ProductsPage() {
9 const supabase = await createClient()
10 const { data: products } = await supabase
11 .from('products')
12 .select('*')
13 .eq('is_active', true)
14 .order('name')
15
16 return (
17 <div className="p-6">
18 <h1 className="text-3xl font-bold mb-6">Shop</h1>
19 <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6">
20 {products?.map((product) => (
21 <Card key={product.id}>
22 <CardContent className="p-4">
23 <div className="aspect-square relative mb-3 rounded-lg overflow-hidden">
24 <Image
25 src={product.image_url}
26 alt={product.name}
27 fill
28 className="object-cover"
29 />
30 </div>
31 <h2 className="font-semibold">{product.name}</h2>
32 <p className="text-muted-foreground text-sm">{product.description}</p>
33 <p className="text-lg font-bold mt-2">
34 ${(product.price_cents / 100).toFixed(2)}
35 </p>
36 {product.stock < 5 && product.stock > 0 && (
37 <Badge variant="destructive" className="mt-1">Only {product.stock} left</Badge>
38 )}
39 </CardContent>
40 <CardFooter>
41 <form action={addToCart}>
42 <input type="hidden" name="productId" value={product.id} />
43 <Button type="submit" className="w-full" disabled={product.stock === 0}>
44 {product.stock === 0 ? 'Out of Stock' : 'Add to Cart'}
45 </Button>
46 </form>
47 </CardFooter>
48 </Card>
49 ))}
50 </div>
51 </div>
52 )
53}

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.

app/actions/cart.ts
1'use server'
2
3import { cookies } from 'next/headers'
4import { createClient } from '@/lib/supabase/server'
5import { revalidatePath } from 'next/cache'
6
7async function getOrCreateCart() {
8 const supabase = await createClient()
9 const { data: { user } } = await supabase.auth.getUser()
10 const cookieStore = await cookies()
11 let sessionId = cookieStore.get('cart_session')?.value
12
13 if (user) {
14 const { data: cart } = await supabase
15 .from('carts')
16 .select('id')
17 .eq('user_id', user.id)
18 .single()
19 if (cart) return cart.id
20 }
21
22 if (sessionId) {
23 const { data: cart } = await supabase
24 .from('carts')
25 .select('id')
26 .eq('session_id', sessionId)
27 .single()
28 if (cart) return cart.id
29 }
30
31 sessionId = crypto.randomUUID()
32 cookieStore.set('cart_session', sessionId, { httpOnly: true, maxAge: 60 * 60 * 24 * 30 })
33
34 const { data: newCart } = await supabase
35 .from('carts')
36 .insert({ user_id: user?.id, session_id: sessionId })
37 .select('id')
38 .single()
39
40 return newCart!.id
41}
42
43export async function addToCart(formData: FormData) {
44 const productId = formData.get('productId') as string
45 const cartId = await getOrCreateCart()
46 const supabase = await createClient()
47
48 const { data: existing } = await supabase
49 .from('cart_items')
50 .select('id, quantity')
51 .eq('cart_id', cartId)
52 .eq('product_id', productId)
53 .single()
54
55 if (existing) {
56 await supabase
57 .from('cart_items')
58 .update({ quantity: existing.quantity + 1 })
59 .eq('id', existing.id)
60 } else {
61 await supabase
62 .from('cart_items')
63 .insert({ cart_id: cartId, product_id: productId, quantity: 1 })
64 }
65
66 revalidatePath('/')
67}
68
69export async function updateQuantity(itemId: string, quantity: number) {
70 const supabase = await createClient()
71 if (quantity <= 0) {
72 await supabase.from('cart_items').delete().eq('id', itemId)
73 } else {
74 await supabase.from('cart_items').update({ quantity }).eq('id', itemId)
75 }
76 revalidatePath('/cart')
77}
78
79export async function removeFromCart(itemId: string) {
80 const supabase = await createClient()
81 await supabase.from('cart_items').delete().eq('id', itemId)
82 revalidatePath('/cart')
83}

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.

app/api/checkout/route.ts
1import { NextRequest, NextResponse } from 'next/server'
2import Stripe from 'stripe'
3import { createClient } from '@supabase/supabase-js'
4
5const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)
6const supabase = createClient(
7 process.env.NEXT_PUBLIC_SUPABASE_URL!,
8 process.env.SUPABASE_SERVICE_ROLE_KEY!
9)
10
11export async function POST(req: NextRequest) {
12 const { cartId } = await req.json()
13
14 const { data: items } = await supabase
15 .from('cart_items')
16 .select('quantity, products(name, price_cents, image_url)')
17 .eq('cart_id', cartId)
18
19 if (!items || items.length === 0) {
20 return NextResponse.json({ error: 'Cart is empty' }, { status: 400 })
21 }
22
23 const lineItems = items.map((item: any) => ({
24 price_data: {
25 currency: 'usd',
26 product_data: {
27 name: item.products.name,
28 images: item.products.image_url ? [item.products.image_url] : [],
29 },
30 unit_amount: item.products.price_cents,
31 },
32 quantity: item.quantity,
33 }))
34
35 const session = await stripe.checkout.sessions.create({
36 payment_method_types: ['card'],
37 line_items: lineItems,
38 mode: 'payment',
39 success_url: `${req.nextUrl.origin}/order/success?session_id={CHECKOUT_SESSION_ID}`,
40 cancel_url: `${req.nextUrl.origin}/cart`,
41 metadata: { cart_id: cartId },
42 })
43
44 return NextResponse.json({ url: session.url })
45}

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.

app/api/webhooks/stripe/route.ts
1import { NextRequest, NextResponse } from 'next/server'
2import Stripe from 'stripe'
3import { createClient } from '@supabase/supabase-js'
4
5const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)
6const supabase = createClient(
7 process.env.NEXT_PUBLIC_SUPABASE_URL!,
8 process.env.SUPABASE_SERVICE_ROLE_KEY!
9)
10
11export async function POST(req: NextRequest) {
12 const rawBody = await req.text()
13 const sig = req.headers.get('stripe-signature')!
14
15 let event: Stripe.Event
16 try {
17 event = stripe.webhooks.constructEvent(
18 rawBody,
19 sig,
20 process.env.STRIPE_WEBHOOK_SECRET!
21 )
22 } catch {
23 return NextResponse.json({ error: 'Invalid signature' }, { status: 400 })
24 }
25
26 if (event.type === 'checkout.session.completed') {
27 const session = event.data.object as Stripe.Checkout.Session
28 const cartId = session.metadata?.cart_id
29
30 const { data: cartItems } = await supabase
31 .from('cart_items')
32 .select('product_id, quantity')
33 .eq('cart_id', cartId)
34
35 await supabase.from('orders').insert({
36 stripe_session_id: session.id,
37 total_cents: session.amount_total,
38 status: 'paid',
39 })
40
41 for (const item of cartItems ?? []) {
42 await supabase.rpc('decrement_stock', {
43 p_product_id: item.product_id,
44 p_quantity: item.quantity,
45 })
46 }
47
48 await supabase.from('cart_items').delete().eq('cart_id', cartId)
49 }
50
51 return NextResponse.json({ received: true })
52}

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

app/api/checkout/route.ts
1import { NextRequest, NextResponse } from 'next/server'
2import Stripe from 'stripe'
3import { createClient } from '@supabase/supabase-js'
4
5const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)
6const supabase = createClient(
7 process.env.NEXT_PUBLIC_SUPABASE_URL!,
8 process.env.SUPABASE_SERVICE_ROLE_KEY!
9)
10
11export async function POST(req: NextRequest) {
12 const { cartId } = await req.json()
13
14 const { data: items } = await supabase
15 .from('cart_items')
16 .select('quantity, products(name, price_cents, image_url)')
17 .eq('cart_id', cartId)
18
19 if (!items || items.length === 0) {
20 return NextResponse.json({ error: 'Cart is empty' }, { status: 400 })
21 }
22
23 const lineItems = items.map((item: any) => ({
24 price_data: {
25 currency: 'usd',
26 product_data: {
27 name: item.products.name,
28 images: item.products.image_url ? [item.products.image_url] : [],
29 },
30 unit_amount: item.products.price_cents,
31 },
32 quantity: item.quantity,
33 }))
34
35 const session = await stripe.checkout.sessions.create({
36 payment_method_types: ['card'],
37 line_items: lineItems,
38 mode: 'payment',
39 success_url: `${req.nextUrl.origin}/order/success?session_id={CHECKOUT_SESSION_ID}`,
40 cancel_url: `${req.nextUrl.origin}/cart`,
41 metadata: { cart_id: cartId },
42 })
43
44 return NextResponse.json({ url: session.url })
45}

Customization ideas

Add wishlist functionality

Let users save products to a wishlist stored in Supabase. Add a heart icon Button on product cards that toggles wishlist status, with a dedicated wishlist page.

Add coupon code support

Create a coupons table in Supabase and a form field in the cart. Apply percentage or fixed-amount discounts before creating the Stripe Checkout session using Stripe's built-in coupon support.

Add product recommendations

Show 'Customers also bought' suggestions on the cart page by querying orders that contain the same products and finding frequently co-purchased items.

Add guest checkout with email

Allow anonymous users to check out by collecting just their email at checkout. Store the email on the order for receipt delivery without requiring account creation.

Add abandoned cart recovery

Set up a Vercel Cron Job that finds carts with items but no order after 24 hours, and sends a reminder email via Resend with a link to resume checkout.

Common pitfalls

Pitfall: Using request.json() instead of request.text() in the Stripe webhook handler

How to avoid: 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.

Pitfall: Storing prices as floating-point dollars instead of integer cents

How to avoid: 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).

Pitfall: Not handling the anonymous-to-authenticated cart merge

How to avoid: 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.

Pitfall: Decrementing stock without checking availability first

How to avoid: 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

AI prompts to try

Copy these prompts to build this project faster.

ChatGPT Prompt

I'm building a shopping cart with Next.js App Router, Supabase, and Stripe. I need: 1) Persistent cart for anonymous users via session_id cookie, 2) Cart merge on login, 3) Stripe Checkout session creation from cart items, 4) Webhook handler for payment confirmation, 5) Atomic stock decrement. Help me design the schema and handle edge cases like concurrent purchases and cart expiration.

Build Prompt

Create a Server Action for shopping cart operations that handles both anonymous and authenticated users. The action should: 1) Check for a cart_session cookie for anonymous users, 2) Generate crypto.randomUUID() for new sessions, 3) Store the session_id cookie with httpOnly flag, 4) Upsert cart_items (increment quantity if product already in cart), 5) On login, merge anonymous cart into user cart by updating cart_items and deleting the anonymous cart. Use Supabase for all database operations.

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).

RapidDev

Talk to an Expert

Our team has built 600+ apps. Get personalized help with your project.

Book a free consultation

Need help building your app?

Our experts have built 600+ apps and can accelerate your development. Book a free consultation — no strings attached.

Book a free consultation

We put the rapid in RapidDev

Need a dedicated strategic tech and growth partner? Discover what RapidDev can do for your business! Book a call with our team to schedule a free, no-obligation consultation. We'll discuss your project and provide a custom quote at no cost.