Skip to main content
RapidDev - Software Development Agency

How to Build Checkout flow with V0

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'll build

  • Multi-step checkout form with cart review, shipping address, and payment using a Stepper pattern
  • Stripe Checkout Session creation with dynamic line items, tax calculation, and coupon codes
  • Webhook handler for checkout.session.completed that creates orders and decrements stock
  • Stock reservation system using Supabase RPC to prevent overselling during checkout
  • Coupon validation Server Action with discount types (percentage, fixed) and usage limits
  • Order confirmation page with order details, tracking, and receipt download
Book a free consultation
4.9Clutch rating
600+Happy partners
17+Countries served
190+Team members
Intermediate10 min read1-2 hoursV0 Premium or higherApril 2026RapidDev Engineering Team
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.

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

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

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

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.

prompt.txt
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.

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.

prompt.txt
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 bar
8// - Continue and Back Buttons for navigation between steps
9// - 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 code
11// - 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.

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(process.env.SUPABASE_URL!, process.env.SUPABASE_SERVICE_ROLE_KEY!)
7
8export async function POST(req: NextRequest) {
9 const { items, shippingAddress, couponCode, userId } = await req.json()
10
11 // Reserve stock atomically
12 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 }
21
22 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 }))
30
31 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 })
43
44 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.

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.

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(process.env.SUPABASE_URL!, process.env.SUPABASE_SERVICE_ROLE_KEY!)
7
8export async function POST(req: NextRequest) {
9 const body = await req.text()
10 const sig = req.headers.get('stripe-signature')!
11
12 let event: Stripe.Event
13 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 }
18
19 if (event.type === 'checkout.session.completed') {
20 const session = event.data.object as Stripe.Checkout.Session
21 const meta = session.metadata!
22 const items = JSON.parse(meta.items)
23 const total = (session.amount_total ?? 0) / 100
24
25 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()
34
35 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 }
46
47 return NextResponse.json({ received: true })
48}

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.

app/actions/checkout.ts
1'use server'
2
3import { createClient } from '@supabase/supabase-js'
4
5const supabase = createClient(
6 process.env.SUPABASE_URL!,
7 process.env.SUPABASE_SERVICE_ROLE_KEY!
8)
9
10export async function validateCoupon(code: string, subtotal: number) {
11 const { data: coupon } = await supabase
12 .from('coupons')
13 .select('*')
14 .eq('code', code.toUpperCase())
15 .single()
16
17 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}` }
24
25 const discount = coupon.discount_type === 'percentage'
26 ? subtotal * (coupon.discount_value / 100)
27 : coupon.discount_value
28
29 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

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.SUPABASE_URL!,
8 process.env.SUPABASE_SERVICE_ROLE_KEY!
9)
10
11export async function POST(req: NextRequest) {
12 const { items, shippingAddress, userId } = await req.json()
13
14 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 }
26
27 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 )
37
38 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 })
49
50 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.

ChatGPT Prompt

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 Prompt

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.

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.