Skip to main content
The checkout process guides customers through completing their purchase with a clear, step-by-step flow that validates information and handles payment securely.

Checkout Flow

The checkout is organized into distinct steps:
1

Shipping Address

Customer enters delivery and billing address information
2

Delivery Method

Customer selects shipping method from available options
3

Payment

Customer chooses payment method and enters payment details
4

Review

Customer reviews order before final confirmation

Checkout Page Structure

Checkout is isolated in its own route group:
app/(storefront)/[countryCode]/(checkout)/
└── checkout/
    └── page.tsx
The checkout form component coordinates all steps:
// features/storefront/modules/checkout/templates/checkout-form/
export default async function CheckoutForm({ cart, customer }) {
  if (!cart) return null
  
  // Get available shipping and payment methods
  const availableShippingMethods = await getCartShippingOptions(cart.id)
  const availablePaymentMethods = await listCartPaymentMethods(cart.region.id)
  
  return (
    <div className="w-full grid grid-cols-1 gap-y-8">
      {/* Step 1: Addresses */}
      <div>
        <Addresses cart={cart} customer={customer} />
      </div>
      
      {/* Step 2: Shipping */}
      <div>
        <Shipping 
          cart={cart} 
          availableShippingMethods={availableShippingMethods} 
        />
      </div>
      
      {/* Step 3: Payment */}
      <div>
        <Payment 
          cart={cart} 
          availablePaymentMethods={availablePaymentMethods} 
        />
      </div>
      
      {/* Step 4: Review */}
      <div>
        <Review cart={cart} />
      </div>
    </div>
  )
}

Step Navigation

Steps are controlled via URL query parameters:
const searchParams = useSearchParams()
const router = useRouter()
const pathname = usePathname()

// Check if current step is active
const isOpen = searchParams?.get('step') === 'address'

// Navigate to next step
const goToNextStep = () => {
  router.push(pathname + '?step=delivery', { scroll: false })
}

Step 1: Shipping Address

Customers enter their shipping and billing addresses:
// features/storefront/modules/checkout/components/addresses/
const Addresses = ({ cart, customer }) => {
  const [sameAsBilling, setSameAsBilling] = useState(true)
  const [isLoading, setIsLoading] = useState(false)
  const [error, setError] = useState(null)
  
  const handleSubmit = async (event) => {
    event.preventDefault()
    setIsLoading(true)
    setError(null)
    
    const formData = new FormData(event.currentTarget)
    
    // Validate required fields
    if (!formData.get('shippingAddress.countryCode')) {
      setError('Please select a country.')
      setIsLoading(false)
      return
    }
    
    try {
      const result = await setAddresses(null, formData)
      
      if (result?.success) {
        // Redirect to next step
        router.push(`/${countryCode}/checkout?step=delivery`)
        return
      }
      
      if (typeof result === 'string') {
        setError(result)
      }
    } catch (err) {
      setError(err.message)
    } finally {
      setIsLoading(false)
    }
  }
  
  return (
    <form onSubmit={handleSubmit}>
      <ShippingAddress
        customer={customer}
        checked={sameAsBilling}
        onChange={() => setSameAsBilling(!sameAsBilling)}
        cart={cart}
      />
      
      {!sameAsBilling && <BillingAddress cart={cart} />}
      
      <Button type="submit" disabled={isLoading}>
        {isLoading && <Loader />}
        Continue to delivery
      </Button>
      
      <ErrorMessage error={error} />
    </form>
  )
}

Address Selection

