Catalog, cart and checkout

The commerce surfaces a storefront renders — which composables and /api routes back each one, the schema-driven checkout, the PCI-safe payment pattern, and the gaps in the live catalog index.

Catalog, cart and checkout are the three surfaces every storefront project touches. The base layer ships all three end to end; this page maps what backs each one, so you know where to hook in.

Every route below is on your own origin. None of them is a gateway URL — see The BFF pattern.

Catalog

SurfaceComposableBFF route
Product listuseProductsGET /api/products
Product detailuseProductGET /api/product/{id}
Facet metadatauseFacetMetaGET /api/products/facet-meta
CategoriesuseCategoriesGET /api/categories
Category listinguseCategoryListingGET /api/categories/{slug}
Price and stockuseOffersPOST /api/offers
Markets and currencyuseMarkets, useDisplayCurrencyGET /api/markets

The split between the first rows and useOffers is the most important design decision in the catalog path. The catalog is cheap and cacheable; the offer is buyer-specific and slow. So the catalog renders during SSR and price and stock resolve afterwards, batched once per page:

in a product grid block
<script setup lang="ts">
// Catalog shell — fetched during SSR, in the first byte. No price or stock here.
const { products, status, error } = useProducts(category)

const { loadOffers } = useOffers()

// One batched offer request for the visible products, after they're known.
watch(visible, (list) => {
  if (list.length) void loadOffers(list)
}, { immediate: true })
</script>

Never fetch price inside the catalog response just because it's convenient — it re-welds the boundary that keeps the catalog cacheable, and one slow price lookup then blocks the whole grid. The full implementation, including the skeleton-by-default price widget, is in Customize blocks.

What the live catalog index does not carry yet

When productService is set to api, product reads resolve against the platform's search index, exposed publicly at /v1/search/*, with the category tree from /v1/products/categories. That index does not yet carry prices, stocks, or images. The layer's mapping fills those contracts with explicit neutral defaults, so every surface renders without special-casing — but a live product card will show a placeholder image and no price until the index does.

What this means for a project plan:

  • Don't demo live catalog data as final. Names, SKUs, descriptions, manufacturers, EANs, categories and attributes come through; the visual and commercial layer does not.
  • Price is a separate axis anyway. useOffers resolves prices through the price service, not through the catalog index, so contract pricing works independently of this gap.
  • Media has its own surface. Product imagery lives in Storage and can be wired up without waiting for the index.

Design your blocks so a missing price renders "price on request" and a missing image renders the placeholder. That's correct behaviour on a live tenant today and it stays correct when the index fills in.

Cart

SurfaceComposableBFF route
Cart stateuseCartStoreGET /api/cart
Add, update, removeuseCartActionsPOST /api/carts/{id}/items
RecalculationuseCartCalculationPOST /api/cart/calculate
Line selectionuseCartSelection— (client state)
SyncPOST /api/cart/sync
ExportPOST /api/cart/export
Multiple cartsGET/POST /api/carts, POST /api/carts/{id}/activate
Empty the cartDELETE /api/cart

Two things to know before you touch cart code.

Recalculation is orchestrated once per page, debounced. The cart shell owns it; the widgets read the result. If every widget recalculated, a cart page would fire five identical requests. See Shells and fields.

Multi-cart is live-mode only. Saved and named carts — a normal B2B requirement — come from the platform's carts service, so they exist when cartService is api and not when it's mock. Build against the single-cart path first and don't be surprised when the cart switcher is absent in mock mode.

Cart state is shared through a store plus useState-backed composables, which is why widgets can be reordered freely: none of them depends on being a particular child of a particular parent.

Checkout

The default flow is four steps:

text
Step 1: Cart review
  ↓
Step 2: Address and delivery
  ↓
Step 3: Payment
  ↓
Step 4: Confirmation
SurfaceComposableBFF route
One-page checkout stateuseCheckoutOnePagePOST /api/checkout/session
Step form schemasuseCheckoutSchemaGET /api/checkout/schema/{key}
Prefill from the buyeruseCheckoutProfileSourceGET /api/checkout/profile
Payment methodsusePspCheckoutPOST /api/payment/methods, GET /api/payment/psp-config
Shipping ratesPOST /api/shipping/rates
Place the orderPOST /api/orders
ConfirmationuseCheckoutConfirmation— (reads the placed order)

It is schema-driven

This is the lever that lets you customize per customer without forking. Each step's form is a JSON schema — fields, validation, conditional visibility — served by your BFF and rendered by FormKit. Adding a cost-center field to one customer's checkout is a schema change, not a component change.

A FormKit schema is an array of nodes. Each names its input type with $formkit, its value key with name, and its rules with validation:

