Checkout
This page is for frontend developers customizing the buyer-side checkout. The default storefront ships with a 4-step flow (Cart → Address → Payment → Confirmation) that handles the common case. Your job, on most projects, is to adapt it to the customer's specific order process — typical B2B requirements include cost-center routing, contract-priced line items, ERP-validated SKUs, approval workflows, multi-shipment addresses, net-30 payment terms, and bespoke validation rules.
The checkout is schema-driven via Formily. This is the lever that lets you customize per customer without forking the storefront: each step's form is defined by a JSON schema (fields, validation, conditional visibility), so you can drop in customer-specific fields by editing config rather than rebuilding Vue components. The pages below walk through each step in detail.
The Payment step is the one most likely to bite you. We use Stripe Elements (or equivalent) to keep card data out of your backend entirely — see the Payment Processing section below for the PCI-safe pattern.
Checkout Flow: 4 Steps
Step 1: Cart Review
↓
Step 2: Shipping Address
↓
Step 3: Payment
↓
Step 4: Confirmation
Step 1: Cart Review
The first step exists to let the buyer confirm what they're about to order — the items, the quantities, and the running total — before they commit to anything. The page shows a product list with image, name, SKU, quantity, and price; the line-item totals broken into subtotal, tax, and a shipping estimate; controls to adjust quantities or remove items; and a button to continue to shipping.
The one bit of real logic here is a stock re-check: between adding an item and reaching checkout, inventory may have moved, so the server confirms each line is still available before letting the buyer proceed:
// Check inventory for each cart item
const cartItems = getCart(tenantId)
const inventory = await cockpit.checkInventory(cartItems)
// Validate all items are still in stock
for (const item of cartItems) {
if (inventory[item.id].available < item.quantity) {
throw new Error(`${item.name}: only ${inventory[item.id].available} left`)
}
}
The cart object the page renders is straightforward — the line items plus the computed totals:
{
"items": [
{
"product_id": "SKU-001",
"name": "Winter Jacket",
"quantity": 1,
"unit_price": 89.99,
"line_total": 89.99,
"image_url": "https://..."
}
],
"subtotal": 89.99,
"tax": 17.98,
"shipping": 10.00,
"total": 117.97
}
Step 2: Shipping Address
The second step collects where the order is going and checks that you can actually ship there. This is the first step driven by a Formily schema, so it's the clearest illustration of the pattern that makes the whole checkout customizable: the form's fields, validation rules, and dependencies are all declared as JSON rather than written as Vue, which is exactly what lets you add a customer-specific field — a cost center, a delivery note — without touching component code.
{
"type": "object",
"properties": {
"first_name": {
"type": "string",
"title": "First Name",
"pattern": "^[a-zA-Z\\s-]+$",
"minLength": 2,
"maxLength": 50,
"required": true,
"x-component": "Input",
"x-component-props": {
"placeholder": "John"
}
},
"last_name": {
"type": "string",
"title": "Last Name",
"required": true,
"x-component": "Input"
},
"street": {
"type": "string",
"title": "Street Address",
"required": true,
"x-component": "Input"
},
"city": {
"type": "string",
"title": "City",
"required": true,
"x-component": "Input"
},
"postal_code": {
"type": "string",
"title": "Postcode",
"required": true,
"x-component": "Input"
},
"country": {
"type": "string",
"title": "Country",
"required": true,
"x-component": "Select",
"x-component-props": {
"options": [
{ "label": "Switzerland", "value": "CH" },
{ "label": "Germany", "value": "DE" },
{ "label": "France", "value": "FR" }
]
}
}
},
"required": ["first_name", "last_name", "street", "city", "postal_code", "country"]
}
Validation happens at two levels. Formily enforces the simple rules from the schema itself — required fields, and patterns like a postcode matching its country's format. But coverage is something only the server knows, so an async check confirms you actually ship to the entered address before the buyer can continue:
// Server-side postcode validation
export default defineEventHandler(async (event) => {
const { postal_code, country } = await readBody(event)
const shipsCoverage = await cockpit.shipping.checkCoverage(country, postal_code)
if (!shipsCoverage) {
throw new Error(`We don't ship to ${postal_code}, ${country}`)
}
return { covered: true }
})
Once the address is valid, the buyer picks a shipping method — typically a free standard option alongside faster paid ones — and choosing one feeds back into the totals from Step 1:
Standard (5-7 business days) — Free
Expedited (2-3 business days) — €12.00
Express (Next day) — €25.00
Step 3: Payment
The third step collects payment and runs the transaction, and it's the step where getting the architecture right matters most. Buyers can pay by credit or debit card (via Stripe), PayPal, SEPA bank transfer, or — on mobile — Apple Pay and Google Pay. Whichever they choose, the same principle governs the design: card data must never touch your servers.
That shapes how the form is split. The Formily schema collects only the choice of payment method and the cardholder name; the actual card fields are rendered by a dedicated Stripe Elements component, iframe-isolated so the card data stays out of your page's JavaScript and form state entirely:
{
"type": "object",
"properties": {
"payment_method": {
"type": "string",
"enum": ["card", "paypal", "bank_transfer"],
"x-component": "RadioGroup"
},
"cardholder_name": {
"type": "string",
"title": "Cardholder Name",
"x-component": "Input",
"x-display": "hidden",
"x-reactions": {
"dependencies": ["payment_method"],
"fulfill": {
"state": {
"hidden": "{{$deps[0] !== 'card'}}"
}
}
}
}
}
}
The Stripe Elements component (card number, expiry, CVV inputs) is rendered separately in the Vue template — outside the Formily schema — so card data never enters your application's form state.
Payment Processing:
paymentMethodId or paymentIntent.Client-side (browser): Stripe Elements creates a payment method from the card data and returns a paymentMethodId:
// In your Vue component
const stripe = await loadStripe(STRIPE_PUBLIC_KEY)
const elements = stripe.elements()
const cardElement = elements.create('card')
cardElement.mount('#card-element')
const handleSubmit = async () => {
const { paymentMethod, error } = await stripe.createPaymentMethod({
type: 'card',
card: cardElement,
billing_details: { name: cardholderName.value, email: email.value }
})
if (error) {
showError(error.message)
return
}
// Send only the paymentMethod ID to your server — never raw card data
await $fetch('/api/checkout/pay', {
method: 'POST',
body: { paymentMethodId: paymentMethod.id }
})
}
Server-side: Receives only the paymentMethodId, never raw card data:
export default defineEventHandler(async (event) => {
const { paymentMethodId } = await readBody(event)
const tenantId = event.context.user?.tenant_id
const cartTotal = getCartTotal(tenantId)
try {
// Create and confirm payment intent with the tokenized payment method
const paymentIntent = await stripe.paymentIntents.create({
amount: Math.round(cartTotal * 100), // in cents
currency: 'eur',
payment_method: paymentMethodId,
confirm: true,
automatic_payment_methods: { enabled: true, allow_redirects: 'never' }
})
if (paymentIntent.status === 'requires_action') {
// 3D Secure required — client will handle it
return {
requires_action: true,
client_secret: paymentIntent.client_secret
}
}
if (paymentIntent.status !== 'succeeded') {
throw new Error(`Payment failed: ${paymentIntent.status}`)
}
return { success: true, paymentIntentId: paymentIntent.id }
} catch (error) {
return { success: false, error: error.message }
}
})
What makes this PCI-compliant is the path the card data takes and, more importantly, the path it doesn't. The card details go straight from the browser to Stripe and never pass through your servers; your backend only ever sees the tokenized paymentMethodId and paymentIntentId. Because Stripe Elements renders the fields in an isolated iframe, even your own page JavaScript can't read them. The practical reward for this discipline is that your PCI scope drops to SAQ A — the simplest compliance tier there is.
Step 4: Confirmation
The final step closes the loop: it records the order and shows the buyer that everything went through. The confirmation page is a receipt — the order number (e.g. ORD-2025-00123), the date and time, the email the confirmation was sent to, the shipping address, the items ordered, the total charged, an estimated delivery date, and a tracking number once one exists.
Behind that page, the server does the real work of turning a successful payment into an order. It re-verifies the payment with Stripe (never trusting the client's word for it), creates the order in the Orders app, clears the cart, and publishes an event so the confirmation email goes out:
export default defineEventHandler(async (event) => {
const { paymentIntentId } = await readBody(event)
const tenantId = event.context.user?.tenant_id
// Verify payment succeeded
const paymentIntent = await stripe.paymentIntents.retrieve(paymentIntentId)
if (paymentIntent.status !== 'succeeded') {
throw new Error('Payment was not processed successfully')
}
// Create order in Orders app
const order = await cockpit.orders.create({
tenant_id: tenantId,
customer_id: event.context.user?.id,
items: getCart(tenantId),
shipping_address: getCheckoutData(tenantId).address,
payment_id: paymentIntentId,
total: getCartTotal(tenantId),
status: 'confirmed'
})
// Clear cart
clearCart(tenantId)
// Send confirmation email
await cockpit.events.publish('order.confirmed', {
order_id: order.id,
customer_email: event.context.user?.email
})
return { order_id: order.id, confirmation_sent: true }
})
Form Validation
Formily handles field-level and form-level validation:
Field-Level Validation:
{
"email": {
"type": "string",
"format": "email",
"required": true,
"x-validator": [
{
"required": true,
"message": "Email is required"
},
{
"format": "email",
"message": "Invalid email format"
}
]
}
}
Form-Level Validation:
formily.setFormState(state => ({
...state,
validating: true
})
const errors = await formily.validate()
if (errors.length > 0) {
// Show error messages
return { success: false, errors }
}
Error Handling
Each step has its own failure modes, and the rule throughout is the same: tell the buyer what went wrong in plain language, and never lose the data they've already entered. A few representative cases:
- Inventory Issue (Step 1)text
Error: "Winter Jacket (SKU-001): only 2 left, but you have 3 in cart" → Show message, allow customer to adjust quantity - Postcode Not Covered (Step 2)text
Error: "We don't currently ship to postcode 80000 in Italy" → Show message, allow customer to edit address - Payment Declined (Step 3)text
Error: "Card was declined. Please try another payment method." → Show message, return to payment form (data is preserved) - Order Creation Failed (Step 4)text
Error: "Order creation failed. Refund initiated." → Show message with customer service contact → Payment refunded automatically
Progress Persistence
All checkout data is persisted in local state (localStorage) so customers can:
- Close the tab and resume later
- Refresh the page without losing progress
- Go back to previous steps
// Save progress after each step
const saveCheckoutProgress = (step, data) => {
localStorage.setItem('checkout_progress', JSON.stringify({
step,
data,
timestamp: Date.now()
}))
}
// Resume on page load
const resumeCheckout = () => {
const saved = localStorage.getItem('checkout_progress')
if (saved && Date.now() - saved.timestamp < 24 * 60 * 60 * 1000) {
// Resume from saved step
return JSON.parse(saved)
}
}
Mobile Optimization
A large share of B2B buyers check out on a phone, so the default flow is built mobile-first: a single-column layout, touch targets big enough to hit comfortably, and the mobile-native payment options (Apple Pay, Google Pay) surfaced where they apply. Abandoned-cart SMS reminders round it out, nudging buyers who dropped off mid-checkout back to finish.
Analytics Integration
Checkout is where revenue is won or lost, so it's worth instrumenting closely. The metrics that tell you the most are the step completion rate (how many reach payment), the abandonment rate (how many start but don't finish), the time spent per step, the fields that throw errors most often, and which payment methods buyers actually choose. Tracking each step entry is enough to compute most of them:
// Track step progress
const trackCheckoutStep = (step) => {
analytics.track('checkout_step_entered', {
step,
timestamp: Date.now()
})
}
Next Steps
- Authentication — User login and registration
- Architecture — Storefront technical design
- Theming — Customizing checkout appearance