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
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
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.
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.
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.
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'78export default async function ProductsPage() {9 const supabase = await createClient()10 const { data: products } = await supabase11 .from('products')12 .select('*')13 .eq('is_active', true)14 .order('name')1516 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 <Image25 src={product.image_url}26 alt={product.name}27 fill28 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.
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.
1'use server'23import { cookies } from 'next/headers'4import { createClient } from '@/lib/supabase/server'5import { revalidatePath } from 'next/cache'67async 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')?.value1213 if (user) {14 const { data: cart } = await supabase15 .from('carts')16 .select('id')17 .eq('user_id', user.id)18 .single()19 if (cart) return cart.id20 }2122 if (sessionId) {23 const { data: cart } = await supabase24 .from('carts')25 .select('id')26 .eq('session_id', sessionId)27 .single()28 if (cart) return cart.id29 }3031 sessionId = crypto.randomUUID()32 cookieStore.set('cart_session', sessionId, { httpOnly: true, maxAge: 60 * 60 * 24 * 30 })3334 const { data: newCart } = await supabase35 .from('carts')36 .insert({ user_id: user?.id, session_id: sessionId })37 .select('id')38 .single()3940 return newCart!.id41}4243export async function addToCart(formData: FormData) {44 const productId = formData.get('productId') as string45 const cartId = await getOrCreateCart()46 const supabase = await createClient()4748 const { data: existing } = await supabase49 .from('cart_items')50 .select('id, quantity')51 .eq('cart_id', cartId)52 .eq('product_id', productId)53 .single()5455 if (existing) {56 await supabase57 .from('cart_items')58 .update({ quantity: existing.quantity + 1 })59 .eq('id', existing.id)60 } else {61 await supabase62 .from('cart_items')63 .insert({ cart_id: cartId, product_id: productId, quantity: 1 })64 }6566 revalidatePath('/')67}6869export 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}7879export 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.
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.
1import { NextRequest, NextResponse } from 'next/server'2import Stripe from 'stripe'3import { createClient } from '@supabase/supabase-js'45const 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)1011export async function POST(req: NextRequest) {12 const { cartId } = await req.json()1314 const { data: items } = await supabase15 .from('cart_items')16 .select('quantity, products(name, price_cents, image_url)')17 .eq('cart_id', cartId)1819 if (!items || items.length === 0) {20 return NextResponse.json({ error: 'Cart is empty' }, { status: 400 })21 }2223 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 }))3435 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 })4344 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.
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.
1import { NextRequest, NextResponse } from 'next/server'2import Stripe from 'stripe'3import { createClient } from '@supabase/supabase-js'45const 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)1011export async function POST(req: NextRequest) {12 const rawBody = await req.text()13 const sig = req.headers.get('stripe-signature')!1415 let event: Stripe.Event16 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 }2526 if (event.type === 'checkout.session.completed') {27 const session = event.data.object as Stripe.Checkout.Session28 const cartId = session.metadata?.cart_id2930 const { data: cartItems } = await supabase31 .from('cart_items')32 .select('product_id, quantity')33 .eq('cart_id', cartId)3435 await supabase.from('orders').insert({36 stripe_session_id: session.id,37 total_cents: session.amount_total,38 status: 'paid',39 })4041 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 }4748 await supabase.from('cart_items').delete().eq('cart_id', cartId)49 }5051 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
1import { NextRequest, NextResponse } from 'next/server'2import Stripe from 'stripe'3import { createClient } from '@supabase/supabase-js'45const 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)1011export async function POST(req: NextRequest) {12 const { cartId } = await req.json()1314 const { data: items } = await supabase15 .from('cart_items')16 .select('quantity, products(name, price_cents, image_url)')17 .eq('cart_id', cartId)1819 if (!items || items.length === 0) {20 return NextResponse.json({ error: 'Cart is empty' }, { status: 400 })21 }2223 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 }))3435 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 })4344 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.
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.
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).
Talk to an Expert
Our team has built 600+ apps. Get personalized help with your project.
Book a free consultation