Authenticated customers can select from saved addresses:
const ShippingAddress = ({ customer, cart }) => {
  const [selectedAddressId, setSelectedAddressId] = useState(
    cart?.shippingAddress?.id || null
  )
  
  return (
    <div>
      {customer?.addresses?.length > 0 && (
        <div className="mb-6">
          <h3>Saved Addresses</h3>
          <RadioGroup value={selectedAddressId} onValueChange={setSelectedAddressId}>
            {customer.addresses.map(address => (
              <div key={address.id}>
                <RadioGroupItem value={address.id} id={address.id} />
                <Label htmlFor={address.id}>
                  <div>
                    <p>{address.firstName} {address.lastName}</p>
                    <p>{address.address1}</p>
                    <p>{address.city}, {address.postalCode}</p>
                  </div>
                </Label>
              </div>
            ))}
          </RadioGroup>
        </div>
      )}
      
      {/* Address form fields */}
      <input type="hidden" name="selectedAddressId" value={selectedAddressId} />
      <Input name="shippingAddress.firstName" placeholder="First name" />
      <Input name="shippingAddress.lastName" placeholder="Last name" />
      <Input name="shippingAddress.address1" placeholder="Address" />
      <Input name="shippingAddress.city" placeholder="City" />
      <Input name="shippingAddress.postalCode" placeholder="Postal code" />
      <CountrySelect name="shippingAddress.countryCode" />
      <Input name="email" type="email" placeholder="Email" />
    </div>
  )
}

Setting Addresses

Address submission handles both new and existing addresses:
export async function setAddresses(currentState: any, formData: FormData) {
  const cartId = cookies().get('_openfront_cart_id')?.value
  if (!cartId) return { message: 'No cartId cookie found' }
  
  const selectedAddressId = formData.get('selectedAddressId')
  const hasModifiedFields = formData.get('hasModifiedFields') === 'true'
  const sameAsBilling = formData.get('same_as_billing') === 'on'
  const email = formData.get('email')
  
  const data = { email }
  
  // If using existing address without modifications
  if (selectedAddressId && !hasModifiedFields) {
    data.shippingAddress = { connect: { id: selectedAddressId } }
    
    if (sameAsBilling) {
      data.billingAddress = { connect: { id: selectedAddressId } }
    }
  } else {
    // Create new address from form data
    const shippingAddress = {
      firstName: formData.get('shippingAddress.firstName'),
      lastName: formData.get('shippingAddress.lastName'),
      address1: formData.get('shippingAddress.address1'),
      city: formData.get('shippingAddress.city'),
      postalCode: formData.get('shippingAddress.postalCode'),
      province: formData.get('shippingAddress.province'),
      phone: formData.get('shippingAddress.phone'),
      country: {
        connect: { iso2: formData.get('shippingAddress.countryCode') }
      }
    }
    
    // Create address in database
    const { createAddress: newShippingAddress } = await openfrontClient.request(
      CREATE_ADDRESS_MUTATION,
      { data: shippingAddress }
    )
    
    data.shippingAddress = { connect: { id: newShippingAddress.id } }
    
    if (sameAsBilling) {
      data.billingAddress = { connect: { id: newShippingAddress.id } }
    } else {
      // Create separate billing address
      const billingAddress = { /* ... */ }
      const { createAddress: newBillingAddress } = await openfrontClient.request(
        CREATE_ADDRESS_MUTATION,
        { data: billingAddress }
      )
      data.billingAddress = { connect: { id: newBillingAddress.id } }
    }
  }
  
  // Update cart with addresses
  await openfrontClient.request(UPDATE_CART_MUTATION, { cartId, data })
  
  revalidateTag('cart')
  return { success: true, countryCode: formData.get('shippingAddress.countryCode') }
}

Step 2: Shipping Method

Customers select their preferred shipping method:
// features/storefront/modules/checkout/components/shipping/
const Shipping = ({ cart, availableShippingMethods }) => {
  const [selectedOption, setSelectedOption] = useState(
    cart?.shippingMethods?.[0]?.shippingOption?.id || null
  )
  const [isLoading, setIsLoading] = useState(false)
  
  const handleSubmit = async () => {
    if (!selectedOption) return
    
    setIsLoading(true)
    try {
      await setShippingMethod(selectedOption)
      router.push(pathname + '?step=payment', { scroll: false })
    } catch (err) {
      setError(err.message)
    } finally {
      setIsLoading(false)
    }
  }
  
  return (
    <div>
      <h2>Delivery</h2>
      
      <RadioGroup value={selectedOption} onValueChange={setSelectedOption}>
        {availableShippingMethods?.map(option => (
          <div key={option.id}>
            <RadioGroupItem value={option.id} id={option.id} />
            <Label htmlFor={option.id}>
              <div className="flex justify-between">
                <span>{option.name}</span>
                <span>{option.calculatedAmount}</span>
              </div>
            </Label>
          </div>
        ))}
      </RadioGroup>
      
      <Button onClick={handleSubmit} disabled={!selectedOption || isLoading}>
        {isLoading && <Loader />}
        Continue to payment
      </Button>
    </div>
  )
}

