Back to examples

Example

Stripe checkout

Lumo's CheckoutForm is payment-agnostic: it validates the address, exposes a paymentSlot for any provider's UI, and calls onSubmit once the form is valid. Here is the canonical Stripe wiring, end to end.

01

Install and add keys

Add the Stripe SDKs and your test keys.

02

Create a PaymentIntent

A route returns a client secret for the order total.

03

Mount Stripe in the slot

Render <PaymentElement /> and confirm on submit.

04

Render the page

Fetch the client secret, then hand it to the component.

1. Install and add keys

Grab your keys from the Stripe dashboard in test mode.

Terminal
npm install @stripe/stripe-js @stripe/react-stripe-js stripe
.env.local
# .env.local
STRIPE_SECRET_KEY=sk_test_...
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_...

2. Create a PaymentIntent

The amount is integer minor units, exactly Lumo's Money.amount, so there is no conversion.

app/api/checkout/route.ts
// app/api/checkout/route.ts
import Stripe from "stripe"

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)

// POST /api/checkout -> { clientSecret }
export async function POST(req: Request) {
  const { amount, currency = "usd" } = await req.json()

  // amount is integer minor units, exactly Lumo's Money.amount.
  const intent = await stripe.paymentIntents.create({
    amount,
    currency,
    automatic_payment_methods: { enabled: true },
  })

  return Response.json({ clientSecret: intent.client_secret })
}

3. Mount Stripe in the payment slot

Wrap the form in <Elements>, drop <PaymentElement /> into the slot, and confirm payment from onSubmit. Throwing surfaces the message inside the form.

components/stripe-checkout.tsx
// components/stripe-checkout.tsx
"use client"

import { Elements, PaymentElement, useStripe, useElements } from "@stripe/react-stripe-js"
import { loadStripe } from "@stripe/stripe-js"
import { CheckoutForm } from "@/components/lumo/checkout-form"
import type { CartLine, Money } from "@/lib/lumo/types"

const stripePromise = loadStripe(process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY!)

interface Props {
  clientSecret: string
  lines: CartLine[]
  subtotal: Money
  total: Money
}

export function StripeCheckout({ clientSecret, ...order }: Props) {
  return (
    <Elements stripe={stripePromise} options={{ clientSecret, appearance: { theme: "flat" } }}>
      <Inner {...order} />
    </Elements>
  )
}

function Inner({ lines, subtotal, total }: Omit<Props, "clientSecret">) {
  const stripe = useStripe()
  const elements = useElements()

  return (
    <CheckoutForm
      lines={lines}
      subtotal={subtotal}
      total={total}
      // Stripe renders its own card inputs into the slot.
      paymentSlot={<PaymentElement />}
      // CheckoutForm validates the address first, then hands you the values.
      // Throw to show an error inside the form; resolve to let it finish.
      onSubmit={async (values) => {
        if (!stripe || !elements) throw new Error("Stripe is still loading.")

        const { error } = await stripe.confirmPayment({
          elements,
          confirmParams: {
            return_url: `${window.location.origin}/checkout/complete`,
            payment_method_data: {
              billing_details: {
                name: `${values.firstName} ${values.lastName}`,
                email: values.email,
                address: {
                  line1: values.line1,
                  line2: values.line2,
                  city: values.city,
                  state: values.region,
                  postal_code: values.postalCode,
                  country: values.country,
                },
              },
            },
          },
        })

        // On success Stripe redirects to return_url; we only reach here on error.
        if (error) throw new Error(error.message)
      }}
    />
  )
}

4. Render the page

Create the PaymentIntent on the server, then pass the client secret down.

app/checkout/page.tsx
// app/checkout/page.tsx
import { headers } from "next/headers"
import { StripeCheckout } from "@/components/stripe-checkout"

// Replace with your real cart (e.g. from the cart-provider block).
const lines = [/* ... */]
const subtotal = { amount: 28998, currency: "USD" }
const total = { amount: 31318, currency: "USD" }

export default async function CheckoutPage() {
  const origin = (await headers()).get("origin") ?? ""

  // Create the PaymentIntent for the order total (integer minor units).
  const res = await fetch(`${origin}/api/checkout`, {
    method: "POST",
    body: JSON.stringify({ amount: total.amount, currency: total.currency.toLowerCase() }),
    cache: "no-store",
  })
  const { clientSecret } = await res.json()

  return <StripeCheckout clientSecret={clientSecret} lines={lines} subtotal={subtotal} total={total} />
}

Live demo

Test mode

The same CheckoutForm with a Stripe-styled payment slot. This demo is a visual stand-in, no keys and no charge. Place the order to reach the confirmation.

Contact

Shipping address

Payment

CardTest mode

Demo only, no card is charged.

Before you go live

  • Test with card 4242 4242 4242 4242, any future expiry, any CVC.
  • Fulfill orders from a Stripe webhook on payment_intent.succeeded, not from return_url, which the customer can skip.
  • Recompute the amount on the server from your own catalog. Never trust an amount sent by the client.