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.
What you're building
The checkout flow is where revenue happens. A confusing or broken checkout means abandoned carts and lost sales. Founders need a clean, fast checkout with shipping, tax, coupons, and reliable payment processing.
V0 generates the multi-step form UI, Stripe integration, and order management from prompts. Stripe via Vercel Marketplace provides the payment infrastructure with auto-provisioned keys. Supabase via the Connect panel stores products, orders, and coupons.
The architecture uses a multi-step client component for the checkout form (cart review, shipping, payment), an API route that creates Stripe Checkout Sessions with line items from the cart, a webhook handler that creates the order after payment confirmation, and a Supabase RPC function for atomic stock reservation to prevent overselling.
Final result
A production-ready checkout flow with cart review, shipping address collection, coupon codes, Stripe Checkout payment, stock reservation, webhook-verified order creation, and a confirmation page.
Tech stack
Prerequisites
- 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
Build steps
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.
1// Paste this prompt into V0's AI chat:2// Build an e-commerce checkout with Supabase and Stripe. Create these tables:3// 1. products: id (uuid PK), name (text), description (text), price (numeric), images (text[]), stock (int), is_active (boolean default true)4// 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)5// 3. order_items: id (uuid PK), order_id (uuid FK), product_id (uuid FK), quantity (int), unit_price (numeric)6// 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)7// 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.
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.
1// Paste this prompt into V0's AI chat:2// Build a multi-step checkout at app/checkout/page.tsx ('use client').3// Requirements:4// - Step 1 (Cart Review): Show cart items in Cards with image, name, quantity selector, unit price, line total. Remove Button. Subtotal at bottom.5// - 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.6// - 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.7// - Stepper pattern: horizontal steps indicator showing current step (1, 2, 3) with Progress bar8// - Continue and Back Buttons for navigation between steps9// - Store cart state in React state (loaded from props or context)10// - On "Pay with Stripe", POST to /api/checkout with cart items, shipping address, and coupon code11// - Use shadcn/ui Card, Input, Button, Separator, Badge, Select for quantityExpected 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.
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.
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(process.env.SUPABASE_URL!, process.env.SUPABASE_SERVICE_ROLE_KEY!)78export async function POST(req: NextRequest) {9 const { items, shippingAddress, couponCode, userId } = await req.json()1011 // Reserve stock atomically12 for (const item of items) {13 const { error } = await supabase.rpc('reserve_stock', {14 p_product_id: item.productId,15 p_quantity: item.quantity,16 })17 if (error) {18 return NextResponse.json({ error: `${item.name} is out of stock` }, { status: 409 })19 }20 }2122 const lineItems = items.map((item: { name: string; price: number; quantity: number }) => ({23 price_data: {24 currency: 'usd',25 product_data: { name: item.name },26 unit_amount: Math.round(item.price * 100),27 },28 quantity: item.quantity,29 }))3031 const session = await stripe.checkout.sessions.create({32 mode: 'payment',33 line_items: lineItems,34 success_url: `${req.nextUrl.origin}/orders/confirmation?session_id={CHECKOUT_SESSION_ID}`,35 cancel_url: `${req.nextUrl.origin}/checkout`,36 metadata: {37 user_id: userId,38 shipping_address: JSON.stringify(shippingAddress),39 items: JSON.stringify(items),40 coupon_code: couponCode || '',41 },42 })4344 return NextResponse.json({ url: session.url })45}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.
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.
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(process.env.SUPABASE_URL!, process.env.SUPABASE_SERVICE_ROLE_KEY!)78export async function POST(req: NextRequest) {9 const body = await req.text()10 const sig = req.headers.get('stripe-signature')!1112 let event: Stripe.Event13 try {14 event = stripe.webhooks.constructEvent(body, sig, process.env.STRIPE_WEBHOOK_SECRET!)15 } catch {16 return NextResponse.json({ error: 'Invalid signature' }, { status: 400 })17 }1819 if (event.type === 'checkout.session.completed') {20 const session = event.data.object as Stripe.Checkout.Session21 const meta = session.metadata!22 const items = JSON.parse(meta.items)23 const total = (session.amount_total ?? 0) / 1002425 const { data: order } = await supabase.from('orders').insert({26 user_id: meta.user_id,27 status: 'paid',28 subtotal: total,29 tax: 0,30 total,31 shipping_address: JSON.parse(meta.shipping_address),32 stripe_checkout_session_id: session.id,33 }).select().single()3435 if (order) {36 await supabase.from('order_items').insert(37 items.map((item: { productId: string; quantity: number; price: number }) => ({38 order_id: order.id,39 product_id: item.productId,40 quantity: item.quantity,41 unit_price: item.price,42 }))43 )44 }45 }4647 return NextResponse.json({ received: true })48}Expected result: When payment completes, the webhook creates an order with all items and shipping details in Supabase.
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.
1'use server'23import { createClient } from '@supabase/supabase-js'45const supabase = createClient(6 process.env.SUPABASE_URL!,7 process.env.SUPABASE_SERVICE_ROLE_KEY!8)910export async function validateCoupon(code: string, subtotal: number) {11 const { data: coupon } = await supabase12 .from('coupons')13 .select('*')14 .eq('code', code.toUpperCase())15 .single()1617 if (!coupon) return { valid: false, error: 'Coupon not found' }18 if (coupon.expires_at && new Date(coupon.expires_at) < new Date())19 return { valid: false, error: 'Coupon expired' }20 if (coupon.max_uses && coupon.used_count >= coupon.max_uses)21 return { valid: false, error: 'Coupon usage limit reached' }22 if (coupon.min_order && subtotal < coupon.min_order)23 return { valid: false, error: `Minimum order $${coupon.min_order}` }2425 const discount = coupon.discount_type === 'percentage'26 ? subtotal * (coupon.discount_value / 100)27 : coupon.discount_value2829 return { valid: true, discount: Math.min(discount, subtotal), coupon }30}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
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.SUPABASE_URL!,8 process.env.SUPABASE_SERVICE_ROLE_KEY!9)1011export async function POST(req: NextRequest) {12 const { items, shippingAddress, userId } = await req.json()1314 for (const item of items) {15 const { error } = await supabase.rpc('reserve_stock', {16 p_product_id: item.productId,17 p_quantity: item.quantity,18 })19 if (error) {20 return NextResponse.json(21 { error: `${item.name} is out of stock` },22 { status: 409 }23 )24 }25 }2627 const lineItems = items.map(28 (item: { name: string; price: number; quantity: number }) => ({29 price_data: {30 currency: 'usd',31 product_data: { name: item.name },32 unit_amount: Math.round(item.price * 100),33 },34 quantity: item.quantity,35 })36 )3738 const session = await stripe.checkout.sessions.create({39 mode: 'payment',40 line_items: lineItems,41 success_url: `${req.nextUrl.origin}/orders/confirmation?session_id={CHECKOUT_SESSION_ID}`,42 cancel_url: `${req.nextUrl.origin}/checkout`,43 metadata: {44 user_id: userId,45 shipping_address: JSON.stringify(shippingAddress),46 items: JSON.stringify(items),47 },48 })4950 return NextResponse.json({ url: session.url })51}Customization ideas
Add guest checkout
Allow checkout without login by collecting email in the shipping step and creating a guest record in Supabase. Send order confirmation and tracking link to the email.
Add address autocomplete
Integrate the Google Places API for address autocomplete in the shipping step, proxied through an API route to keep the API key server-side.
Add order tracking
Add a tracking_number column to orders and a status timeline on the order detail page showing processing, shipped, in-transit, and delivered stages.
Add saved addresses
Store shipping addresses in a user_addresses table so returning customers can select a saved address instead of re-entering it.
Common pitfalls
Pitfall: Not reserving stock before creating the Stripe Checkout Session
How to avoid: 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.
Pitfall: Using request.json() for the Stripe webhook body
How to avoid: Always use request.text() for the webhook body. Pass the raw string to stripe.webhooks.constructEvent().
Pitfall: Not handling abandoned checkout sessions
How to avoid: 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
AI prompts to try
Copy these prompts to build this project faster.
I'm building a multi-step checkout flow with Next.js App Router, Stripe, and Supabase. I need cart review, shipping form, coupon validation, Stripe Checkout, webhook order creation, and stock reservation. Help me design the stock reservation RPC function and the abandoned checkout recovery flow.
Build a stock reservation Supabase RPC function that takes product_id and quantity as parameters. It should: lock the product row with SELECT FOR UPDATE, check that stock >= quantity, decrement stock by quantity, and return success. If stock is insufficient, raise an exception. The function must be called within the checkout API route before creating the Stripe session.
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.
Talk to an Expert
Our team has built 600+ apps. Get personalized help with your project.
Book a free consultation