Checkout Flow
The checkout is organized into distinct steps:Checkout Page Structure
Checkout is isolated in its own route group:app/(storefront)/[countryCode]/(checkout)/
└── checkout/
└── page.tsx
// 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