Catalog, cart and checkout
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
| Surface | Composable | BFF route |
|---|---|---|
| Product list | useProducts | GET /api/products |
| Product detail | useProduct | GET /api/product/{id} |
| Facet metadata | useFacetMeta | GET /api/products/facet-meta |
| Categories | useCategories | GET /api/categories |
| Category listing | useCategoryListing | GET /api/categories/{slug} |
| Price and stock | useOffers | POST /api/offers |
| Markets and currency | useMarkets, useDisplayCurrency | GET /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:
<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
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.
useOffersresolves 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
| Surface | Composable | BFF route |
|---|---|---|
| Cart state | useCartStore | GET /api/cart |
| Add, update, remove | useCartActions | POST /api/carts/{id}/items |
| Recalculation | useCartCalculation | POST /api/cart/calculate |
| Line selection | useCartSelection | — (client state) |
| Sync | — | POST /api/cart/sync |
| Export | — | POST /api/cart/export |
| Multiple carts | — | GET/POST /api/carts, POST /api/carts/{id}/activate |
| Empty the cart | — | DELETE /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:
Step 1: Cart review
↓
Step 2: Address and delivery
↓
Step 3: Payment
↓
Step 4: Confirmation
| Surface | Composable | BFF route |
|---|---|---|
| One-page checkout state | useCheckoutOnePage | POST /api/checkout/session |
| Step form schemas | useCheckoutSchema | GET /api/checkout/schema/{key} |
| Prefill from the buyer | useCheckoutProfileSource | GET /api/checkout/profile |
| Payment methods | usePspCheckout | POST /api/payment/methods, GET /api/payment/psp-config |
| Shipping rates | — | POST /api/shipping/rates |
| Place the order | — | POST /api/orders |
| Confirmation | useCheckoutConfirmation | — (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:
[
{
"$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:
{
"$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:
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
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-confighands the browser the provider configuration it needs — public values only.POST /api/payment/methodsresolves which methods are available for this buyer, market and cart.- Your server receives a token, and
POST /api/ordersplaces 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:
| Requirement | Where it lands |
|---|---|
| Cost-center routing | A schema field, plus the account cost-center surfaces |
| Approval workflows | The checkout_workflow step and the account approvals blocks |
| Requisitions | POST /api/requisitions, plus the cart and account requisition blocks |
| Contract pricing | The price service — already per-buyer, nothing to build |
| ERP-validated SKUs | A server route of your own, called from the address or summary step |
| Net-30 and invoice terms | A payment method, resolved through /api/payment/methods |
| Multi-shipment addresses | A schema change plus a delivery-step override |
| Order notes and references | The 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.
| Failure | What the buyer should see | What should happen |
|---|---|---|
| A line item is no longer available in that quantity | Which item, and how many are left | Let them adjust the quantity in place |
| The delivery address isn't covered | Which postcode and country | Return 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 payment | A message with a support contact | Reverse 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.