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_codes table
  • 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 orders table 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_id to 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.