Setting Shipping Method

export async function setShippingMethod(shippingOptionId: string) {
  const cartId = cookies().get('_openfront_cart_id')?.value
  if (!cartId) throw new Error('No cartId cookie found')
  
  await openfrontClient.request(
    gql`mutation AddShippingMethod($cartId: ID!, $shippingMethodId: ID!) {
      addActiveCartShippingMethod(
        cartId: $cartId
        shippingMethodId: $shippingMethodId
      ) {
        id
        shippingMethods {
          id
          price
          shippingOption {
            id
            name
          }
        }
      }
    }`,
    { cartId, shippingMethodId: shippingOptionId }
  )
  
  revalidateTag('cart')
}

Step 3: Payment

Customers select and configure their payment method:
// features/storefront/modules/checkout/components/payment/
const Payment = ({ cart, availablePaymentMethods }) => {
  const [selectedPaymentMethod, setSelectedPaymentMethod] = useState(
    activeSession?.paymentProvider?.code ?? ''
  )
  const [cardComplete, setCardComplete] = useState(false)
  const [isLoading, setIsLoading] = useState(false)
  
  const isStripePayment = isStripe(selectedPaymentMethod)
  const stripeReady = useContext(StripeContext)
  
  const handleSubmit = async () => {
    setIsLoading(true)
    try {
      const shouldInputCard = isStripe(selectedPaymentMethod) && !cardComplete
      
      // Initiate payment session if not already selected
      const hasExistingSelectedSession = 
        cart.paymentCollection?.paymentSessions?.some(
          session => session.isSelected && 
                    session.paymentProvider.code === selectedPaymentMethod
        )
      
      if (!hasExistingSelectedSession) {
        await initiatePaymentSession(cart.id, selectedPaymentMethod)
      }
      
      if (!shouldInputCard) {
        router.push(pathname + '?step=review', { scroll: false })
      }
    } catch (err) {
      setError(err.message)
    } finally {
      setIsLoading(false)
    }
  }
  
  return (
    <div>
      <h2>Payment</h2>
      
      {/* Payment method selection */}
      <RadioGroup value={selectedPaymentMethod} onValueChange={setSelectedPaymentMethod}>
        {availablePaymentMethods.map(method => (
          <div key={method.id}>
            <RadioGroupItem value={method.code} id={method.id} />
            <Label htmlFor={method.id}>
              <div className="flex justify-between">
                <span>{paymentInfoMap[method.code]?.title || method.code}</span>
                <div>{paymentInfoMap[method.code]?.icon}</div>
              </div>
            </Label>
          </div>
        ))}
      </RadioGroup>
      
      {/* Stripe card input */}
      {isStripePayment && stripeReady && (
        <div className="mt-5">
          <p>Enter your card details:</p>
          <CardElement
            options={stripeOptions}
            onChange={(e) => {
              setCardComplete(e.complete)
              setError(e.error?.message || null)
            }}
          />
        </div>
      )}
      
      <Button onClick={handleSubmit} disabled={isLoading}>
        {isLoading && <Loader />}
        {isStripePayment && !cardComplete 
          ? 'Enter card details' 
          : 'Continue to review'
        }
      </Button>
    </div>
  )
}

Payment Providers