a checkout address schema
[
  {
    "$formkit": "text",
    "name": "companyName",
    "label": "fields.companyName",
    "validation": "required",
    "autocomplete": "organization"
  },
  {
    "$formkit": "text",
    "name": "street",
    "label": "fields.street",
    "validation": "required",
    "autocomplete": "address-line1"
  },
  {
    "$formkit": "text",
    "name": "postalCode",
    "label": "fields.postalCode",
    "validation": "required|matches:/^[0-9]+$/|length:4,10",
    "validationMessages": {
      "matches": "validation.postalCodeFormat",
      "length": "validation.postalCodeFormat"
    },
    "autocomplete": "postal-code",
    "inputmode": "numeric"
  },
  {
    "$formkit": "select",
    "name": "country",
    "label": "fields.country",
    "validation": "required",
    "value": "DE",
    "options": ["DE", "AT", "CH", "NL", "FR", "IT", "ES"],
    "autocomplete": "country"
  }
]

label and validationMessages hold translation keys, not literal strings — the BFF resolves them to the buyer's locale before serving the schema. That keeps one schema per step instead of one per language.

Conditional fields use a schema expression against another field's value:

JSON
{
  "$formkit": "text",
  "name": "cardholderName",
  "label": "fields.cardholderName",
  "validation": "required",
  "if": "$get(paymentMethod).value === 'card'"
}

Which schema is served for a step is resolved through the schemaService key, so a customer-specific schema set is a configuration change. See Service modes.

Validation happens at two levels

FormKit enforces what the schema declares — required fields, patterns like a postcode matching its country's format. But some checks only the server can make: whether you actually ship to the entered address, whether a SKU still validates against the customer's ERP, whether the buyer's approval limit covers the order.

At the form level, get the node, wait for it to settle, and check validity. Calling submit() on an invalid form is what makes every field's message visible — the behaviour a buyer expects when they press Continue:

TypeScript
import { getNode } from '@formkit/core'

async function submitStep(formId: string) {
  const node = getNode(formId)
  if (!node) return null

  await node.settled
  if (!node.context?.state.valid) {
    node.submit() // surfaces all field messages
    return null
  }

  return node.value as Record<string, unknown>
}

Returning null rather than throwing keeps the buyer's entered data intact — the step simply doesn't advance.

The payment step

Raw card data must never reach your servers. The payment provider collects card details directly in the buyer's browser, in fields it renders itself and your page's JavaScript cannot read. Your server only ever receives a token. Keeping card data out of your infrastructure is what keeps your PCI scope at the simplest tier — and there is no configuration that makes handling it yourself acceptable.

That principle shapes how the step is split:

  • The schema collects the choice of payment method, and any non-sensitive field like the cardholder name.
  • The provider's own component renders the card fields, isolated from your page.
  • GET /api/payment/psp-config hands the browser the provider configuration it needs — public values only.
  • POST /api/payment/methods resolves which methods are available for this buyer, market and cart.
  • Your server receives a token, and POST /api/orders places the order against it.

Invoice, SEPA and other non-card methods take the same route without the browser-side provider component, which is why the method choice lives in the schema rather than in code.

Confirmation

The final step records the order and shows the buyer a receipt: order number, date, the email the confirmation went to, the address, the items, the total, and a tracking number once one exists. useCheckoutConfirmation reads the placed order, and the confirmation surface is composed from blocks — confirmation_hero, confirmation_recap, confirmation_next_steps, confirmation_actions — so an editor can adjust the wording without a deploy.

Verify the payment server-side before creating the order. Never trust the client's word that a payment succeeded.

Customizing for a B2B order process

Most projects need something a B2C template doesn't have. Roughly in order of how often:

RequirementWhere it lands
Cost-center routingA schema field, plus the account cost-center surfaces
Approval workflowsThe checkout_workflow step and the account approvals blocks
RequisitionsPOST /api/requisitions, plus the cart and account requisition blocks
Contract pricingThe price service — already per-buyer, nothing to build
ERP-validated SKUsA server route of your own, called from the address or summary step
Net-30 and invoice termsA payment method, resolved through /api/payment/methods
Multi-shipment addressesA schema change plus a delivery-step override
Order notes and referencesThe checkout_notes block

Check the block catalog before you scope any of these as new work. Cost centers, requisitions, approvals, budgets and team management already exist as blocks.

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.

FailureWhat the buyer should seeWhat should happen
A line item is no longer available in that quantityWhich item, and how many are leftLet them adjust the quantity in place
The delivery address isn't coveredWhich postcode and countryReturn to the address form with the data intact
Payment declined"Please try another payment method"Return to payment, preserving everything else
The order couldn't be created after paymentA message with a support contactReverse the payment; never leave a charge without an order

The last row is the one to design for deliberately. A successful payment with no order is the worst state a checkout can reach, and it's the reason order creation is orchestrated server-side rather than from the browser.

Next steps

  • The BFF pattern — the rule every route above follows.
  • Service modes — flipping catalog, cart and checkout to live data one at a time.
  • Search — the listing and autocomplete surfaces.
  • Forms — the same FormKit machinery, for editor-built forms.
  • Customize blocks — the shell-first catalog pattern, implemented.
  • Block catalog — the cart, checkout and account blocks you inherit.
Was this page helpful?