Checkout & Payments
Last updated on 2026-08-30
The kit uses Stripe Checkout Sessions (redirect mode) for payments. This is PCI-compliant out of the box and supports Apple Pay, Google Pay, and all major credit cards without any additional setup.
Checkout Flow
1. Customer fills cart
2. Customer enters shipping info (/checkout)
3. Customer selects shipping method (/checkout/shipping)
4. Customer clicks "Pay with Stripe" (/checkout/payment)
5. Server creates Stripe Checkout Session (API route)
6. Customer is redirected to Stripe's hosted checkout page
7. Customer completes payment on Stripe
8. Stripe fires checkout.session.completed webhook
9. Webhook handler creates order in Supabase
10. Customer is redirected to /order-confirmation?session_id=xxx
11. Confirmation page fetches order from Supabase by session ID
Checkout Pages
Step 1: Information (/checkout)
- Contact email (auto-filled for logged-in users)
- Shipping address form with validation
- Guest checkout option (no account required)
- Order summary sidebar
Step 2: Shipping (/checkout/shipping)
- Shipping method selection (standard, express, overnight)
- Pricing based on order subtotal (free shipping over $100)
- Estimated delivery dates
Step 3: Payment (/checkout/payment)
- "Pay with Stripe" button that triggers the redirect
- Shows order total with tax and shipping
- Security trust signals (SSL, PCI DSS)
- Error handling with user-friendly messages
API Route: Session Creation
app/api/checkout/route.ts creates the Stripe Checkout Session:
const session = await stripe.checkout.sessions.create({
mode: "payment",
customer_email: customerEmail,
line_items: lineItems, // Built from cart items
shipping_options: [...], // Based on shipping method
metadata: {
user_id: user?.id || "",
shipping_address: JSON.stringify(shipping_address),
billing_address: JSON.stringify(billing_address),
shipping_method: "standard",
cart_items: JSON.stringify(items),
},
success_url: `${appUrl}/order-confirmation?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${appUrl}/checkout/payment`,
})
Key details:
- Line items are built from cart data with product name, image, price, and quantity
- Shipping cost is calculated server-side based on subtotal and method
- Tax is estimated at 8% (replace with your tax calculation)
- Discount codes are validated against the
discount_codestable - Metadata carries all order data needed by the webhook
Webhook Handler
app/api/webhooks/stripe/route.ts handles the checkout.session.completed event:
// 1. Verify the webhook signature
const event = stripe.webhooks.constructEvent(body, sig, webhookSecret)
// 2. Extract order data from session metadata
const session = event.data.object
const metadata = session.metadata
// 3. Create the order in Supabase
const { data: order } = await supabaseAdmin
.from("orders")
.insert({
order_number: generateOrderNumber(),
user_id: metadata.user_id || null,
email: metadata.email,
status: "pending",
payment_status: "paid",
stripe_checkout_session_id: session.id,
stripe_payment_intent_id: session.payment_intent,
subtotal: session.amount_subtotal / 100,
total: session.amount_total / 100,
// ... shipping, tax, discount from metadata
})
// 4. Create order items
const cartItems = JSON.parse(metadata.cart_items)
for (const item of cartItems) {
await supabaseAdmin.from("order_items").insert({
order_id: order.id,
product_id: item.product_id,
name: item.name,
quantity: item.quantity,
price: item.price,
})
}
// 5. Clear the user's cart
if (metadata.user_id) {
await supabaseAdmin.from("cart_items")
.delete()
.eq("user_id", metadata.user_id)
}
Important: The webhook uses the admin Supabase client (service role) because it runs outside any user session and needs to insert into the
orderstable without RLS restrictions.
Order Confirmation
/order-confirmation?session_id=xxx fetches the order from Supabase using the Stripe session ID:
const { data: order } = await supabase
.from("orders")
.select("*, order_items(*)")
.eq("stripe_checkout_session_id", sessionId)
.single()
Displays:
- Order number and date
- Items purchased with images
- Shipping and billing addresses
- Payment total breakdown
- Expected delivery estimate
Error Handling
- Server-side errors are logged but never exposed to customers. The API route returns generic messages.
- Stripe API key errors are caught and sanitized — customers see "Unable to start checkout" instead of raw error details.
- Webhook signature verification prevents tampering. Invalid signatures return 400.
- Idempotency — the webhook checks for existing orders by
stripe_checkout_session_idto avoid duplicates.
Discount Codes
Discount codes are validated during checkout:
const { data: discount } = await supabase
.from("discount_codes")
.select("*")
.eq("code", discount_code)
.eq("status", "active")
.single()
Supports percentage and fixed-amount discounts with optional minimum order amounts.
Customizing
Tax Calculation
Replace the flat 8% estimate with a real tax service:
// In app/api/checkout/route.ts
// Replace: const taxAmount = subtotal * 0.08
// With your tax calculation API (TaxJar, Avalara, etc.)
Shipping Rates
Replace fixed shipping costs with carrier rate calculations:
// Replace the hardcoded rates with your shipping API
// The shipping_methods table can be extended with carrier data
Currency
Change "usd" in the Stripe session creation to your currency code. Update price formatting throughout the app.