Supported payment methods are configured in the backend:
const paymentInfoMap = {
  stripe: {
    title: 'Credit Card',
    icon: <CreditCard />
  },
  'stripe-sandbox': {
    title: 'Credit Card (Test)',
    icon: <CreditCard />
  },
  paypal: {
    title: 'PayPal',
    icon: <PayPalIcon />
  },
  manual: {
    title: 'Manual Payment',
    icon: <BanknoteIcon />
  }
}

Step 4: Review & Place Order

Final review before order submission:
// features/storefront/modules/checkout/components/review/
const Review = ({ cart }) => {
  const [isLoading, setIsLoading] = useState(false)
  
  const handlePlaceOrder = async () => {
    setIsLoading(true)
    
    try {
      const result = await placeOrder(paymentSessionId)
      
      if (result?.success && result?.redirectTo) {
        router.push(result.redirectTo)
      }
    } catch (err) {
      setError(err.message)
    } finally {
      setIsLoading(false)
    }
  }
  
  return (
    <div>
      <h2>Review</h2>
      
      {/* Order summary */}
      <div className="space-y-4">
        {/* Cart items */}
        {cart.lineItems.map(item => (
          <div key={item.id} className="flex justify-between">
            <span>{item.title}</span>
            <span>{item.quantity}x {formatAmount(item.unitPrice)}</span>
          </div>
        ))}
        
        {/* Shipping */}
        <div className="flex justify-between">
          <span>Shipping</span>
          <span>{formatAmount(cart.shippingTotal)}</span>
        </div>
        
        {/* Tax */}
        <div className="flex justify-between">
          <span>Tax</span>
          <span>{formatAmount(cart.taxTotal)}</span>
        </div>
        
        {/* Total */}
        <div className="flex justify-between text-xl font-bold">
          <span>Total</span>
          <span>{formatAmount(cart.total, cart.region.currency.code)}</span>
        </div>
      </div>
      
      {/* Place order button */}
      <Button 
        onClick={handlePlaceOrder} 
        disabled={isLoading}
        className="w-full"
      >
        {isLoading && <Loader />}
        Place order
      </Button>
    </div>
  )
}

Placing the Order

export async function placeOrder(paymentSessionId?: string) {
  const cartId = cookies().get('_openfront_cart_id')?.value
  if (!cartId) throw new Error('No cartId cookie found')
  
  const { completeActiveCart } = await openfrontClient.request(
    gql`mutation CompleteActiveCart($cartId: ID!, $paymentSessionId: ID) {
      completeActiveCart(cartId: $cartId, paymentSessionId: $paymentSessionId)
    }`,
    { cartId, paymentSessionId }
  )
  
  if (completeActiveCart?.id) {
    // Remove cart cookie after successful order
    await removeCartId()
    revalidateTag('cart')
    
    const countryCode = completeActiveCart.shippingAddress?.country?.iso2
    const secretKeyParam = completeActiveCart.secretKey 
      ? `?secretKey=${completeActiveCart.secretKey}` 
      : ''
    
    return {
      success: true,
      redirectTo: `/${countryCode}/order/confirmed/${completeActiveCart.id}${secretKeyParam}`
    }
  }
  
  return completeActiveCart
}

Order Confirmation

After successful checkout, customers see their order confirmation:
const OrderConfirmedPage = async ({ params }) => {
  const { id, countryCode } = params
  const secretKey = searchParams?.get('secretKey')
  
  const order = await getOrder(id, secretKey)
  
  return (
    <div className="text-center py-12">
      <CheckCircle className="mx-auto h-16 w-16 text-green-500 mb-4" />
      <h1 className="text-3xl font-bold mb-2">Order Confirmed!</h1>
      <p className="text-muted-foreground mb-6">
        Your order #{order.displayId} has been placed
      </p>
      
      <div className="max-w-2xl mx-auto">
        <OrderSummary order={order} />
      </div>
      
      <Button onClick={() => router.push(`/${countryCode}`)}>Continue shopping</Button>
    </div>
  )
}

Next Steps

Customer Accounts

Learn how customers track orders and manage profiles

Payment Providers

Configure payment provider integrations

Build docs developers (and LLMs) love