Description
Production patterns for building customer-facing storefronts on commercetools - the full B2C and B2B domain feature set. Patterns are stated as decisions plus commercetools-exact code; framework-specific implementation. Assumes a server tier exists to host the BFF. Use for PDP, PLP, cart, checkout flow, customer login, search/facets, locale handling, and any B2B- or B2C-specific feature.
Installation
In any Claude Code session:
/plugin marketplace add commercetools/commercetools-ai-plugins
/plugin install commercetools@commercetools
If you've updated the plugin or installed it in another window and need the current session to pick up the latest version:
/reload-plugins
commercetools/commercetools-ai-plugins. Then, click on the plugin and click Install.Instructions Included
commercetools Storefront (framework-agnostic)
Production patterns for commercetools storefronts — covering the full range from the shared BFF foundation to B2C and B2B surface-specific features. The patterns here are framework-neutral: each is stated as a decision plus commercetools-exact code. Your frontend framework supplies the implementation primitives (file layout, render and routing primitives, the cookie read/write binding).
Architecture assumption — a server tier exists. These patterns assume your storefront runs on a stack with a server-side tier (SSR / server components / a standalone BFF service) that can hold secrets and proxy commercetools. The BFF and secret rules below are non-negotiable and depend on this. They are designed SSR-first. For a specific framework, use its stack adapter underreferences/stack/— e.g. the Next.js stack — which maps each concept here to that framework's primitives and owns all file-layout and render-primitive details.
Reference implementation. Where a code example shows a file path or a render primitive, it uses the Next.js App Router as the reference implementation. The framework-neutral rule is always in the surrounding prose; the adapter pins the exact primitive.
concept-mapping.md in your stack adapter for the exact primitives used.
Convention Meaning <server>/Your server-side code root; each stack pins the actual directory (Example Next.js: lib/)<api>/the client-facing API surface the browser calls and routes <server>/ct/*The commercetools helper modules <server>/ct/clientThe apiRootsingleton<server>/typesThe app type-mapping root (the boundary types) <server>/mappers/The commercetools→app mappers Client state Your framework's client-side state-management / cache layer (the specific library is a stack choice) — use it for mutable per-user data
Workflow
When this skill is invoked, always follow these steps:
-
Gather context (required, run first) — Always begin by gathering context for this skill. This is the mandatory grounding step: it gathers the latest verified documentation as context for you (the agent). Do not skip it, and do not replace it with another tool (such as an MCP documentation-search tool) — run this command:
node scripts/gather-context.mjs \ --query "<extract key terms from user's question>" \ --client-name "<current-client>" \ --model "<current-model>" \ --skill-name "commercetools-storefront" \ --limit 3Use its output as your primary grounding. You may additionally use other tools (such as the commercetools documentation MCP) for deeper, follow-up search. -
Combine with skill references — Cross-reference the analysis output with local references in
./references/for complete context. -
Provide implementation guidance — Synthesize the documentation with the specific integration mode the user is targeting.
Key Takeaways
<server>/ct/* directly. Cart, account, and user-specific data are loaded into client-side state management → server endpoint → commercetools SDK.<server>/ct/* is server-only. Never import it from a client component. Import types from the app type-mapping root (<server>/types).Decision Steps
Build a storefront in this order — each step maps to a section of the Reference Index below:
- Choose your stack (if exists). Pick the adapter under
references/stack/<name>/for your frontend. It owns file layout, render/routing primitives, the session mechanism, the client-state library, and deploy. - Implement the shared foundation. Wire the BFF boundary, sessions, commercetools client, type boundary, cart, and data-loading —
references/core/. - Add optional core features. Layer in cross-cutting extras (e.g. recurring prices/orders) as needed —
references/core/optional/. - Choose B2C or B2B. Load the matching surface for its domain features and rules —
references/b2c/orreferences/b2b/. - Add optional surface features. Layer in surface-specific extras .
Reference Index
Stacks
references/stack/<name>/ maps the framework-neutral patterns in this skill onto that stack's primitives and owns its file layout, render primitives, and deploy.| Stack | Reference | Commands |
|---|---|---|
| Next.js 16 (App Router) + next-intl v4 + Tailwind v4 | stack/nextjs/overview.md | /nextjs-setup-project, /nextjs-deploy-vercel, /nextjs-deploy-netlify, /nextjs-add-locale |
| Nuxt 4 (Vue, SSR) + Nitro + @nuxtjs/i18n v10 + nuxt-auth-utils + Pinia + Tailwind v4 | stack/nuxtjs/overview.md | /nuxtjs-setup-project |
Shared Foundation (references/core/)
| Task | Reference |
|---|---|
| Scaffold a new project (deps, styling, locale routing, folder structure) | Framework-specific - see the adapter's overview.md |
| commercetools SDK singleton, server-managed sessions, BFF boundary | core/ct-client.md |
| Shared auth patterns: commercetools login endpoint, server endpoint structure, client state hook, logout | core/customer-auth.md |
Add a new country / currency / locale — COUNTRY_CONFIG flat structure | core/add-country.md |
| Parallel fetching, server-side TTL caching, client-cache hydration, N+1 avoidance | core/performance.md |
| Product image URL transforms (CDN, Imgix, Cloudinary) | core/image-config.md |
| Cart CRUD, cart state/context, client state hook, mini-cart drawer | core/cart.md |
| Full-text search, facet config, URL state, renderers | core/search-facets.md |
| Add a new BFF endpoint + client state hook (the 3-layer pattern) | core/add-api.md |
| Add a new standalone or CMS-driven page | core/add-page.md |
| Server-rendered vs client-fetched decisions, mappers, BFF shape, 409 retry | core/data-loading.md |
| Checkout page flow, step routing, order placement | core/checkout-page.md |
| PDP route, variant selectors, shared product detail patterns | core/product-detail.md |
| Shopping lists (wishlist, saved items) | core/shopping-lists.md |
Core Optional Features (references/core/optional/)
| Task | Reference |
|---|---|
| Recurring prices — mapper, PDP gate, selector component, add-to-cart with recurrenceInfo | core/recurring-prices.md |
| Recurring orders — scoping, state transitions, post-checkout creation, recurrence policies | core/recurring-orders.md |
| Deploy to Vercel | Run /deploy-vercel — checks commercetools credentials, then hands off to Vercel's official agent skill |
| Deploy to Netlify | Run /deploy-netlify — checks commercetools credentials, then hands off to Netlify's official agent skill |
B2C Storefront (references/b2c/)
| Task | Reference |
|---|---|
| B2C overview, key takeaways, full reference index | b2c/overview.md |
| Category pages, product mapper, commercetools Search API, ProductCard/Grid | b2c/product-listing.md |
| B2C PDP route, image gallery, variant selectors, AddToCartButton | b2c/product-detail.md |
| Register, login, anonymous cart merge, protected account layout | b2c/customer-auth.md |
| Multi-step checkout, shipping methods, order placement | b2c/checkout.md |
| Navigation patterns, header, mobile menu | b2c/navigation.md |
| Shared UI component library | b2c/ui-components.md |
| PDP variant selector configuration (blocklist, swatch, sort) | b2c/variant-config.md |
| Wishlist functionality | b2c/wishlist.md |
B2C Optional Features (references/b2c/optional/)
| Task | Reference |
|---|---|
| CSR impersonation, dual session, line-item price override | b2c/optional/superuser.md |
| Buy Online Pick Up In Store — channel API, per-store inventory | b2c/optional/bopis.md |
| Product bundles — parent/child cart items, cascade updates | b2c/optional/bundles.md |
| Product discounts, cart discounts, discount codes, promotion banners | b2c/optional/promotions.md |
| Recurring prices — recurrencePrices[] array, gate | b2c/recurring-prices.md |
| Recurring orders — customer scoping, originOrder expand, post-checkout create, skip/setSchedule | b2c/recurring-orders.md |
B2B Storefront (references/b2b/)
| Task | Reference |
|---|---|
| B2B overview, key takeaways, full reference index | b2b/overview.md |
| Session fields, BU/store selection, channel data, BusinessUnitContext | b2b/session-and-bu.md |
| ProductApi session scoping — store, channels, price injection, availability | b2b/product-listing.md |
| B2B PDP route, variant selectors, session-scoped pricing | b2b/product-detail.md |
| as-associate cart CRUD, CartContext, auto-creation with BU+store | b2b/cart.md |
| Cart checkout and "Request a Quote" submission, BU addresses, order placement | b2b/checkout.md |
| Login endpoint, BU auto-select, session fields written at login | b2b/customer-auth.md |
| RBAC — all permission strings, usePermissions, UI gating patterns | b2b/permissions.md |
| Quotes dashboard — CT data model, unified thread list per BU, status labels, client state hooks | b2b/quotes.md |
| Quote buyer actions — accept & place order, decline, renegotiate, state guards | b2b/quote-actions.md |
| Approval rules, approval flows, predicate builder, tier model | b2b/approval-workflows.md |
| Dashboard shell, stat widgets, pages, sidebar nav items | b2b/dashboard.md |
| Purchase lists (commercetools ShoppingList via as-associate, BU-scoped) | b2b/purchase-lists.md |
| Add a new B2B BFF endpoint + client state hook | b2b/add-api.md |
| B2B data loading — server-rendered vs client-fetched, mappers, BFF shape | b2b/data-loading.md |
| B2B variant selector configuration | b2b/variant-config.md |
B2B Optional Features (references/b2b/optional/)
| Task | Reference |
|---|---|
| Superuser role — view all store carts, switch carts | b2b/superuser.md |
| Recurring prices — recurrencePrices[] array, as-associate add-to-cart, PDP gate | b2b/recurring-prices.md |
| Recurring orders — BU scoping, cart expand, create-from-cart, duplicate, dashboard | b2b/recurring-orders.md |
Priority Tiers
CRITICAL
- BFF architecture —
<server>/ct/*is server-only. Zero commercetools SDK imports in any client component. (Requires a server tier — see the architecture assumption.) - Session & client secrets — the commercetools client secret and any session-signing secret are server-only env vars, never hardcoded and never exposed to the client bundle (no public env prefix).
- commercetools login endpoint —
apiRoot.login().post(), neverapiRoot.customers().login(). - B2B: as-associate chain — ALL B2B writes (cart, order, quote, approval, BU) go through
apiRoot.asAssociate().*. Never use project-levelapiRoot.*for user-facing B2B mutations. - B2B: session B2B fields —
businessUnitKey+storeKey+distributionChannelId+supplyChannelId+productSelectionIdare always written together fromgetStoreChannelData(storeKey). - B2B: three-field locale atomicity —
locale,currency,countrymust all be updated together. ResetcartIdon locale/currency change.
Framework-version gates, findoverview.mdof your stack.
HIGH
- Parallel fetching —
Promise.allfor independent fetches in server-rendered loads. No request waterfalls. - Type safety — Frontend components import types from the app type-mapping root (
<server>/types), never from<server>/ct/*. - commercetools type boundary — Map commercetools SDK responses to app types in
<server>/mappers/before they leave<server>/ct/. - Client state invalidation — invalidate/refresh the relevant client state after login, logout, and order placement.
- B2B: BU key in client state-manager/cache keys — all dashboard state is keyed by
businessUnitKey(e.g. a[KEY, businessUnitKey]tuple) so it refreshes on BU switch.
MEDIUM
- Product Search API — Use
apiRoot.products().search(), never the deprecatedproductProjections().search(). See thecommercetools-platformskill → product-search.md. - Server-side TTL cache — Wrap rarely-changing commercetools data in the framework's server-side cache-with-TTL. Never use it for per-user or per-session data. (Next.js:
unstable_cache— see the adapter.)
Anti-Patterns Quick Reference
| Anti-pattern | Correct approach |
|---|---|
Importing <server>/ct/client (apiRoot) in a client component | Use a client state hook → server endpoint → <server>/ct/ |
Calling the endpoint (fetch('/<api>/*')) directly in a component | Encapsulate it in a client data/state module |
new ClientBuilder() inside a page or server endpoint | Singleton apiRoot in <server>/ct/client |
Raw fetch() to commercetools REST endpoints | Always use apiRoot — the SDK manages OAuth tokens and refresh |
| Exposing a commercetools secret to the client bundle (public env prefix) | Server-only env var, no public prefix |
product.name['en-US'] (hardcoded locale key) | getLocalizedString(product.name, locale) |
(centAmount / 100).toFixed(2) | formatMoney(centAmount, currencyCode, locale) |
Sequential await for independent fetches | Promise.all([fetchA(), fetchB()]) |
apiRoot.customers().login() | apiRoot.login().post() |
| commercetools SDK types in components | Types from <server>/types; mapped in <server>/mappers/ |
B2B: apiRoot.carts().post(...) for a logged-in user | asAssociate().withAssociateIdValue(...).inBusinessUnitKey(...).carts().post(...) |
| B2B: BU-scoped client state not keyed by BU | Key the state entry by businessUnitKey (e.g. [KEY_ORDERS, businessUnitKey]) |
B2B: StagedQuote.sellerComment for per-round display | Quote.sellerComment — the snapshot at quote creation time |
B2B: apiRoot.shoppingLists() for purchase lists | asAssociate().*.shoppingLists() — BU-scoped, permission-enforced |
References
Adding a BFF API Endpoint (B2B)
This reference covers B2B-specific additions only: scoping client state-manager/cache keys to BU and store, using the as-associate API chain in commercetools helpers, and validating both session fields in server endpoints.
B2B Addition 1: client state-manager/cache keys must include BU and store context
WRONG — a flat client state-manager/cache key like `KEY_ORDERS` makes one BU's orders appear for every other BU.
businessUnitKey; add storeKey when data varies by store:// <server>/cache-keys
export const KEY_ORDERS = 'orders';
export function keyOrder(id: string) { return `order-${id}`; }
// BU-scoped: orders, quotes, approval flows, purchase lists
export function keyOrdersByBU(buKey: string) {
return [KEY_ORDERS, buKey] as const;
}
// Store-scoped: prices, inventory, product selections differ per store
export function keyProductsByStore(buKey: string, storeKey: string) {
return [KEY_PRODUCTS, buKey, storeKey] as const;
}
useOrders) reads currentBusinessUnit.key from the BU context and uses it as the cache key:- Cache key:
[KEY_ORDERS, businessUnitKey]tuple, or a null/empty key to skip the fetch when no BU is selected. The cache automatically re-fetches when the BU changes (key changes). - Endpoint: the fetcher calls the BU-scoped order endpoint (e.g.
GET /<api>/orders). - Mutations: after a write (e.g.
cancelOrder), invalidate the BU-scoped list state-manager/cache entry[KEY_ORDERS, businessUnitKey]and update the detail entrykeyOrder(orderId)from the mutation response without a refetch.
Find the stack'sconcept-mapping.mdfor concrete client-state and cache implementation.
Rule: any data that differs per BU includesbuKeyin the client state-manager/cache key. Any data that also differs per store (prices, inventory, product selections) includes bothbuKeyandstoreKey.
B2B Addition 2: commercetools helpers must use the as-associate chain
apiRoot for user-facing B2B operations — commercetools does not enforce associate permissions:// WRONG — bypasses B2B permission model; commercetools will not check associate roles
const { body } = await apiRoot.orders().get(...).execute();
asAssociate():// <server>/ct/orders
export async function getOrders(
associateId: string,
businessUnitKey: string
): Promise<Order[]> {
const { body } = await apiRoot
.asAssociate()
.withAssociateIdValue({ associateId })
.inBusinessUnitKeyWithBusinessUnitKeyValue({ businessUnitKey })
.orders()
.get({ queryArgs: { sort: 'createdAt desc', limit: 20 } })
.execute();
return body.results.map(mapOrder); // always map before returning
}
associateIdis alwayssession.customerId.businessUnitKeyis alwayssession.businessUnitKey. commercetools enforces associate permissions automatically — no app-level permission checks needed in the helper or server endpoint.
B2B Addition 3: server endpoints validate both customerId AND businessUnitKey
customerId — a logged-in user without a BU context can proceed and all as-associate calls will fail:WRONG — guarding only on `session.customerId` (a 401 when it is absent) lets a logged-in user
without a BU context through, and every as-associate call then fails.
GET /<api>/orders) reads the session, then:- Return an Unauthorized (401) response unless both
session.customerIdandsession.businessUnitKeyare present. - Call the commercetools helper with both fields:
getOrders(session.customerId, session.businessUnitKey). - Return the mapped result, or an error response (500) with the error message on failure.
Find the stack'sdata-loading.mdfor concrete server endpoint implementation pattern.
Checklist (B2B additions to the shared checklist)
- client state-manager/cache keys for BU-scoped data use
[KEY, businessUnitKey]tuple — empty/null key whenbuKeyabsent - client state-manager/cache keys for store-scoped data (prices, inventory) use
[KEY, businessUnitKey, storeKey]tuple - commercetools helper uses
asAssociate().withAssociateIdValue(...).inBusinessUnitKeyWithBusinessUnitKeyValue(...) - server endpoint validates both
customerIdANDbusinessUnitKey - After mutation: invalidate the BU-scoped list state-manager/cache entry
[KEY, buKey]
Approval Workflows
This reference covers approval rules (create/edit), approval flows (read/approve/reject), the predicate builder, and the tier model.
Table of Contents
- Pattern 1: How Approval Flows Work
- Pattern 2: Approval Rule Draft Structure
- Pattern 3: Approve/Reject — Read-Then-Write
- Pattern 4: Eligibility Check Before Showing Approve/Reject Buttons
- Pattern 5: PredicateBuilder — Adding a New Condition Type
- Pattern 6: Graceful Degradation on 403
- Checklist
Pattern 1: How Approval Flows Work
- An admin creates an approval rule with a predicate and a tier chain of approver roles.
- When any associate places an order, commercetools evaluates all active rules automatically — no app code triggers this.
- If a rule matches, commercetools creates an approval flow linked to the order.
- Associates with eligible roles see the flow on the order detail page.
- Creates/edits approval rules
- Lists and displays approval flows
- Approves or rejects flows via
{ action: 'approve' }/{ action: 'reject' }
Pattern 2: Approval Rule Draft Structure
// WRONG — commercetools requires the nested tiers/and/or structure
approvers: [{ associateRole: { key: 'approver', typeId: 'associate-role' } }]
and: [{ or: [roles] }]:// <server>/ct/approval-rules
export async function createApprovalRule(
associateId: string,
businessUnitKey: string,
draft: {
name: string;
description?: string;
status: 'Active' | 'Inactive';
predicate: string;
requesters: Array<{ associateRole: { key: string; typeId: 'associate-role' } }>;
approvers: {
tiers: Array<{
and: Array<{
or: Array<{ associateRole: { key: string; typeId: 'associate-role' } }>;
}>;
}>;
};
}
) {
const { body } = await apiRoot
.asAssociate()
.withAssociateIdValue({ associateId })
.inBusinessUnitKeyWithBusinessUnitKeyValue({ businessUnitKey })
.approvalRules()
.post({ body: draft })
.execute();
return body;
}
const draft = {
name: 'Large Order Approval',
status: 'Active',
predicate: 'totalPrice.centAmount > 500000',
requesters: [{ associateRole: { key: 'buyer', typeId: 'associate-role' } }],
approvers: {
tiers: [
{
// Tier 1: any associate with 'approver' role must approve first
and: [{ or: [{ associateRole: { key: 'approver', typeId: 'associate-role' } }] }],
},
{
// Tier 2: any associate with 'admin' role must approve after tier 1
and: [{ or: [{ associateRole: { key: 'admin', typeId: 'associate-role' } }] }],
},
],
},
};
| Field | commercetools predicate | Value |
|---|---|---|
| Total price | totalPrice.centAmount > 500000 | Integer (display × 100) |
| Line item count | lineItemCount > 5 | Integer |
| Currency | totalPrice.currencyCode = "USD" | ISO 4217 |
and. parsePredicate in PredicateBuilder.tsx handles the order. prefix as well.Pattern 3: Approve/Reject — Read-Then-Write
// WRONG — version may be stale if another approver acted concurrently
await performApprovalAction(flowId, cachedFlow.version, 'approve');
// <server>/ct/approval-flows
async function fetchApprovalFlowRaw(
associateId: string, businessUnitKey: string, flowId: string
) {
const { body } = await apiRoot
.asAssociate()
.withAssociateIdValue({ associateId })
.inBusinessUnitKeyWithBusinessUnitKeyValue({ businessUnitKey })
.approvalFlows()
.withId({ ID: flowId })
.get()
.execute();
return body; // raw commercetools response — has current .version
}
export async function approveFlow(
associateId: string, businessUnitKey: string, flowId: string
) {
// Read-then-write: get current version before posting
const raw = await fetchApprovalFlowRaw(associateId, businessUnitKey, flowId);
const { body } = await apiRoot
.asAssociate()
.withAssociateIdValue({ associateId })
.inBusinessUnitKeyWithBusinessUnitKeyValue({ businessUnitKey })
.approvalFlows()
.withId({ ID: flowId })
.post({ body: { version: raw.version, actions: [{ action: 'approve' }] } })
.execute();
return mapApprovalFlow(body);
}
export async function rejectFlow(
associateId: string, businessUnitKey: string, flowId: string, reason?: string
) {
const raw = await fetchApprovalFlowRaw(associateId, businessUnitKey, flowId);
const { body } = await apiRoot
.asAssociate()
.withAssociateIdValue({ associateId })
.inBusinessUnitKeyWithBusinessUnitKeyValue({ businessUnitKey })
.approvalFlows()
.withId({ ID: flowId })
.post({ body: { version: raw.version, actions: [{ action: 'reject', reason }] } })
.execute();
return mapApprovalFlow(body);
}
Pattern 4: Eligibility Check Before Showing Approve/Reject Buttons
// WRONG — shows buttons to associates who are not eligible or not in the active tier
{flow.status === 'Pending' && (
<Button onClick={handleApprove}>Approve</Button>
)}
eligibleApprovers and currentTierPendingApprovers:roleKeys via usePermissions(), then derive two booleans before rendering the approve/reject controls:// Check 1: user's role is listed as eligible for this flow
const isEligibleApprover = flow.eligibleApprovers.some(
(a) => roleKeys.has(a.associateRole.key)
);
// Check 2: user's role is in the currently active tier (not a future tier)
const canActOnCurrentTier = flow.currentTierPendingApprovers.some(
(a) => roleKeys.has(a.associateRole.key)
);
isEligibleApprover && canActOnCurrentTier && flow.status === 'Pending' — all three must hold. Reject typically opens a reason input before posting.This usesroleKeys(role keys from associate role assignments), not named permissions. Approval eligibility is role-based, not permission-based.
Find the stack'sconcept-mapping.mdfor concrete client-state and cache implementation.
Pattern 5: PredicateBuilder — Adding a New Condition Type
components/approval-rules/PredicateBuilder.tsx:-
fieldOptionsarray — add{ value: 'myField', label: 'Display Name', description: '...' } -
handleFieldChange— add anelse if (field === 'myField')branch with default operator/value -
Input JSX — add
{condition.field === 'myField' && (...)}inside the conditions map -
buildPredicateString— addif (c.field === 'myField')branch with commercetools syntax:if (c.field === 'myField') return `myField ${c.operator} ${c.value}`; -
parsePredicate— add a regex branch to recognize the new field:const myFieldMatch = str.match(/(?:order\.)?myField\s*(>|>=|<|<=|=|!=)\s*(.+)/); if (myFieldMatch) return { field: 'myField', operator: myFieldMatch[1], value: myFieldMatch[2].trim() };
totalPrice.centAmount, lineItemCount, totalPrice.currencyCode.Pattern 6: Graceful Degradation on 403
getApprovalFlows(customerId, businessUnitKey), and wraps that call so that:- a commercetools 403 (associate lacks
UpdateApprovalFlows) resolves to an empty result set{ results: [], total: 0 }— not an error - any other failure surfaces as a generic 500
const statusCode = (error as { statusCode?: number }).statusCode;
// commercetools 403 = associate lacks UpdateApprovalFlows — return empty list, not an error
if (statusCode === 403) {
return { results: [], total: 0 };
}
Checklist
- App never creates approval flows — commercetools creates them automatically on order placement
- Approval rule draft uses nested
tiers → and → orstructure - Approve/reject always calls
fetchApprovalFlowRawfirst to get current version - Approve/reject buttons gated on both
eligibleApproversANDcurrentTierPendingApprovers - Approval flows list endpoint returns empty list on commercetools 403 (no error to browser)
- Always expand
order,approvals[*].approver.customer,rejection.rejecter.customerwhen fetching flow detail - New predicate field: touch all 5 locations in
PredicateBuilder.tsx
Cart — B2B extensions
apiRoot.carts() bypasses associate permission enforcement and breaks B2B semantics.Table of Contents
- B2B Extension: The as-associate Chain
- B2B Extension: Cart Creation with BU + Store Context
- B2B Extension: Auto-Creation on First Item Add
- B2B Extension: Distribution Channel on Line Items
- Checklist
B2B Extension: The as-associate Chain
<server>/ct/cart that reaches for apiRoot.carts() must instead go through an
as-associate helper. The project-level carts() endpoint does not evaluate associate permissions.// <server>/ct/cart
function asAssociateInStore(associateId: string, businessUnitKey: string) {
return apiRoot
.asAssociate()
.withAssociateIdValue({ associateId })
.inBusinessUnitKeyWithBusinessUnitKeyValue({ businessUnitKey })
.carts();
}
associateId and businessUnitKey as additional parameters and
routes through this builder. The associateId is always session.customerId; businessUnitKey is
always session.businessUnitKey. Both are mandatory — never make them optional.B2B Extension: Cart Creation with BU + Store Context
businessUnit and a store reference. Without them commercetools will not
enforce associate permissions and the cart will not be visible within the correct BU scope.// <server>/ct/cart
export async function createCart(
customerId: string,
associateId: string,
businessUnitKey: string,
storeKey: string,
currency = 'USD',
country = 'US'
) {
const { body } = await asAssociateInStore(associateId, businessUnitKey)
.post({
body: {
currency,
country,
customerId,
businessUnit: { key: businessUnitKey, typeId: 'business-unit' },
store: { key: storeKey, typeId: 'store' },
},
})
.execute();
return mapCart(body);
}
businessUnitKey and storeKey are present in the session
before calling createCart — return 400 if either is missing.B2B Extension: Auto-Creation on First Item Add
/<api>/cart/items server endpoint) from the shared reference.createCart call also
requires associateId and businessUnitKey, and the distribution channel must be resolved before
the addLineItem call (see next section). Write cartId to the session before calling addLineItem
so it is persisted even if the item add fails.B2B Extension: Distribution Channel on Line Items
addLineItem helper) from the shared reference.getStoreChannelData(storeKey) (shared cache) and
pass it to every addLineItem call.// <server>/ct/cart
export async function addLineItem(
cartId: string, version: number,
productId: string, variantId: number, quantity: number,
associateId: string, businessUnitKey: string, storeKey: string,
distributionChannelId?: string,
locale?: string
) {
const action: CartAddLineItemAction = {
action: 'addLineItem',
productId,
variantId,
quantity,
...(distributionChannelId
? { distributionChannel: { id: distributionChannelId, typeId: 'channel' } }
: {}),
};
const { body } = await asAssociateInStore(associateId, businessUnitKey)
.withId({ ID: cartId })
.post({ body: { version, actions: [action] } })
.execute();
return mapCart(body, locale);
}
Checklist
- All cart read/write operations use
asAssociateInStore(session.customerId, session.businessUnitKey) - Cart draft includes both
businessUnit: { key, typeId: 'business-unit' }andstore: { key, typeId: 'store' } - POST route validates
businessUnitKeyandstoreKeyare present before creating a cart -
distributionChannelIdfromgetStoreChannelData(storeKey)passed to everyaddLineItem -
cartIdwritten to session beforeaddLineItemcall during auto-creation -
session.cartIdcleared after successful order placement or quote request creation
Checkout — B2B
Table of Contents
Flow 1: Cart Checkout
Address Step — Saved Addresses from the Business Unit
businessUnit.addresses) and identify defaults via businessUnit.defaultShippingAddressId and businessUnit.defaultBillingAddressId. Auto-select these on load; allow the user to pick another BU address.Order Placement Sequence
addresses (shipping + billing) → shipping method → payment (Checkout SDK) → confirmation
- Addresses step — shipping and billing addresses persisted to cart when moving to the next step. All cart writes use the as-associate chain — see the reference.
- Shipping step — user selects a method with
shippingMethodId - Payment step — Checkout frontend SDK mounts and handles payment capture and order placement. If a PO Number payment method is configured in the Checkout frontend SDK, it will appear automatically — no custom PO Number field is needed in the checkout form.
- Confirmation — SDK signals completion → clear
cartIdfrom session → redirect to/checkout/confirmation?orderId=<id>
POST /<api>/checkout route for order creation.Reference: See the Checkout frontend SDK implementation skill for SDK setup and the order-completion event handler.
BU + Store Validation
session.businessUnitKey and session.storeKey are present. Return a 400 or redirect to an error page if either is missing — a cart without BU/store context cannot be checked out via the as-associate chain.Approval Flows
If the order triggers a B2B approval rule, commercetools creates an ApprovalFlow automatically upon order creation.
Flow 2: Request a Quote
Entry Point
Steps
addresses (shipping + billing) → shipping method → comment → submit
Steps 1 and 2 (addresses and shipping method) follow the same patterns as Flow 1 — BU address selection and shipping method selection from the shared reference. Step 3 is specific to this flow.
comment field. It is optional but the step must always be shown so the user has the opportunity to add context for the seller.Submission
QuoteRequest can be created. The address step in this flow satisfies that — do not skip it or allow bypassing it. If the BU has no addresses and none can be selected, show an error before reaching this step.QuoteRequest from the current cart. All cart writes up to this point (addresses, shipping method) use the as-associate chain.cartId from the session and redirect to:/checkout/quote-request-confirmation?quoteRequestId=<id>
Confirmation Page
quoteRequestId from the URL query and fetches the quote request to display its details (line items, addresses, shipping method, comment, submission date).Checklist
- Extends shared checkout patterns (page structure, shipping methods, payment SDK, confirmation)
- Address step reads BU addresses, auto-selects
defaultShippingAddressId/defaultBillingAddressId - All cart writes in checkout use the as-associate chain
-
session.businessUnitKeyandsession.storeKeyvalidated before rendering checkout - Order placement driven by Checkout frontend SDK — no custom order route for cart checkout
- PO Number not added manually — relies on Checkout SDK configuration if needed
- Confirmation page handles
order.orderState === 'Open'(approval pending) gracefully - Cart page shows Request a Quote button below the Checkout button
- Quote request flow: addresses and shipping use the same BU patterns as Flow 1
- Cart has a shipping address before
QuoteRequestcreation (enforced by the address step) - Comment step always rendered; value stored as
commenton the QuoteRequest - Submit button labelled "Submit Quote Request"
-
cartIdcleared from session after quote request creation - Quote request confirmation at
/checkout/quote-request-confirmation?quoteRequestId=<id> -
cartIdcleared from session after order or quote order completion
Customer Authentication — B2B Extensions
BU Auto-Selection at Login
loginCustomer, fetch all business units the customer is an associate of and auto-select the first BU and its first store. This must happen in the same login server endpoint — never leave the session without a businessUnitKey.apiRoot.businessUnits() filtered by associate ID), not the as-associate chain. The as-associate chain is used for all subsequent operations once businessUnitKey is in the session.getStoreChannelData(storeKey) to resolve the channel IDs needed for pricing and inventory.Session Fields Written at Login
setSession() call alongside the base auth fields:- Auth:
customerId,customerEmail,customerFirstName,customerLastName - B2B context:
businessUnitKey,storeKey,storeId,distributionChannelId,supplyChannelId,productSelectionId - Locale: preserve existing
locale,currency,countryfrom the prior session if present; fall back to defaults
Writing these atomically ensures no intermediate state where auth fields exist but B2B context does not.
Auth client state and useAccount
GET /<api>/auth/me, useAccount exposes the same key. No B2B-specific changes needed here.Logout
KEY_AUTH_ME, KEY_CART, and KEY_BUSINESS_UNITS from the client state-manager/cache. The logout server endpoint preserves locale, currency, country but strips all user and B2B context fields from the session.Checklist
- Login calls
getBusinessUnitsForAssociate(customer.id)immediately after authentication - First BU's first store is auto-selected;
getStoreChannelData(storeKey)populates channel IDs - All B2B session fields written atomically in one
setSession()call - Logout clears
KEY_AUTH_ME,KEY_CART, andKEY_BUSINESS_UNITSfrom the client state-manager/cache - Logout preserves
locale,currency,countryin the session
Dashboard — Shell, Widgets, Pages, Nav
businessUnitKey in the client state-manager/cache key or the cache won't invalidate when the user switches business units.This reference covers the dashboard layout, stat card widgets, adding new pages, sidebar nav items, and the shared UI primitives.
Table of Contents
- Pattern 1: Dashboard Shell
- Pattern 2: BU-Keyed Client State Hook
- Pattern 3: Adding a Stat Widget
- Pattern 4: Adding a Dashboard Page
- Pattern 5: Sidebar Nav Items
- Shared UI Primitives
- Checklist
Pattern 1: Dashboard Shell
The dashboard layout is a client component that:
- Redirects to
/loginwhen!isLoggedIn(viauseAuth) - Shows a BU-selection screen when
!currentBusinessUnit(viauseBusinessUnit) - Renders two-column:
<aside>DashboardNav</aside>+<main>{children}</main>
Inside any dashboard page, these contexts are always available:
useAuth()—user,isLoggedInuseBusinessUnit()—currentBusinessUnit,currentStore,businessUnitsusePermissions()—can,hasAnyPermission,roleKeysuseToast()—addToast(message)useFormatters()—formatMoney(centAmount, currency),formatDate(isoString)
Pattern 2: BU-Keyed Client State Hook
WRONG — a static client state-manager/cache key (e.g. just `KEY_ORDERS`) leaves stale data in place
when the user switches business units.
businessUnitKey in the client state-manager/cache key tuple:useOrders) reads currentBusinessUnit.key from the BU context and uses it in the cache key:- Cache key:
[KEY_ORDERS, businessUnitKey]tuple, or an empty/null key to skip the fetch until a BU is selected. - Endpoint: the fetcher calls the BU-scoped endpoint with the resolved
businessUnitKey. - Refetch: the client state-manager/cache automatically re-fetches when the key changes (BU switch).
Find the stack'sconcept-mapping.mdfor concrete client-state and cache implementation.
An empty/null key skips the fetch — use it whenbusinessUnitKeyis not yet known.
Pattern 3: Adding a Stat Widget
statCards array.useMyStats) reads currentBusinessUnit.key and uses cache key [KEY_MY_STATS, businessUnitKey] (empty/null when no BU), with a fetcher that calls GET /<api>/my-stats?buKey=<buKey>.myStats from the hook and can from usePermissions(), then append to the statCards config:const statCards = [
// ... existing cards
{
label: t('myMetric'),
value: myStats?.total ?? 0,
href: '/dashboard/my-section',
enabled: can('SomePermission'), // disabled cards show lock icon + opacity-50
},
];
"dashboard".Pattern 4: Adding a Dashboard Page
A client-rendered dashboard page is a client component that:
- Reads its translations via the framework's i18n API and
canfromusePermissions(), and loads its data via a BU-keyed client-state hook (e.g.useMyData). - Gates the entire page on permission — renders nothing (
return null) whencan('SomePermission')is false; shows a loading state while the data is loading. - Wraps the content so query-param access (the framework's query-param API) is available — in Next.js this means a
<Suspense>boundary to avoid static-rendering errors.
getSession() + commercetools functions, then passes initialData to a client child component.Pattern 5: Sidebar Nav Items
NAV_ITEMS array in the dashboard nav component:const NAV_ITEMS = [
// existing items...
{
label: t('mySection'), // from 'nav' translation namespace
href: '/dashboard/my-section', // locale prefix added by the framework's link automatically
requiredPermissions: ['SomePermission', 'AnotherPermission'],
// omit requiredPermissions to show always
},
];
hasAnyPermission(item.requiredPermissions) returns false."nav":{ "nav": { "mySection": "My Section" } }
Shared UI Primitives
Located in the UI component directory:
| Component | Key props |
|---|---|
Table | columns, data, loading, emptyMessage, optional onRowClick |
Pagination | total, limit, offset, onChange |
Button | variant (primary/secondary/ghost/danger), href (renders as a framework link), loading, disabled |
Badge | variant (success/warning/error/info/neutral) |
Modal | isOpen, onClose, title, footer, size |
Input / Select | standard labeled form controls with error prop |
Checklist
- New hook uses
[KEY, businessUnitKey]tuple — empty/null key when BU not yet selected - Stat card has
enabled: can('SomePermission')— disabled cards render with lock icon automatically - Dashboard page wraps content for query-param access (Next.js:
<Suspense>, prevents static rendering errors) - Permission check at top of page content —
if (!can(...)) return null - Nav item specifies
requiredPermissions(or omits it to show always) - Translation keys added to all locale messages files
Data Loading — B2B Extensions
See the shared reference and stack'sdata-loading.mdfor the server-load vs client-state decision, commercetools type boundary, BFF route shape, version conflict retry, and caching patterns. This file covers B2B-specific additions only.
as-associate Chain in /ct/
<server>/ct/ that reads or writes a cart, order, or quote must go through the as-associate chain — not the project-level apiRoot. This applies to the version conflict logic in Pattern 4 as well: the re-fetch (version) logic use asAssociateInStore(associateId, businessUnitKey). See reference for the helper.BU-Scoped client state-manager/cache Keys
[KEY, buKey] tuple as the client state-manager/cache key. Passing an empty/null key suspends the fetch until businessUnitKey is available in the session. The client state-manager/cache re-fetches automatically when the key changes (BU switch).Find the stack'sconcept-mapping.mdfor concrete client-state and cache implementation.
Never use a plain string key for BU-scoped data — two users in different BUs on the same client would share the cache.
Additional Mapper Files
Extend the shared mapper table with these B2B-specific files:
| File | Maps |
|---|---|
<server>/mappers/business-unit | commercetools BusinessUnit → app BusinessUnit |
<server>/mappers/quote | commercetools Quote / QuoteRequest → app types |
<server>/mappers/approval-flow | commercetools ApprovalFlow → app ApprovalFlow |
<server>/mappers/associate-role | commercetools AssociateRole → app AssociateRole |
Checklist
- Extends shared data-loading patterns
- All
<server>/ct/functions use the as-associate chain — including inside version conflict logic - BU-scoped client-state hooks use
[KEY, buKey]tuple; empty/null key when BU not yet resolved - B2B mapper files present for
business-unit,quote,approval-flow,associate-role - the framework's server-side cache-with-TTL (Next.js:
unstable_cache) never used for per-BU data
Recurring Orders — B2B
Table of Contents
- B2B Extension: Scoping and Auth
- B2B Extension: Line Items — originOrder Expand
- B2B Extension: Create Post-Checkout
- B2B Extension: Duplicate
- B2B Extension: API Routes
- B2B Extension: Dashboard Pages
- Checklist
B2B Extension: Scoping and Auth
Scope the list query to the active business unit:
where: businessUnit(key="${businessUnitKey}")
customerId AND businessUnitKey from the session. Single-order fetch and state-change routes require customerId only — the BU filter is only needed on the list.where clause is the only enforcement mechanism — omitting it returns all recurring orders across the entire project.B2B Extension: Line Items — originOrder Expand
originOrder to access line items:expand: ['originOrder']
recurrencePolicyId is not a first-class field on RecurringOrder. Derive it by walking the expanded originOrder's line items in the mapper:originOrder.obj?.lineItems?.find(li => li.recurrenceInfo?.recurrencePolicy?.id)
?.recurrenceInfo?.recurrencePolicy?.id
<server>/mappers/recurring-order, not in server endpoints.B2B Extension: Create Post-Checkout
startsAt and expiresAt to control when the subscription becomes active and expires:{
originOrder: { typeId: 'order', id: orderId },
cart: { typeId: 'cart', id: cartId },
customer: { typeId: 'customer', id: customerId },
startsAt?: string, // ISO 8601 — optional
expiresAt?: string, // ISO 8601 — optional
}
recurrenceInfo attached to the cart's line items. There is no need to pass a schedule or recurrencePolicyId in the body.B2B Extension: Duplicate
expand: ['originOrder'] to get line item context, then call createRecurringOrder with the same cartId and cartVersion.This is useful for re-activating a cancelled subscription with the same items without rebuilding the cart from scratch.
B2B Extension: API Routes
| Method | Path | Action | Auth required |
|---|---|---|---|
GET | /<api>/recurring-orders | List, filtered by BU | customerId + businessUnitKey |
GET | /<api>/recurring-orders/[id] | Fetch single with originOrder expand | customerId |
POST | /<api>/recurring-orders/[id]/pause | State → paused | customerId |
POST | /<api>/recurring-orders/[id]/resume | State → active | customerId |
POST | /<api>/recurring-orders/[id]/cancel | State → canceled | customerId |
POST | /<api>/recurring-orders/[id]/duplicate | Clone from same cart | customerId |
GET | /<api>/recurrence-policies | List all policies | Session |
POST routes (not a single PUT). The list route is the only one that requires businessUnitKey.B2B Extension: Dashboard Pages
Recurring orders are a procurement management feature and live under the B2B dashboard:
/[locale]/dashboard/recurring-orders— list with state filter tabs (All / Active / Paused / Cancelled)/[locale]/dashboard/recurring-orders/[id]— detail with pause / resume / cancel actions and order snapshot
customerId + businessUnitKey in session).Checklist
- List
whereclause usesbusinessUnit(key="${businessUnitKey}")— not customer scoping - List route validates both
customerIdANDbusinessUnitKey; all other routes validatecustomerIdonly - Always
expand: ['originOrder'] -
recurrencePolicyIdderived fromoriginOrder.obj.lineItems[].recurrenceInfo.recurrencePolicy.idin the mapper - Create draft includes
originOrder,cart,customer+ optionalstartsAt/expiresAt— noschedulein body - Duplicate fetches with
expand: ['originOrder']and reuses the samecartIdandcartVersion - No
POST /<api>/recurring-orderscreation route — creation happens from checkout - State-change routes are separate per-action
POSTroutes - Pages under
/[locale]/dashboard/recurring-orders/ -
priceSelectionMode: 'Fixed'on all cart line items with recurrence
Recurring Prices — B2B
Table of Contents
B2B Extension: PDP Gate
The B2B subscription UI requires three conditions to all be true:
- The user is logged in (
isLoggedIn === true) recurringPrices.length > 0for the selected variant- At least one recurrence policy is available (
recurrencePolicies.length > 0)
recurrencePrices entries is eligible. Login is required because anonymous B2B users cannot have recurring orders.recurringPrices (availablePolicies = policies.filter(pol => recurringPrices.some(p => p.recurrencePolicy?.id === pol.id))). Showing all policies would let users select a schedule with no corresponding price.B2B Extension: Add to Cart with Recurrence
associateId (= session.customerId) and businessUnitKey. Implement a dedicated addLineItemWithRecurrence wrapper in <server>/ct/cart that calls the base addLineItem with recurrenceInfo attached.priceSelectionMode: 'Fixed' is the only mode used in B2B. Do not use 'Dynamic'.Checklist
-
recurrencePricesmapped fromvariant.recurrencePrices[]— not filtered out ofvariant.prices[] -
regularPriceandrecurringPricesseparated inPDPAddToCartand passed as distinct props — same pattern as core Pattern 2/3 - Gate:
isLoggedIn && recurringPrices.length > 0 && recurrencePolicies.length > 0 -
availablePoliciesfiltered to only those with a matchingrecurrencePricesentry on the variant -
addLineItemWithRecurrencegoes through the as-associate chain -
priceSelectionMode: 'Fixed'on all B2B recurrence add-to-cart actions
Superuser / CSR
superuser. They can view all active carts in their store, switch the active cart to any of them, create merchant-originated carts, and reassign carts to other associates.Session Flag
isSuperuser: boolean is stored in the server-managed session. It is detected at login and re-evaluated on BU selection.Detect at login
businessUnits, derive the flag and write it into the session alongside the other fields:const SUPERUSER_ROLE_KEY = 'superuser';
const isSuperuser = businessUnits.some(bu =>
bu.associates?.some(associate =>
associate.customer.id === customer.id &&
associate.associateRoleAssignments.some(a => a.associateRole.key === SUPERUSER_ROLE_KEY)
)
);
await setSession({
customerId: customer.id,
isSuperuser,
// ... other session fields
});
commercetools Cart Functions (<server>/ct/cart)
Fetch all active carts in a store
export async function getAllSuperuserCarts(businessUnitKey: string, storeKey: string): Promise<Cart[]> {
const response = await apiRoot
.carts()
.get({
queryArgs: {
where: [`cartState="Active"`, `store(key="${storeKey}")`, `businessUnit(key="${businessUnitKey}")`],
limit: 20,
sort: 'createdAt desc',
expand: ['createdBy.customer'], // avoids N+1 — creator info in one request
},
})
.execute();
return response.body.results.map(ct => ({
id: ct.id,
version: ct.version,
origin: ct.origin,
createdByEmail: (ct.createdBy as any)?.customer?.email,
createdByName: [(ct.createdBy as any)?.customer?.firstName, (ct.createdBy as any)?.customer?.lastName]
.filter(Boolean).join(' '),
// ... rest of cart fields
}));
}
apiRoot.carts() (not as-associate) — superusers read carts they don't own.Create merchant-originated cart
export async function createSuperuserCart(associateId, businessUnitKey, storeKey, currency = 'USD', country = 'US') {
const response = await apiRoot
.asAssociate()
.withAssociateIdValue({ associateId })
.inBusinessUnitKeyWithBusinessUnitKeyValue({ businessUnitKey })
.carts()
.post({
body: {
currency, country,
origin: 'Merchant', // commercetools-native way to mark merchant-created carts
businessUnit: { key: businessUnitKey, typeId: 'business-unit' },
store: { key: storeKey, typeId: 'store' },
},
})
.execute();
return response.body;
}
origin: 'Merchant' marks the cart as merchant-created. Do not set customerId — merchant carts are owner-less until reassigned.Reassign cart to another customer
export async function reassignCart(cartId, version, associateId, businessUnitKey, targetCustomerId) {
const response = await apiRoot
.asAssociate()
.withAssociateIdValue({ associateId })
.inBusinessUnitKeyWithBusinessUnitKeyValue({ businessUnitKey })
.carts()
.withId({ ID: cartId })
.post({ body: { version, actions: [{ action: 'setCustomerId', customerId: targetCustomerId }] } })
.execute();
return response.body;
}
API Routes
| Route | Description |
|---|---|
GET /<api>/superuser/status | Returns { isSuperuser, carts: [] } — never 403 for non-superusers |
POST /<api>/superuser/carts | Create merchant cart; writes new cartId to session |
POST /<api>/superuser/carts/switch | Switch active cart; writes cartId to session |
POST /<api>/superuser/carts/[id]/reassign | Reassign cart to targetCustomerId |
GET /<api>/superuser/status returns { isSuperuser: false, carts: [] } for non-superusers — no 403, no information leakage.SuperuserContext
SuperuserProvider owns superuser state, backed by client state, and exposes it via a useSuperuser() hook:- Status: loaded from client state keyed by
KEY_SUPERUSER_STATUS(endpointGET /<api>/superuser/status), defaulting to{ isSuperuser: false, carts: [] }. switchCart(cartId): POSTs to/<api>/superuser/carts/switch; on success invalidates theKEY_CARTclient state-manager/cache entry (forces the cart context to refetch) and does a full page reload so every component sees the new active cart.createMerchantCart(): POSTs to/<api>/superuser/carts, then refreshes the superuser cart list and invalidates the cart context.
Find the stack'sconcept-mapping.mdfor concrete client-state and cache implementation.
Layout Integration
SuperuserProvider sits inside AuthProvider and outside CartProvider:AuthProvider
└─ SuperuserProvider
└─ BusinessUnitProvider
└─ CartProvider
├─ Header
├─ SuperuserBanner (amber banner shown only to superusers)
└─ main / page content
Find the stack'sconcept-mapping.mdfor concrete provider nesting in the layout.
UI Components
| Component | File | Purpose |
|---|---|---|
SuperuserBanner | components/superuser/SuperuserBanner.tsx | Amber banner — "You are in superuser mode" |
CartBrowser | components/superuser/CartBrowser.tsx | Dropdown listing all store carts — switch or create |
ReassignCartButton | components/superuser/ReassignCartButton.tsx | Select from BU associates to reassign active cart |
CartBrowser appears as a dropdown from a caret next to the cart icon in the Header. ReassignCartButton appears on the cart page.commercetools Prerequisite
superuser in commercetools Merchant Center:- Assign at minimum:
ViewOthersCarts,UpdateOthersCarts,CreateOthersCarts - Assign this role to the test user in their business unit
Key Patterns
| Pattern | Why |
|---|---|
Project-level apiRoot.carts() for listing | Superusers read carts they don't own |
| As-associate chain for create/reassign | commercetools enforces BU membership |
origin: 'Merchant' in cart draft | commercetools-native merchant-cart marker |
expand: ['createdBy.customer'] | One query, no N+1 |
window.location.replace() after switch | Full reload ensures all components see the new cart |
Return { isSuperuser: false } not 403 | No info leakage to non-superusers |
Checklist
-
isSuperuserstored in session at login — not re-checked on every request -
GET /<api>/superuser/statusreturns empty carts for non-superusers (never 403) -
SuperuserProviderinsideAuthProvider, outsideCartProvider -
SuperuserBannerrendered in layout (after Header, before main) - commercetools associate role
superusercreated with correct permissions
commercetools B2B Storefront
Production-tested patterns for the b2b-site — a B2B ecommerce storefront built on commercetools with server-managed sessions. The key B2B concepts are: associates acting on behalf of business units, store-scoped pricing/inventory, associate permissions enforced by commercetools, and B2B-only features (quotes, approval workflows, purchase lists, recurring orders). The patterns are framework-neutral; load a framework adapter for the implementation primitives.
Shared foundation: BFF architecture, session setup, commercetools SDK singleton, project scaffold,COUNTRY_CONFIG, performance patterns, image config, and the shared auth base are in this skill'score/references.
Key Takeaways (B2B-specific)
apiRoot.asAssociate().withAssociateIdValue({ associateId }).inBusinessUnitKeyWithBusinessUnitKeyValue({ businessUnitKey }).*. The associateId is always session.customerId; the businessUnitKey is always session.businessUnitKey. commercetools enforces associate permissions server-side — no app-level permission checks in the server endpoints.businessUnitKey, storeKey, distributionChannelId, supplyChannelId, and productSelectionId are resolved once (at login or BU selection) from the store record and written atomically into the session. Every product search and cart operation reads these from the session.ProductApi.buildProjectionParams() injects priceChannel (distributionChannelId), storeProjection (storeKey), and priceCustomerGroupAssignments (accountGroupIds) into every search. Without a store context (unauthenticated users), commercetools returns "Price on request."COUNTRY_CONFIG with three-field atomicity. Same COUNTRY_CONFIG flat structure as B2C (key = BCP-47 locale like de-DE; same value used for commercetools API calls and URL routing). When changing locale or currency, locale, currency, and country must all be updated together and cartId must be reset — commercetools cart currency is immutable.usePermissions() to hide/disable buttons. The API enforces everything automatically via the as-associate chain — a 403 from commercetools means the associate lacks the permission. No app-level authorization code in the server endpoints.Reference Index
Shared Foundation
core/. Find your stack's overview.md file.| Task | Reference |
|---|---|
| Scaffold a new project (deps, styling, locale routing) | Framework-specific — find adapter's overview.md |
| commercetools SDK singleton, server-managed sessions, BFF boundary | ct-client.md |
| Shared auth base: commercetools login, server endpoint, client state hook, logout | customer-auth.md |
Add a new country / currency / locale (COUNTRY_CONFIG) | add-country.md |
| Parallel fetching, server-side TTL caching, client-cache hydration, image optimization | performance.md |
| Product image URL transforms (CDN, Imgix, Cloudinary) | image-config.md |
Core — B2B Foundation (follow in order)
| Task | Reference |
|---|---|
| Session fields, BU/store selection, channel data, BusinessUnitContext | session-and-bu.md |
| ProductApi session scoping — store, channels, price injection, availability | product-listing.md |
| PDP route, variant selectors, session-scoped PDP pricing | product-detail.md |
| as-associate cart CRUD, CartContext, auto-creation with BU+store | cart.md |
| Order placement from cart and from quote, confirmation | checkout.md |
| Login endpoint, BU auto-select, session fields written at login | customer-auth.md |
| Full-text search, facet config, URL state, renderers | search-facets.md |
| RBAC — all permission strings, usePermissions, UI gating patterns | permissions.md |
B2B Feature Modules
| Task | Reference |
|---|---|
| Quote lifecycle, multi-round negotiation, commercetools data model, client state hooks | quotes.md |
| Approval rules, approval flows, predicate builder, tier model | approval-workflows.md |
| Dashboard shell, stat widgets, pages, sidebar nav items | dashboard.md |
| Recurring orders — pause, resume, cancel, duplicate | recurring-orders.md |
| Purchase lists (commercetools ShoppingList via as-associate, BU-scoped) | purchase-lists.md |
Enhancement — Modify Existing Features
| Task | Reference |
|---|---|
| Add a new BFF endpoint + client state hook (no-fetch-in-client, 3-layer pattern) | add-api.md |
| Server-rendered vs client-fetched decisions, mappers, BFF shape, commercetools type boundary | data-loading.md |
| Add a new page — standalone or dashboard section | add-page.md |
| Configure PDP variant selectors (blocklist, swatch, sort order) | variant-config.md |
Optional Features — Not Required for Core B2B Storefront
| Task | Reference |
|---|---|
| Superuser role — view all store carts, switch carts, merchant-origin carts | superuser.md |
| Personal wishlists (project-level, not as-associate) | wishlists.md |
| Deploy to Vercel | Run /deploy-vercel — checks commercetools credentials, then hands off to Vercel's official agent skill |
| Deploy to Netlify | Run /deploy-netlify — checks commercetools credentials, then hands off to Netlify's official agent skill |
Priority Tiers (B2B-specific additions)
Shared CRITICAL/HIGH/MEDIUM rules (BFF, session secrets, commercetools login endpoint, parallel fetching, type safety, mappers, Product Search API, server-side TTL caching) are in this skill's top-level SKILL.md.
CRITICAL
- as-associate chain — ALL B2B writes (cart, order, quote, approval, BU) go through
apiRoot.asAssociate().*. Never use project-levelapiRoot.*for user-facing mutations. - Session B2B fields —
businessUnitKey+storeKey+distributionChannelId+supplyChannelId+productSelectionIdare always written together fromgetStoreChannelData(storeKey). - Three-field locale atomicity —
locale,currency,countrymust all be updated together. ResetcartIdon locale/currency change. - Session fields for product pricing — always pass
sessiontosearchProducts()andgetProductBySku(). WithoutdistributionChannelIdandstoreKey, commercetools returns unscoped "Price on request" prices.
HIGH
- BU key in client state-manager/cache keys — all dashboard state is keyed by
[KEY, businessUnitKey]so it refreshes on BU switch. - Permission gating — gate all UI actions with
usePermissions(). commercetools enforces on the API side; the UI must not show what commercetools will reject. - CartContext auto-creation — if
session.cartIdis absent when adding an item, the server endpoint creates a cart withbusinessUnit+store+currency+countryfrom the session.
MEDIUM
- No-fetch-in-client — all endpoint (
fetch('/<api>/*')) calls live inhooks/*Api.tsfunctions, not in component or context files. - Store data cache —
storeDataCachein<server>/ct/storesis a module-levelMapwith no TTL. It is the single source forstoreId,distributionChannelId,supplyChannelId,productSelectionId. - Product type cache —
_productTypesCacheinfacets.tshas a 60-second TTL (the only timed cache in the codebase). - Approval flow graceful degradation — the approval-flows endpoint returns
{ results: [], total: 0 }on commercetools 403, never a 4xx to the browser. - Quote
sellerCommentis per-round — read fromQuote.sellerComment(snapshot), not fromStagedQuote.sellerComment(mutable latest).
Anti-Patterns Quick Reference (B2B-specific)
Shared anti-patterns (apiRoot in a client component, client-exposed secrets, sequential awaits, etc.) are in this skill's top-level SKILL.md.
| Anti-pattern | Correct approach |
|---|---|
apiRoot.carts().post(...) for a logged-in user | asAssociate().withAssociateIdValue(...).inBusinessUnitKey(...).carts().post(...) |
Single locale | Single locale field in BCP-47 (e.g. de-DE) — same value for routing and commercetools API calls |
Setting locale without resetting currency, country, cartId | Update all three fields atomically via the session-locale endpoint |
Omitting distributionChannelId in product search | Pass full session to searchProducts() — ProductApi injects channel automatically |
| BU-scoped client state not keyed by BU | Key the state entry by businessUnitKey (e.g. [KEY_ORDERS, businessUnitKey]) — must scope to the active BU |
| Reading approval flow version from client state | fetchApprovalFlowRaw() to get current version before every approve/reject |
StagedQuote.sellerComment for per-round display | Quote.sellerComment — the snapshot at quote creation time |
apiRoot.shoppingLists() for purchase lists | asAssociate().*.shoppingLists() — BU-scoped, permission-enforced |
Permissions & RBAC
usePermissions() (the client-state permission hook). No app-level enforcement in server endpoints — commercetools enforces everything via the as-associate chain. A 403 from commercetools means the associate lacks the permission.usePermissions resolves them, UI gating patterns, and "My vs Others" semantics.Table of Contents
- Pattern 1: Permission Architecture
- Pattern 2: usePermissions Resolution
- Pattern 3: UI Gating Patterns
- Pattern 4: All Permission Strings
- Pattern 5: Nav Item Gating
- Checklist
Pattern 1: Permission Architecture
associateRoleAssignments for CreateMyCarts before proceeding. This duplicates commercetools enforcement and is fragile because it must be maintained by hand against the role config.CreateMyCarts:// commercetools will 403 automatically if the associate lacks CreateMyCarts
const cart = await createCart(
session.customerId,
session.customerId, // associateId
session.businessUnitKey,
session.storeKey!,
session.currency,
session.country
);
The as-associate chain is the enforcement layer. If commercetools returns 403, propagate it to the browser as-is (or return a generic error). Never try to replicate commercetools's permission logic in application code.
Find the stack'sdata-loading.mdfor concrete server endpoint implementation patterns.
Pattern 2: usePermissions Resolution
// WRONG — role definitions live in commercetools, not in code
const BUYER_PERMISSIONS = ['CreateMyCarts', 'ViewMyOrders'];
const isBuyer = currentUser.roles.includes('buyer');
const canCreateCart = isBuyer;
usePermissions fetches associate roles from commercetools and resolves dynamically. It is a client-state hook backed by the current user and the active business unit. Its resolution is:- Fetch all
AssociateRoleobjects from the associate-roles endpoint (commercetools source of truth; cached once per tab) - Find the current associate in
currentBusinessUnit.associatesbycustomer.id - Collect that associate's role keys from
associateRoleAssignments→roleKeys - Union the
permissionsof every role whose key is inroleKeys - Expose
can(permission),hasAnyPermission(ps),hasAllPermissions(ps), androleKeys
// Core resolution — framework-neutral
const keys = new Set(
associate.associateRoleAssignments.map((r) => r.associateRole.key)
); // → roleKeys
const permissions = new Set<string>();
for (const role of allAssociateRoles) {
if (keys.has(role.key)) {
for (const p of role.permissions) permissions.add(p);
}
}
const can = (permission: string) => permissions.has(permission);
const hasAnyPermission = (ps: string[]) => ps.some((p) => permissions.has(p));
const hasAllPermissions = (ps: string[]) => ps.every((p) => permissions.has(p));
Role definitions (which permissions a role has) are configured in commercetools Merchant Center, not in code.usePermissionsfetches them at runtime — no permission mapping in the codebase. Find the stack'sconcept-mapping.mdfor concrete client-state and cache implementation.
Pattern 3: UI Gating Patterns
usePermissions(); the snippets below show the decision logic only. Render the gated control when the resulting boolean is true (and hide it otherwise).Pattern A — single permission
const { can } = usePermissions();
const canCreateRules = can('CreateApprovalRules');
Pattern B — "either My or Others grants access" (feature visibility)
const { hasAnyPermission } = usePermissions();
const canViewOrders = hasAnyPermission(['ViewMyOrders', 'ViewOthersOrders']);
hasAnyPermission for deciding whether to show a feature at all.Pattern C — dynamic My/Others dispatch (per-resource actions)
Resolve the current user (id) from auth/session, then pick the My vs Others permission per resource:
const { can } = usePermissions();
const isOwnQuote = quote.customer.id === currentUserId;
const canAccept = isOwnQuote ? can('AcceptMyQuotes') : can('AcceptOthersQuotes');
Use this for action buttons on specific resources.
Pattern D — role-key based approval tier check
const { roleKeys } = usePermissions();
const isEligibleApprover = flow.eligibleApprovers.some(
(a) => roleKeys.has(a.associateRole.key)
);
const canActOnCurrentTier = flow.currentTierPendingApprovers.some(
(a) => roleKeys.has(a.associateRole.key)
);
const canApprove = isEligibleApprover && canActOnCurrentTier;
Pattern 4: All Permission Strings
<server>/types:AddChildUnits— create sub-divisionsUpdateBusinessUnitDetails— edit BU name, email, addressesUpdateAssociates— add/remove/change roles of associates
CreateMyCarts/CreateOthersCartsUpdateMyCarts/UpdateOthersCartsDeleteMyCarts/DeleteOthersCartsViewMyCarts/ViewOthersCarts
CreateMyOrdersFromMyCarts/CreateOrdersFromOthersCartsCreateMyOrdersFromMyQuotes/CreateOrdersFromOthersQuotesViewMyOrders/ViewOthersOrdersUpdateMyOrders/UpdateOthersOrders
CreateMyQuoteRequestsFromMyCarts/CreateQuoteRequestsFromOthersCartsAcceptMyQuotes/AcceptOthersQuotesDeclineMyQuotes/DeclineOthersQuotesRenegotiateMyQuotes/RenegotiateOthersQuotesReassignMyQuotes/ReassignOthersQuotesViewMyQuotes/ViewOthersQuotes
CreateApprovalRulesUpdateApprovalRulesUpdateApprovalFlows
ViewMyShoppingLists/ViewOthersShoppingListsCreateMyShoppingLists/CreateOthersShoppingListsUpdateMyShoppingLists/UpdateOthersShoppingListsDeleteMyShoppingLists/DeleteOthersShoppingLists
"My" vs "Others":My*= resources whereresource.customer.id === user.id.Others*= resources owned by any other associate in the BU. commercetools enforces this at the data level — an associate with onlyViewMyOrdersonly receives their own orders from the as-associate endpoint.
Pattern 5: Nav Item Gating
/dashboard/approval-rules) and only failing once the associate clicks through — the user sees the link, clicks it, then hits an error.hasAnyPermission before rendering:const NAV_ITEMS = [
{ label: 'orders', href: '/dashboard/orders',
requiredPermissions: ['ViewMyOrders', 'ViewOthersOrders'] },
{ label: 'quotes', href: '/dashboard/quotes',
requiredPermissions: ['ViewMyQuotes', 'ViewOthersQuotes'] },
{ label: 'approvalRules', href: '/dashboard/approval-rules',
requiredPermissions: ['CreateApprovalRules', 'UpdateApprovalRules'] },
{ label: 'company', href: '/dashboard/company',
requiredPermissions: ['UpdateBusinessUnitDetails', 'UpdateAssociates'] },
];
// Visible items only:
const visibleItems = NAV_ITEMS.filter(
(item) => !item.requiredPermissions || hasAnyPermission(item.requiredPermissions)
);
Render each visible item with the framework's locale-aware link primitive; labels go through the framework's i18n/locale routing.
Checklist
- No permission checks in server endpoints — commercetools enforces via as-associate chain
- All UI action buttons gated with
can()orhasAnyPermission() - "My vs Others" pattern used for resource-scoped actions (quotes, orders, carts)
- Approval flow actions gated with
roleKeys(pattern D), not named permissions - Nav items specify
requiredPermissions— items not shown if associate lacks them - New feature: check
<server>/typesfor the correctPermissionunion strings - Role definitions configured in commercetools Merchant Center — never hardcoded in the app
Product Detail Page — B2B
Data Fetching
Promise.all as per the shared pattern. The helper you call (getProductBySku or getProductById) depends on your chosen route identifier. The critical B2B addition: always pass session to the product fetch — it carries the channel and store context required for correct pricing and availability.session there too, or the SEO title and description may not match what the customer sees in their channel.Session and Channel Scoping
session to ProductApi injects these parameters into every commercetools query automatically:priceChannel→session.distributionChannelId— scopes price to the customer's channelavailabilityChannel→session.supplyChannelId— scopes availabiltiy to the customer's channelstoreProjection→session.storeKey— filters to store-visible productspriceCustomerGroupAssignments→ applies B2B customer group discounts
session, the product loads at list price with no channel or store filtering. The session is populated during Business Unit selection — never bypass it.Availability
variant.availability.channels[supplyChannelId].availableQuantity for stock display — not variant.availability.isOnStock.isOnStock aggregates across all channels and shows in-stock even when the customer's specific supply channel is out of stock. In a multi-channel B2B setup, this is always wrong.const channelStock = variant.availability?.channels?.[supplyChannelId];
// channelStock.availableQuantity → correct
// variant.availability.isOnStock → never use in B2B
supplyChannelId comes from session, populated during BU selection.Pricing
session is passed. Price display follows the shared pattern — discount with strikethrough, recurring price support.Components
Same as the shared component list, with these B2B additions:
- Product title — product name + active SKU
- Description — from the product description field
- Info attributes — attributes listed in
PDP_INFO_ATTRIBUTES - Related products — products sharing the same category
- Purchase list — add to BU shopping list (requires auth)
Purchase List
Authenticated B2B users can add items to their Business Unit's shared shopping list. Render the purchase list button only when the user is authenticated — it is not available to guests.
Checklist
- Pass
sessionto product fetch — never call without it - Pass
sessionwhen building page metadata (framework's page-metadata API) — ensures channel-scoped SEO content - Use
channelStock.availableQuantity(notisOnStock) for availability -
supplyChannelIdcomes fromsession— set during BU selection - Purchase list rendered only for authenticated users
Product Listing & Session-Scoped Pricing
ProductApi session injection, how distributionChannelId, storeKey, and accountGroupIds scope prices, and how supplyChannelId drives availability display.Table of Contents
- Pattern 1: Session-Scoped Product Search
- Pattern 2: Price Injection via buildProjectionParams
- Pattern 3: Store-Scoped Category Filtering
- Pattern 4: Availability via supplyChannelId
- Pattern 5: Facet Retry on commercetools Error
- Checklist
Pattern 1: Session-Scoped Product Search
searchProducts without the session — returns global prices:// WRONG — no channel context; commercetools returns unscoped prices or "Price on request"
const results = await searchProducts({ query: searchTerm, locale: 'en-US' });
session to searchProducts(). The server endpoint that backs product search reads the request body and the current session, then hands both to searchProducts(body, session). searchProducts reads businessUnitKey, storeKey, distributionChannelId, supplyChannelId, and accountGroupIds from the session internally — it is a thin wrapper over ProductApi:// <server>/ct/products — thin wrapper over ProductApi
export async function searchProducts(
query: ProductQuery,
session?: Partial<SessionData>
): Promise<ProductPaginatedResult> {
const s = session ?? (await getSession());
return new ProductApi(s).query(query);
}
ProductApireads all B2B fields from the session automatically. The caller passes the full session andProductApiinjects the appropriate commercetools parameters.
Find the stack'sdata-loading.mdfor concrete server endpoint patterns.
Pattern 2: Price Injection via buildProjectionParams
productProjectionParameters manually without channel/store scoping:// WRONG — prices not scoped to the customer's distribution channel
productProjectionParameters: {
priceCurrency: currency,
priceCountry: country,
}
ProductApi.buildProjectionParams() injects all B2B scoping parameters:// <server>/ct/product-api (key excerpt)
private buildProjectionParams(
locale: Locale,
distributionChannelId?: string,
storeKey?: string,
accountGroupIds?: string[]
): ProductSearchProjectionParams {
return {
priceCurrency: locale.currency,
priceCountry: locale.country,
expand: PRODUCT_PROJECTION_EXPANDS,
// Channel-scoped pricing — customer's negotiated prices for this distribution channel
...(distributionChannelId ? { priceChannel: distributionChannelId } : {}),
// Store projection — restricts to products in this store's product selection
...(storeKey ? { storeProjection: storeKey } : {}),
// Customer group pricing — B2B contract prices for this customer group
...(accountGroupIds?.length ? { priceCustomerGroupAssignments: accountGroupIds } : {}),
};
}
| Parameter | Session field | commercetools effect |
|---|---|---|
priceChannel | distributionChannelId | Returns only prices assigned to this distribution channel |
storeProjection | storeKey | Filters to products in the store's product selection |
priceCustomerGroupAssignments | accountGroupIds | Applies B2B contract pricing for the customer's group |
priceCurrency | currency | Returns prices in this currency |
priceCountry | country | Applies country-specific price scoping |
When the user is not logged in (no store context), all these fields are absent and commercetools returns global prices or "Price on request". This is intentional — B2B pricing requires authentication.
Pattern 3: Store-Scoped Category Filtering
// WRONG — shows categories that have no products in the active store
const categories = await getCategories();
ProductApi.queryCategories uses the store's product selection to filter:// <server>/ct/product-api (key excerpt)
async queryCategories(categoryQuery: CategoryQuery) {
const storeKey = categoryQuery.storeKey ?? this.session.storeKey;
if (storeKey) {
const { storeId } = await getStoreChannelData(storeKey);
if (storeId) {
// Get category IDs that have at least one product in this store
const categoryIds = await this.getCategoryIdsForStore(storeId);
if (categoryIds?.length) {
where.push(`id in ("${categoryIds.join('","')}")`);
}
}
}
// ...
}
// getCategoryIdsForStore uses the categoriesSubTree facet —
// one Product Search API call returns all category IDs with products in the store
private async getCategoryIdsForStore(storeId: string): Promise<string[] | undefined> {
const response = await apiRoot.products().search().post({
body: {
query: { exact: { field: 'stores', value: storeId } },
facets: [{
distinct: {
name: 'categoriesSubTree',
field: 'categoriesSubTree',
level: 'products',
limit: 200,
},
}],
},
}).execute();
// Returns only category IDs that have > 0 products in this store
return facet.buckets.filter((b) => b.count > 0).map((b) => b.key);
}
Pattern 4: Availability via supplyChannelId
// WRONG — shows availability without considering the store's supply channel
const inStock = product.variants[0].availability?.isOnStock;
supplyChannelId to the product mapper:// <server>/ct/product-api (in query method)
const items = searchResults.map((r) =>
mapProduct(
r.productProjection!,
matchingIds,
locale, // Always use BCP-47
this.session.supplyChannelId // ← inventory display for this supply channel
)
);
// <server>/mappers/product
export function mapProduct(
projection: ProductProjection,
matchingVariantIds: Set<number> | null,
locale: string,
supplyChannelId?: string
): Product {
// For each variant, check inventory for this specific supply channel
const availability = supplyChannelId
? variant.availability?.channels?.[supplyChannelId]
: variant.availability;
return {
// ...
variants: variants.map((v) => ({
// ...
availability: {
isOnStock: availability?.isOnStock ?? false,
availableQty: availability?.availableQuantity,
},
})),
};
}
supplyChannelIdis NOT sent as a commercetools query parameter — it is only used by the mapper to pick the right channel's inventory data from the response. commercetools returns all channel inventory when no channel filter is applied.
Pattern 5: Facet Retry on commercetools Error
// WRONG — commercetools 400 on invalid facet expression leaves the user with an error page
const results = await searchProducts({ facetConfigurations });
searchProducts call so that a first failure retries once with facets stripped — products always render even if facets fail — and only a second failure surfaces as a 500:try {
return await searchProducts(body, session);
} catch (error) {
// Retry without facets — products always render even if facets fail
console.warn('Product search failed with facets, retrying without:', error);
return await searchProducts({ ...body, facetConfigurations: [] }, session);
// a second failure here surfaces as a generic "Product search failed"
}
Find the stack'sdata-loading.mdfor concrete server endpoint implementation patterns.
Checklist
-
searchProducts(query, session)called with full session — never with empty session -
buildProjectionParamsincludespriceChannel,storeProjection,priceCustomerGroupAssignments -
supplyChannelIdpassed tomapProduct(fromsession.supplyChannelId) - Category listing calls
queryCategorieswith store context to filter empty categories - Product search API retries without facets on commercetools error (products always load)
- Unauthenticated users see "Price on request" — intentional, no fix needed
Quote Actions
?quoteId=<id> query parameter. Decline and renegotiate are triggered from the quote detail page.Table of Contents
State Guard
quote.quoteState:| Action | Allowed states |
|---|---|
| Accept | Pending, RenegotiationAddressed |
| Decline | Pending, RenegotiationAddressed |
| Renegotiate | Pending only |
Show an error and no action buttons if the quote is not in an allowed state.
Accept & Place Order
- Guard: quote must be
PendingorRenegotiationAddressed - User clicks Accept & Place Order
- Transition the quote to
AcceptedviachangeQuoteState - Create the order using
createOrderFromQuote— use the version returned from step 3, not the original quote version - Clear
cartIdfrom session and redirect to/checkout/confirmation?orderId=<id>
Accepted state when it fires.Decline
- Guard: quote must be
PendingorRenegotiationAddressed - User clicks Decline
- Transition the quote to
DeclinedviachangeQuoteState - Redirect to the quotes dashboard
No order is created. The thread remains visible in the dashboard with state "Declined".
Renegotiate
- Guard: quote must be
Pending - Present a textarea for the buyer's counter-comment
- User submits — call
requestQuoteRenegotiationwith thebuyerComment - The quote transitions to
RenegotiationRequested; thebuyerCommentis stored on theQuote - Redirect to the quote detail page
StagedQuote and publishing a new Quote (round N+1). That new Quote shares the same stagedQuote.id and will appear as the next entry in the thread timeline. The buyer then sees state RenegotiationAddressed and can accept, decline, or renegotiate again.Associate Permission Guard
Before rendering any action button, also check that the associate has the appropriate permission:
AcceptMyQuotes— whenquote.customer.id === currentUser.idAcceptOthersQuotes— when acting on another associate's quote
Checklist
- State guard shown before any action button; error displayed for invalid states
- Accept and order creation are sequential — order creation uses the version from the accept response
- No payment SDK step on the quote acceptance page
-
cartIdcleared from session after order creation - Redirects to
/checkout/confirmation?orderId=<id>on order success - Decline redirects to the quotes dashboard with
Declinedstate visible - Renegotiate stores
buyerCommenton the Quote; thread gains a new round after seller responds - Associate permission (
AcceptMyQuotes/AcceptOthersQuotes) checked before rendering actions
Quotes Dashboard
Quote.sellerComment is a per-round snapshot; StagedQuote.sellerComment is the latest-only mutable value. Always use Quote.sellerComment when displaying individual rounds in a thread.Table of Contents
- Pattern 1: CT Data Model — Three Resources
- Pattern 2: as-associate API Constraint
- Pattern 3: Unified List — Thread Grouping
- Pattern 4: Status Labels
- Pattern 5: Client State Hooks
- Checklist
Pattern 1: CT Data Model — Three Resources
QuoteRequest → StagedQuote → Quote (round 1)
↓ renegotiate
Quote (round 2) [same StagedQuote]
| Resource | Who creates it | Key fields |
|---|---|---|
QuoteRequest | Buyer (from active cart) | comment, purchaseOrderNumber, lineItems, totalPrice |
StagedQuote | commercetools automatically | sellerComment (mutable — always latest seller edit) |
Quote | Seller (in Merchant Center) | sellerComment (snapshot per round), buyerComment, validTo, quoteState |
StagedQuote + one or more Quote rounds sharing the same stagedQuote.id. The QuoteRequest is the thread's origin.Pattern 2: as-associate API Constraint
All quote operations — list, detail, actions — must go through the as-associate chain:
apiRoot.asAssociate().withAssociateIdValue(associateId)
.inBusinessUnitKeyWithBusinessUnitKeyValue(buKey)
.quotes()
apiRoot.quotes() bypasses BU scoping and associate permission enforcement.['quoteRequest', 'stagedQuote'] on every fetch — required for the sellerComment per-round snapshot fallback and for reading the buyer's original quoteRequestComment.Pattern 3: Unified List — Thread Grouping
Quote object.Quote objects by stagedQuote.id. Each group represents one thread. Display the QuoteRequest details (date, buyer comment) as the thread's origin row, then the most recent Quote round's state and seller comment as the thread summary.Quote.quoteState as the row's state. If no Quote exists yet for a QuoteRequest, fall back to QuoteRequest.quoteRequestState.Quote round.- Buyer request comment — shown once above all rounds, from
thread[0].quoteRequestComment - Per round:
- Seller comment —
Quote.sellerComment(per-round snapshot, notStagedQuote.sellerComment) - Buyer counter-comment —
Quote.buyerComment, only shown when present (set on renegotiate) - Expiry date — info line when
quote.validTois present
- Seller comment —
- Sort quotes within a thread by
createdAtascending to maintain chronological order
whitespace-pre-wrap to preserve line breaks.Quote objects with the same stagedQuote.id. Defer this fetch until stagedQuote.id is known (pass an empty/null key to the client-state hook to skip until available).Pattern 4: Status Labels
Map the entity state to a user-facing display label:
| Entity | State | Display label |
|---|---|---|
QuoteRequest | Submitted | Pending review |
QuoteRequest | Accepted | In negotiation |
Quote | Pending | Quote ready |
Quote | RenegotiationRequested | Renegotiation in progress |
Quote | RenegotiationAddressed | Updated quote ready |
Quote | Accepted | Accepted |
Quote | Declined | Declined |
QuoteRequest | Withdrawn | Withdrawn |
Pattern 5: Client State Hooks
| Hook | Returns |
|---|---|
useQuotes() | Paginated list of quotes for the active BU |
useQuote(id) | Single quote detail; pass an empty/null key to skip |
useQuoteThread(stagedQuoteId) | All rounds sharing the same stagedQuote.id; pass an empty/null key to skip |
useQuotesByQuoteRequest(qrId) | Quotes linked to a specific quote request |
useQuoteRequests() | Paginated list of quote requests for the active BU |
useQuoteRequest(id) | Single quote request detail; pass an empty/null key to skip |
[KEY, businessUnitKey] so data is isolated per BU. Pass an empty/null key to defer fetching until the required ID is available.Find the stack'sconcept-mapping.mdfor concrete client-state and cache implementation.
Checklist
-
Quote.sellerCommentused (notStagedQuote.sellerComment) for per-round display - Always
expand: ['quoteRequest', 'stagedQuote']when fetching quotes - All quote API calls via as-associate chain
- Dashboard groups quotes by
stagedQuote.id— one row per thread - Thread state driven by most recent
Quote.quoteState; falls back toQuoteRequest.quoteRequestState - Client-state hooks use
[KEY, businessUnitKey]tuple for cache isolation - Actions (accept, decline, renegotiate) handled by quote-actions.md
Session & Business Unit Context
BusinessUnitContext that drives the UI.Table of Contents
- Pattern 1: Session Fields
- Pattern 2: Store Channel Resolution
- Pattern 3: BU Selection — Writing the Session
- Pattern 4: BusinessUnitContext
- Pattern 5: Reading Session Fields in Server Endpoints
- Checklist
Pattern 1: Session Fields
// WRONG — missing B2B fields; product prices and cart scoping will be wrong
await setSession(response, {
customerId: customer.id,
cartId: undefined,
locale: 'en-US', // BCP-47 locale must come with currency and country
});
setSession call:// <server>/types — the full SessionData interface
export interface SessionData {
// Auth
customerId?: string;
customerEmail?: string;
customerFirstName?: string;
customerLastName?: string;
// Active cart
cartId?: string;
// B2B context — resolved from the active store at login / BU-select
businessUnitKey?: string; // commercetools Business Unit key — used as associateId context
storeKey?: string; // commercetools Store key — scopes product visibility
supplyChannelId?: string; // commercetools Channel ID — used for inventory display
distributionChannelId?: string; // commercetools Channel ID — used for price scoping
productSelectionId?: string; // commercetools ProductSelection ID — restricts visible products
/** Customer group IDs for priceCustomerGroupAssignments in product search */
accountGroupIds?: string[];
// Locale (always write all three together)
locale?: string; // BCP-47, e.g. 'de-DE' — used for both framework locale routing and all commercetools API calls
currency?: string; // ISO 4217, e.g. 'EUR'
country?: string; // ISO 3166-1 alpha-2, e.g. 'DE'
}
| Field | Example | Used in |
|---|---|---|
customerId | "abc123" | associateId in every as-associate chain call |
businessUnitKey | "acme-eu" | businessUnitKey in every as-associate chain call |
storeKey | "acme-eu-de" | storeProjection in product search; cart store reference |
distributionChannelId | "ch-abc" | priceChannel in product search; line item distributionChannel |
supplyChannelId | "sc-abc" | Passed to mapProduct for availability display |
productSelectionId | "ps-abc" | Stored for reference; commercetools auto-enforces via storeKey |
accountGroupIds | ["cg-abc"] | priceCustomerGroupAssignments in product search |
locale | "de-DE" | framework locale routing and all commercetools API calls: cart locale, order locale, product language |
currency | "CHF" | Cart currency, price display |
country | "CH" | priceCountry in product search; cart country |
Pattern 2: Store Channel Resolution
apiRoot.stores() in every server endpoint — redundant network calls:// WRONG — called on every cart add, every product search
const store = await apiRoot.stores().withKey({ key: storeKey }).get().execute();
const distributionChannelId = store.body.distributionChannels?.[0]?.id;
getStoreChannelData(storeKey) with module-level Map cache:// <server>/ct/stores
export interface StoreChannelData {
storeId: string | undefined;
supplyChannelId: string | undefined;
distributionChannelId: string | undefined;
productSelectionId: string | undefined;
}
const storeDataCache = new Map<string, StoreChannelData>();
export async function getStoreChannelData(storeKey: string): Promise<StoreChannelData> {
if (storeDataCache.has(storeKey)) return storeDataCache.get(storeKey)!;
try {
const { body } = await apiRoot.stores().withKey({ key: storeKey }).get().execute();
const data: StoreChannelData = {
storeId: body.id,
supplyChannelId: body.supplyChannels?.[0]?.id,
distributionChannelId: body.distributionChannels?.[0]?.id,
productSelectionId: body.productSelections?.[0]?.productSelection?.id,
};
storeDataCache.set(storeKey, data);
return data;
} catch {
return { storeId: undefined, supplyChannelId: undefined, distributionChannelId: undefined, productSelectionId: undefined };
}
}
storeDataCacheis a module-levelMap— it persists for the server instance lifetime with no TTL. It is the single source of truth for all store → channel mappings. All call sites importgetStoreChannelDatafrom this file.
Pattern 3: BU Selection — Writing the Session
businessUnitKey and storeKey without channel data:// WRONG — products will return unscoped prices; cart creation will fail
await setSession(response, { ...session, businessUnitKey, storeKey });
POST .../business-units/[id]/select):- Reads the session; returns Not authenticated (401) unless
session.customerIdis present. - Reads
{ businessUnitKey, storeKey }from the request body; returns a validation error (400) if either is missing. - Resolves the channel IDs from the store, then writes the full session and returns success:
// Resolve distributionChannelId, supplyChannelId, productSelectionId
const { supplyChannelId, distributionChannelId, productSelectionId } =
await getStoreChannelData(storeKey);
await setSession({
...session,
businessUnitKey,
storeKey,
supplyChannelId,
distributionChannelId,
productSelectionId,
// cartId intentionally kept — existing cart is still valid for the new BU+store
});
Find the stack'sdata-loading.mdfor concrete server endpoint patterns.
The session update is atomic — all five B2B fields (businessUnitKey,storeKey,supplyChannelId,distributionChannelId,productSelectionId) are written in onesetSessioncall, so there is never a partially-updated state. Where the session is stored (a signed token or a server-side store) is a stack choice.
Pattern 4: BusinessUnitContext
GET /<api>/business-units independently — N fetches, no shared state, no auto-invalidation.BusinessUnitProvider owns all BU state, backed by client state, and auto-invalidates on logout. It exposes currentBusinessUnit, currentStore, businessUnits, plus selectBusinessUnit/selectStore actions via the useBusinessUnit() hook. Behaviour the provider implements:- BU list: loaded from client state keyed by
KEY_BUSINESS_UNITS, with an empty/null key while logged out (skips the fetch). Endpoint:GET /<api>/business-units. - Auto-select on first load: when the BU list resolves and nothing is selected yet, pick the persisted BU (from the session) or the first BU, take its first store, and call the BU-select endpoint; on success set
currentBusinessUnitandcurrentStore. selectBusinessUnit(id): find the BU, take its first store, call the BU-select endpoint, and update current BU + store on success.selectStore(storeKey): within the current BU, find the store, call the BU-select endpoint, and update the current store on success.- Clear on logout: reset current BU/store and the auto-select guard, and clear the
KEY_BUSINESS_UNITSclient state-manager/cache entry so the next login re-picks.
Find the stack'sconcept-mapping.mdfor concrete client-state and cache implementation.
Pattern 5: Reading Session Fields in Server Endpoints
// WRONG — missing BU/store context
const cart = await createCart(session.customerId, 'USD', 'US');
const products = await searchProducts({ query: '...' });
customerId is absent and a No-active-business-unit error (400) when businessUnitKey/storeKey are absent, then call the commercetools helpers:// Pass session to product search — ProductApi reads all B2B fields internally
const results = await searchProducts(query, session);
// Pass all required args to cart helper
const cart = await createCart(
customerId,
customerId, // associateId = customerId in B2B
businessUnitKey,
storeKey,
session.currency ?? 'USD',
session.country ?? 'US',
);
Checklist
-
getStoreChannelData(storeKey)called when resolving store → channel mapping - All five B2B session fields written together in one
setSession()call -
businessUnitKeyandstoreKeyvalidated before any B2B server endpoint proceeds -
sessionpassed tosearchProducts()— never call with empty/partial session -
BusinessUnitProviderwraps the locale layout and is insideAuthProvider - client state-manager/cache keys for BU-scoped data use
[KEY, businessUnitKey]tuple -
KEY_BUSINESS_UNITSclient state-manager/cache entry cleared on logout
Shopping Lists — B2B (Wishlists + Purchase Lists)
| Type | Scope | Who sees it |
|---|---|---|
| Wishlist | Personal (customer-owned) | Only the customer |
| Purchase List | BU-shared | All associates in the business unit |
ShoppingList. The separation is entirely in which API chain you use and which session fields you require. Implement them as two separate namespaces (<server>/ct/wishlists and <server>/ct/purchase-lists) — do not try to unify them in one file.Table of Contents
- B2B Extension: Wishlists (Personal)
- B2B Extension: Purchase Lists (BU-Scoped)
- B2B Extension: Client State and Mutation
- B2B Extension: UI — Header Icon
- B2B Extension: UI — PDP Add-to-List Flow
- Checklist
B2B Extension: Wishlists (Personal)
apiRoot.shoppingLists() chain, enforce ownership with list.customer?.id === customerId in app code, and do not include a store field in the create draft. Server endpoints validate customerId only. The client state-manager/cache key is [KEY_WISHLISTS, customerId]./wishlists/ (personal space), not under /dashboard/ (BU space). This boundary makes the scoping visible in the URL.B2B Extension: Purchase Lists (BU-Scoped)
API Chain
All purchase list operations go through the as-associate chain. commercetools enforces BU membership at the API layer — an associate without access receives a 403, so app-level ownership checks are not needed.
apiRoot
.asAssociate()
.withAssociateIdValue({ associateId })
.inBusinessUnitKeyWithBusinessUnitKeyValue({ businessUnitKey })
.shoppingLists()
associateId is always session.customerId. businessUnitKey is always session.businessUnitKey. Both are mandatory — never make them optional or fall back to a default.Create Draft
customer and store:name: { [locale]: name }
customer: { id: customerId, typeId: 'customer' }
store: { typeId: 'store', key: storeKey }
store field ties the list to the active store so pricing and availability stay consistent when items are moved to cart.Permissions
Create, Add item, Remove item, Rename, Delete) on CreatePurchaseLists / UpdatePurchaseLists via usePermissions(). Hide the purchase lists nav item when the associate lacks ViewPurchaseLists.B2B Extension: Client State and Mutation
| List type | client state-manager/cache key | Fires when |
|---|---|---|
| Wishlist | [KEY_WISHLISTS, customerId] | customerId is resolved |
| Purchase list | [KEY_PURCHASE_LISTS, businessUnitKey] | businessUnitKey is resolved |
businessUnitKey is not yet available is critical — it prevents fetching as the wrong BU or before context is ready.[KEY_PURCHASE_LISTS, businessUnitKey]. The businessUnitKey in the invalidation must come from the same source as the hook — usually currentBusinessUnit.key from useBusinessUnit() — so the state-manager/cache entry matches exactly.When the user switches business unit, the purchase lists cache auto-invalidates because the key tuple changes. Wishlist caches are unaffected by BU switches.
Find the stack'sconcept-mapping.mdfor concrete client-state and cache implementation.
B2B Extension: UI — Header Icon
/wishlists, empty state for unauthenticated users.ViewPurchaseLists.B2B Extension: UI — PDP Add-to-List Flow
In B2B, clicking "Save" or "Add to list" on a PDP opens a modal or popover rather than a heart toggle. The flow supports both wishlists and purchase lists from the same entry point.
- Show two sections: "My Wishlists" (personal) and "Purchase Lists" (BU-shared, only if the associate has
ViewPurchaseLists) - Each section lists existing lists with a checkbox — checking one adds the current product/variant to that list
- A "+ New wishlist" inline input at the bottom of the "My Wishlists" section lets the customer create and add in one step
- A "+ New purchase list" inline input (gated on
CreatePurchaseListspermission) at the bottom of the purchase list section does the same - On confirm, fire all selected add-item mutations in parallel; show a single success toast when all resolve
variantId. This lets associates see at a glance where the product is saved and toggle membership.Checklist
- commercetools calls use project-level
apiRoot.shoppingLists()— not the as-associate chain - Ownership check in app code after single-list fetch:
list.customer?.id === customerId→ 404 on mismatch - Create draft has no
storefield - Server endpoints validate
customerIdonly - client state-manager/cache key is
[KEY_WISHLISTS, customerId] - Pages at
/wishlists/— not under/dashboard/
- All commercetools calls use
asAssociateInStore(associateId, businessUnitKey)— not project-level -
store: { key: storeKey }included in create draft - Server endpoints validate
customerIdANDbusinessUnitKey - client state-manager/cache key is
[KEY_PURCHASE_LISTS, businessUnitKey]; fires only whenbusinessUnitKeyis resolved - All mutations invalidate
[KEY_PURCHASE_LISTS, businessUnitKey]after completing - Permission gates:
ViewPurchaseListsfor nav,CreatePurchaseLists/UpdatePurchaseListsfor write actions - Dashboard nav item hidden when associate lacks
ViewPurchaseLists - Pages under
/dashboard/purchase-lists/
- "Save to list" button opens modal (not a simple heart toggle)
- Modal shows both wishlists and purchase lists in separate sections
- Existing membership pre-fills checkboxes
- Inline create for both list types within the modal
- Add-item mutations fire in parallel on confirm
-
expand: ['lineItems[*].variant']on all list fetches
Variant Selector Configuration
<server>/ct/variant-config.All variant selector behaviour on the PDP is controlled in this one file. No component changes needed for common adjustments.
VARIANT_SELECTOR_BLOCKLIST
Attribute names to never render as variant selectors. Add any attribute here to prevent it from appearing as a selection option:
// <server>/ct/variant-config
export const VARIANT_SELECTOR_BLOCKLIST: string[] = [
'internal-code', // just the attribute name, not 'variants.attributes.*'
];
VARIANT_RENDERER_MAP
'pill':export type VariantRenderer = 'pill' | 'color';
export const VARIANT_RENDERER_MAP: Record<string, VariantRenderer> = {
color: 'color', // → circular swatch using COLOR_HEX
model: 'pill', // → text button (same as default, explicit for clarity)
};
| Renderer | Component | Use for |
|---|---|---|
'pill' | Text button | Any attribute (default) |
'color' | Circular swatch | Color attributes — fills from COLOR_HEX |
COLOR_HEX
Maps lowercase color names/keys to CSS color values for swatch rendering:
export const COLOR_HEX: Record<string, string> = {
black: '#1A1A1A',
gray: '#9CA3AF',
white: '#F9FAFB',
blue: '#3B82F6',
yellow: '#EAB308',
red: '#EF4444',
green: '#22C55E',
silver: '#C0C0C0',
gold: '#D97706',
multicolored: 'linear-gradient(135deg, #3B82F6, #EC4899, #22C55E)',
// add more as needed
};
'yellow', 'black'.VARIANT_SORT_ORDER
Display order for variant selector groups (left to right). Attributes listed here appear first; unlisted attributes are appended after:
export const VARIANT_SORT_ORDER: string[] = ['color'];
// color swatch first, then other attributes in commercetools-defined order
PDP_INFO_ATTRIBUTES
Attribute names to render as informational text sections on the PDP, below the product description. These are excluded from the product details grid:
export const PDP_INFO_ATTRIBUTES: string[] = ['mobility', 'capacity', 'iso45001'];
Each listed attribute renders with its localised commercetools label and its value as preformatted text (preserving line breaks). Attributes with no value on the active variant are silently skipped.
How Variant Navigation Works
<Link href={opt.targetUrl}>. The targetUrl is /{locale}/{categorySlug}/p/{variantSku}.// components/product/VariantSelector.tsx
export interface VariantOption {
label: string;
targetUrl: string; // URL for this variant
colorCode?: string; // hex from COLOR_HEX
isActive: boolean; // current SKU matches
isAvailable: boolean; // stock from supplyChannelId
}
<span> (not <Link>) with opacity-35 cursor-not-allowed styling.How Availability Is Determined
isAvailable on each VariantOption comes from the supplyChannelId in session:// In the variant options builder (<server>/ct/product-api or mapper)
const channelStock = variant.availability?.channels?.[session.supplyChannelId];
const isAvailable = channelStock ? channelStock.availableQuantity > 0 : true;
supplyChannelId is not in session (no BU selected), falls back to isOnStock — acceptable for unauthenticated users.Adding a New Color
- Add the commercetools attribute name to
VARIANT_RENDERER_MAPwithrenderer: 'color' - Add hex codes to
COLOR_HEXfor each color value in the commercetools enum - Ensure the commercetools attribute is a variant attribute (not product-level)
Checklist
- New color attribute: add to
VARIANT_RENDERER_MAP+ hex values toCOLOR_HEX - Hidden selector: add attribute name to
VARIANT_SELECTOR_BLOCKLIST - Attribute display order: add to
VARIANT_SORT_ORDER - Info-only attribute (not a selector): add to
PDP_INFO_ATTRIBUTES - Availability uses
channels[supplyChannelId]when BU is active
Checkout — B2C
Address Step — Saved Addresses from Logged-In Customer
useAccount(). On load, auto-select the address flagged isDefaultShipping for the shipping field and isDefaultBilling for the billing field. The user may pick a different saved address from the list or enter a new one manually.Saved addresses are display-only in the selector — editing or adding a new address during checkout writes only to the cart (when moving to the next step), not back to the customer account.
Order Placement Sequence
addresses (shipping + billing) → shipping method → payment (Checkout SDK) → confirmation
- Addresses step — shipping and billing addresses persisted to cart in real time
- Shipping step — user selects a method
- Payment step — Checkout frontend SDK mounts, handles payment capture and order placement
- Confirmation — SDK signals completion → clear
cartIdfrom session → redirect to/checkout/confirmation?orderId=<id>
POST /<api>/checkout route for order creation.Reference: See the Checkout frontend SDK implementation skill for SDK setup and the order-completion event handler.
Checklist
- Extends shared checkout patterns (page structure, shipping methods, payment SDK, confirmation)
- Address step auto-selects
isDefaultShipping/isDefaultBillingfromuseAccount() - Saved address list shown as selectable options; manual entry also allowed
- Address changes debounced to
PATCH /<api>/cart(inherited from shared) - Shipping method filtered by session currency (inherited from shared)
- Order placement driven by Checkout frontend SDK — no custom order route
-
cartIdcleared from session after SDK order completion event
Customer Authentication — B2C Extensions
Anonymous Cart Merge
anonymousCartId and anonymousCartSignInMode to apiRoot.login().post() when a guest cart exists:// <server>/ct/auth
export async function signIn(email: string, password: string, anonymousCartId?: string) {
const { body } = await apiRoot.login().post({
body: {
email,
password,
...(anonymousCartId && {
anonymousCartId,
anonymousCartSignInMode: 'MergeWithExistingCustomerCart',
}),
},
}).execute();
return body; // body.cart is the merged cart when merge occurred
}
session.cartId as anonymousCartId, calls signIn, then writes body.cart.id back into the session as the new cartId.Register
apiRoot.customers().post() creates the account but does not log the customer in. Immediately call signIn after registration so the session is populated and the anonymous cart is merged.Protected Account Layout
/login?redirect=<path> when the current account state resolves to null (signed out). While the account state is still loading (undefined), render nothing to avoid a layout flash; render the children once a customer is confirmed.- Read the current account from the client state hook (see the reference).
- Three states:
undefined(loading) → render nothing;null(signed out) → issue a client-side redirect to/login?redirect=<encoded current path>using the framework's client navigation and current-path access; a resolved customer → render the children.
Find the adapter'sconcept-mappingfile for Concrete protected-layout component. Example: see the Next.js stack → Client state hooks.
Checklist
-
signInpassesanonymousCartId+anonymousCartSignInMode: 'MergeWithExistingCustomerCart'when a guest cart exists - Login server endpoint writes
cart.idfrom the commercetools response back into the session - Register calls
signInimmediately afterapiRoot.customers().post() - Account layout redirects to
/login?redirect=<path>onnull; returnsnullwhile loading
B2C BOPIS (Buy Online, Pick Up In Store)
Store channels expose per-store inventory on the PDP. A ChannelSelector lets customers choose delivery or pickup. The chosen supply channel is attached to cart line items so fulfilment knows which store ships or holds the item.
Key Takeaways
channelId string — always { typeId: 'channel', id: channelId } in cart actions.InventorySupply role for the store selector. Distribution or fulfilment-only channels should not appear in the pickup UI.localStorage. ChannelSelector saves 'delivery' or 'pickup' across navigation. Switching to delivery calls onSelect(null) to clear the supply channel.KEY_CHANNELS / keyChannel(id) from <server>/cache-keys. Don't inline cache key strings in hooks.Anti-Patterns
| Anti-pattern | Correct approach |
|---|---|
supplyChannel: channelId (string) | supplyChannel: { typeId: 'channel', id: channelId } |
| Showing all channels in the selector | channels.filter(c => c.roles?.includes('InventorySupply')) |
| Hardcoded string key in the client state hook | Use KEY_CHANNELS from <server>/cache-keys |
Reference
| Task | Reference |
|---|---|
Channels API (<server>/ct/channels), server endpoints, supply channel on cart, per-channel inventory, cache keys, useChannels hook, ChannelSelector UI, pickup badge in CartItem | bopis.md |
BOPIS (Buy Online, Pick Up In Store)
{ typeId: 'channel', id: channelId }.BOPIS adds store channels to the cart and shows per-store stock on the PDP.
Table of Contents
- Pattern 1: Channels API
- Pattern 2: Cart Supply Channel
- Pattern 3: Per-Channel Inventory
- Pattern 4: Cache Keys
- Pattern 5: useChannels Hook
- Pattern 6: Type Extensions
- Pattern 7: UI Components
Pattern 1: Channels API
// /<server>/ct/channels
export async function getAllChannels(): Promise<Channel[]> {
const { body } = await ctClient
.channels()
.get({ queryArgs: { limit: 500 } })
.execute();
return body.results.map(mapChannel);
}
export async function getChannelById(id: string): Promise<Channel | null> {
const { body } = await ctClient.channels().withId({ ID: id }).get().execute();
return mapChannel(body);
}
export async function getChannelByKey(key: string): Promise<Channel | null> {
const { body } = await ctClient.channels().withKey({ key }).get().execute();
return mapChannel(body);
}
Server endpoints expose these helpers to the client:
GET /channels→ returnsgetAllChannels().GET /channels/:id→ returnsgetChannelById(id), or a 404 not-found response when the channel does not exist.
Find the stack'sdata-loading.mdfor concrete server endpoints pattern implementation.
Pattern 2: Cart Supply Channel
// BAD
supplyChannel: channelId // string only — commercetools rejects this
typeId:// /<server>/ct/cart
export async function addLineItem(
cartId: string,
cartVersion: number,
productId: string,
variantId: number,
quantity: number,
supplyChannelId?: string
) {
const { body } = await apiRoot.carts().withId({ ID: cartId }).post({
body: {
version: cartVersion,
actions: [{
action: 'addLineItem',
productId,
variantId,
quantity,
...(supplyChannelId && {
supplyChannel: { typeId: 'channel', id: supplyChannelId }, // ← correct format
}),
}],
},
}).execute();
return body;
}
{ productId, variantId, quantity, supplyChannelId } from the request and the cartId from the session, loads the cart with getCart(session.cartId), then calls addLineItem(cartId, cart.version, productId, variantId, quantity, supplyChannelId) and returns the updated cart.Pattern 3: Per-Channel Inventory
Accessing per-store stock:
const variant = product.masterVariant;
const storeStock = variant.availability?.channels?.[channelId];
const isInStock = storeStock?.isOnStock ?? false;
const availableQty = storeStock?.availableQuantity ?? 0;
Pattern 4: Cache Keys
// <root-dir>/<server>/cache-keys
export const KEY_CHANNELS = 'channels';
export const keyChannel = (id: string) => `channel-${id}`;
Use these as the client state-manager/cache key and as the server-side cache-invalidation tag in server endpoints that mutate channels.
Pattern 5: useChannels Hook
Two client state hooks back the channel UI:
useChannels()— cache keyKEY_CHANNELS; fetchesGET /<api>/channels; returns{ channels, error, isLoading }(defaultingchannelsto[]). Disable refetch-on-focus.useChannel(id)— cache key[keyChannel(id), id], ornullwhenidis null so it does not fetch; fetchesGET /<api>/channels/:id; returns{ channel, error, isLoading }(defaultingchanneltonull). Disable refetch-on-focus.
KEY_CHANNELS / keyChannel from <server>/cache-keys and the Channel type from <server>/types — do not inline key strings.Find the stack'sconcept-mapping.mdfor concrete client-cache implementation.
Pattern 6: Type Extensions
// <root-dir>/types/index.ts
export interface CartLineItem {
id: string;
sku: string;
name: string;
quantity: number;
price: Money;
totalPrice: Money;
imageUrl?: string;
supplyChannelId?: string; // ← new: which store this item ships from / is collected at
}
export interface VariantAvailability {
isOnStock: boolean;
availableQuantity?: number;
channels?: Record<string, VariantChannelAvailability>;
}
export interface VariantChannelAvailability {
isOnStock: boolean;
availableQuantity?: number;
}
Pattern 7: UI Components
localStorage:- Reads the channel list from
useChannels()and derives the pickup options withchannels.filter((c) => c.roles?.includes('InventorySupply'))— onlyInventorySupplychannels appear in the pickup selector. - Holds a local
'delivery' | 'pickup'mode, initialised fromlocalStorage(deliveryMode) and written back whenever it changes. - Renders a Delivery button, a Pick Up In Store button, and — when in pickup mode — a
<select>of the pickup channels that callsonSelect(channelId). - Switching to delivery calls
onSelect(null)to clear the supply channel.
PickupBadge client component takes a channelId, resolves the channel via useChannel(channelId), and renders the store name (e.g. "Pickup: {channel.name}"), returning nothing while unresolved. In CartItem, render it only when item.supplyChannelId is set: {item.supplyChannelId && <PickupBadge channelId={item.supplyChannelId} />}.Checklist
-
getAllChannels,getChannelById,getChannelByKeyimplemented in<server>/ct/channels - Server endpoints
GET /channelsandGET /channels/:idexposegetAllChannels/getChannelById -
addLineItemacceptssupplyChannelIdand uses{ typeId: 'channel', id }reference - The add-to-cart server endpoint passes
supplyChannelIdthrough toaddLineItem -
KEY_CHANNELSandkeyChannel(id)added to<server>/cache-keys -
useChannels()anduseChannel(id)client state hooks created with a deduping interval -
CartLineItem.supplyChannelId?: stringadded to types -
VariantAvailabilityandVariantChannelAvailabilityinterfaces added -
ChannelSelectorpersists delivery mode tolocalStorage - Pickup badge visible in cart line items when
supplyChannelIdis set
B2C Product Bundles
parentKey. All cart mutations cascade from parent to all matching children. bundleItems() groups them before any component sees the data.Key Takeaways
node tools/create-bundles-custom-type.mjs creates the custom type line-item-additional-info with a parentKey String field. This must exist in commercetools before any bundle add-to-cart calls.parentKey is the cascade link. Parent line items get a UUID key. Children get custom.fields.parentKey set to that UUID. Without this, removing the parent leaves orphaned children in the cart.applyCartAction call. changeLineItemQuantity and removeLineItem must batch parent + all child actions together — not sequential calls that can race on the cart version.bundleItems() runs in the cart data fetcher, not in components. Apply grouping once in the cart client state fetcher so every component receives pre-grouped data. No grouping logic in components.cartItemCount() excludes children from the badge. Filter !i.parentKey before summing quantities — children are display-only sub-rows.Anti-Patterns
| Anti-pattern | Correct approach |
|---|---|
Adding children without a parentKey link | Parent gets UUID key; children get custom.fields.parentKey referencing it |
| Removing parent without cascade | Find children by parentKey === parent.key, batch all removals in one action call |
| Grouping in a component | Apply bundleItems() in the cart data fetcher — components receive pre-grouped data |
| Counting children in cart badge | cartItemCount() filters !i.parentKey before summing |
| Sequential action calls for cascades | Batch all actions in a single applyCartAction to avoid 409 version conflicts |
Reference
| Task | Reference |
|---|---|
| commercetools setup script, CartLineItem extension, cascade cart operations, cart-mapper, items endpoint, bundle-utils, cart fetcher override, CartItem UI, BundleAddToCart component | bundles.md |
Bundles
parentKey). Without it, removing the parent leaves orphaned child line items in the cart.parentKey custom field. All cart operations cascade from parent to children.Table of Contents
- Pattern 1: commercetools Setup
- Pattern 2: CartLineItem Extension
- Pattern 3: Cart Operations
- Pattern 4: cart-mapper.ts
- Pattern 5: Add-to-Cart Endpoint
- Pattern 6: bundle-utils.ts
- Pattern 7: Cart Fetcher Override
- Pattern 8: UI
Pattern 1: commercetools Setup
parentKey field:node tools/create-bundles-custom-type.mjs
line-item-additional-info with a parentKey String field.In commercetools Merchant Center, add a bundle attribute to the product type:
- Type:
SetofReferencetoProduct - Name:
bundledProducts(or similar) - Searchable: no
Pattern 2: CartLineItem Extension
// <server>/types/index.ts
export interface CartLineItem {
id: string;
sku: string;
name: string;
quantity: number;
price: Money;
totalPrice: Money;
imageUrl?: string;
// Bundle fields
key?: string; // UUID — set on parent line items
parentKey?: string; // references parent's key — set on children
bundledItems?: CartLineItem[]; // populated by bundleItems() — not from commercetools
}
Pattern 3: Cart Operations
// BAD — no parentKey, no way to cascade removal
await addLineItem(cartId, version, childSku, 1);
custom.fields.parentKey:// /<server>/ct/cart
import { v4 as uuidv4 } from 'uuid';
export async function addLineItem(
cartId: string, cartVersion: number, productId: string, variantId: number, quantity: number, key?: string
) {
const { body } = await apiRoot.carts().withId({ ID: cartId }).post({
body: {
version: cartVersion,
actions: [{ action: 'addLineItem', productId, variantId, quantity, ...(key && { key }) }],
},
}).execute();
return body;
}
export async function addBundledLineItems(
cartId: string, cartVersion: number, parentKey: string, childSkus: string[]
) {
const actions: CartUpdateAction[] = childSkus.map((sku) => ({
action: 'addLineItem',
sku,
quantity: 1,
custom: {
type: { key: 'line-item-additional-info' },
fields: { parentKey }, // ← links child to parent
},
}));
const { body } = await apiRoot.carts().withId({ ID: cartId }).post({
body: { version: cartVersion, actions },
}).execute();
return body;
}
// Cascade quantity change to all children
export async function changeLineItemQuantity(
cart: Cart, lineItemId: string, quantity: number
) {
const item = cart.lineItems.find((i) => i.id === lineItemId);
if (!item) throw new Error('Line item not found');
const actions: CartUpdateAction[] = [
{ action: 'changeLineItemQuantity', lineItemId, quantity },
];
if (item.key) {
const children = cart.lineItems.filter(
(i) => i.custom?.fields?.parentKey === item.key
);
for (const child of children) {
actions.push({ action: 'changeLineItemQuantity', lineItemId: child.id, quantity });
}
}
const { body } = await apiRoot.carts().withId({ ID: cart.id }).post({
body: { version: cart.version, actions },
}).execute();
return body;
}
// Cascade removal to all children
export async function removeLineItem(cart: Cart, lineItemId: string) {
const item = cart.lineItems.find((i) => i.id === lineItemId);
if (!item) throw new Error('Line item not found');
const actions: CartUpdateAction[] = [
{ action: 'removeLineItem', lineItemId },
];
if (item.key) {
const children = cart.lineItems.filter(
(i) => i.custom?.fields?.parentKey === item.key
);
for (const child of children) {
actions.push({ action: 'removeLineItem', lineItemId: child.id });
}
}
const { body } = await apiRoot.carts().withId({ ID: cart.id }).post({
body: { version: cart.version, actions },
}).execute();
return body;
}
Pattern 4: cart-mapper.ts
key and parentKey from the commercetools line item:// /<server>/mappers/cart-mapper
function mapLineItem(ctItem: CtLineItem): CartLineItem {
return {
id: ctItem.id,
sku: ctItem.variant?.sku ?? '',
name: getLocalizedString(ctItem.name, locale),
quantity: ctItem.quantity,
price: mapMoney(ctItem.price.value),
totalPrice: mapMoney(ctItem.totalPrice),
imageUrl: ctItem.variant?.images?.[0]?.url,
key: ctItem.key,
parentKey: ctItem.custom?.fields?.parentKey,
};
}
Pattern 5: Add-to-Cart Endpoint
{ productId, variantId, quantity, bundledSKUList } from the request and the cartId from the session, then:- Loads the cart with
getCart(session.cartId). - Generates a
parentKey(a UUID viauuidv4()) only whenbundledSKUListis non-empty. - Adds the parent with
addLineItem(cart.id, cart.version, productId, variantId, quantity, parentKey). - When a
parentKeyand bundled SKUs exist, adds the children withaddBundledLineItems(cart.id, cart.version, parentKey, bundledSKUList). - Returns the updated cart.
Find the stack'sdata-loading.mdfor concrete server endpoints pattern implementation.
Pattern 6: bundle-utils.ts
// <server/utils/bundle-utils.ts
/**
* Groups children under their parent line item.
* Children (items with parentKey) are moved into parent.bundledItems[].
*/
export function bundleItems(items: CartLineItem[]): CartLineItem[] {
const parents = items.filter((i) => !i.parentKey);
const children = items.filter((i) => i.parentKey);
return parents.map((parent) => ({
...parent,
bundledItems: children.filter((c) => c.parentKey === parent.key),
}));
}
/**
* Count only parent/standalone items (exclude children from badge count).
*/
export function cartItemCount(items: CartLineItem[]): number {
return items.filter((i) => !i.parentKey).reduce((sum, i) => sum + i.quantity, 0);
}
Pattern 7: Cart Fetcher Override
bundleItems in the cart data fetcher so all components receive pre-grouped data. The cart client state hook (cache key KEY_CART) fetches GET /<api>/cart and, before returning, maps the response line items through bundleItems(...):return { ...data.cart, lineItems: bundleItems(data.cart.lineItems ?? []) };
bundleItems from <server>/bundle-utils and KEY_CART from <server>/cache-keys. The hook can accept server-fetched data as its initial value and refetch on focus.Find the stack'sconcept-mapping.mdfor concrete client-state and cache implementation.
Pattern 8: UI
item.name, formatMoney(item.price)), then maps item.bundledItems? into indented sub-rows showing each child's name (e.g. + {child.name}). Children are display-only.bundledSKUList to the add-to-cart endpoint:productId, variantId, and bundledSKUs. On click it calls the cart context's addToCart(productId, variantId, 1, { bundledSKUList: bundledSKUs }) (managing a local loading flag) — addToCart POSTs to the add-to-cart endpoint and opens the mini-cart, with bundledSKUList passed as extra data the extended endpoint picks up. No direct fetch in the component.Checklist
-
node tools/create-bundles-custom-type.mjsrun — custom typeline-item-additional-infowithparentKeyfield exists in commercetools -
CartLineItemextended withkey,parentKey,bundledItems -
addLineItemaccepts optionalkeyparameter -
addBundledLineItemscreates children withcustom.fields.parentKey -
changeLineItemQuantityandremoveLineItemcascade to children by matchingparentKey -
cart-mapper.tsmapsctItem.keyandctItem.custom.fields.parentKey - The add-to-cart server endpoint generates a UUID parent key and calls
addBundledLineItems -
bundleItems()andcartItemCount()inlib/bundle-utils.ts -
bundleItemsapplied in the cart data fetcher (cart client state hook override) -
CartItemrendersitem.bundledItemsas sub-rows (uses the framework's image primitive, not<img>) -
BundleAddToCartuses the cart context'saddToCart()— no directfetchin component
B2C Promotions & Discounts
variant.price.discounted and require search expansion to show the name. Cart Discounts silently reduce totals. Discount Codes trigger Cart Discounts and are managed by the existing DiscountCodeForm component — don't re-implement it.Key Takeaways
discountName is always undefined. Add masterVariant.price.discounted.discount and variants[*].price.discounted.discount to productProjectionParameters.expand. The mapper then reads ctPrice.discounted.discount.obj.name to surface the name.DiscountCodeForm already exists — import it, don't rewrite it. It handles apply + remove + KEY_CART client state-manager/cache invalidation via the cart-discount server endpoint (apply and remove). Just drop <DiscountCodeForm /> where needed.content/message section. Static is simpler; CMS-driven via lib/layout.ts allows content changes without deploys and supports localized strings.Anti-Patterns
| Anti-pattern | Correct approach |
|---|---|
| No expand on discount refs in search | Add both masterVariant and variants[*] discount expand to search params |
| Custom discount code fetch/form | Import <DiscountCodeForm /> from components/cart/ — it already handles everything |
| Hardcoding discount name string | Map from expanded ctPrice.discounted.discount.obj.name in <server>/mappers/product |
| Product Discount badge without expand | Name is undefined without the expand — always expand before mapping |
Reference
| Task | Reference |
|---|---|
| Discount types overview, product discount expand + mapper + ProductCard, DiscountCodeForm usage, promotion banner (static + CMS-driven) | promotions.md |
Promotions & Discounts
Table of Contents
- Pattern 1: Discount Types Overview
- Pattern 2: Product Discount Display
- Pattern 3: Discount Code Form
- Pattern 4: Promotion Banner
Pattern 1: Discount Types Overview
| Type | How it works | Where it surfaces |
|---|---|---|
| Product Discount | Changes variant.price.discounted on matching products | Badge + strikethrough on ProductCard and PDPPrice |
| Cart Discount | Reduces lineItem.totalPrice and/or cart.totalPrice silently | Line item price difference, cart total reduction |
| Discount Code | Customer-entered code that triggers a Cart Discount | Applied chip in cart, cart.discountCodes[] |
Pattern 2: Product Discount Display
discountName is undefined.// BAD — discount ref not expanded
const productProjectionParameters = {
body: {
// No expand — variant.price.discounted.discount is just { id: '...' }
},
};
// In component: product.price.discounted?.discountName → undefined
masterVariant and variants discount references:See product-search.md — Pattern 6: Discount Expansion for the full explanation and mapper code.
// /<server>/ct/products
const productProjectionParameters = {
body: {
query: { ... },
productProjectionParameters: {
expand: [
'masterVariant.price.discounted.discount',
'variants[*].price.discounted.discount',
],
},
},
};
// /<server>/mappers/product
function mapPrice(ctPrice: CtPrice): Price {
return {
value: mapMoney(ctPrice.value),
discounted: ctPrice.discounted
? {
value: mapMoney(ctPrice.discounted.value),
discountName: (ctPrice.discounted.discount?.obj as any)?.name?.[locale],
}
: undefined,
};
}
// <root-dir>/components/product/ProductCard.tsx
{product.price.discounted && (
<>
<span className="line-through text-gray-400">{formatMoney(product.price.value)}</span>
<span className="text-red-600">{formatMoney(product.price.discounted.value)}</span>
{product.price.discounted.discountName && (
<span className="rounded bg-red-100 px-1 text-xs text-red-700">
{product.price.discounted.discountName}
</span>
)}
</>
)}
Pattern 3: Discount Code Form
<DiscountCodeForm /> wherever needed. Do not write a custom fetch. It reads and mutates the KEY_CART client state-manager/cache entry automatically, calling the cart-discount server endpoint to apply ({ code }) and remove ({ code }) codes.DiscountCodeForm and drop <DiscountCodeForm /> inside the cart page or cart drawer.The form:
- Shows an input for entering a code
- On submit: calls the apply server endpoint, then revalidates the cart client state-manager/cache entry
- Shows applied codes as chips with a remove button (calls the remove server endpoint)
- Displays commercetools error messages (e.g. "Code not found", "Already applied")
{ code } from the request and call the commercetools cart update via applyCartAction, returning mapCart(cart):// apply code
const cart = await applyCartAction(session.cartId!, session.customerId, [
{ action: 'addDiscountCode', code },
]);
// remove code
const cart = await applyCartAction(session.cartId!, session.customerId, [
{ action: 'removeDiscountCode', discountCode: { typeId: 'discount-code', id: codeId } },
]);
Find the stack'sdata-loading.mdfor concrete server endpoints pattern implementation.
Pattern 4: Promotion Banner
Two options — choose one:
Header.tsx:// <root-dir>/components/layout/Header.tsx
export default function Header() {
return (
<>
{/* Promotion banner — hardcoded or from environment variable */}
<div className="bg-sage-100 py-2 text-center text-sm font-medium">
Free shipping on orders over $50 — Use code FREESHIP
</div>
{/* rest of header */}
</>
);
}
content/message section in lib/layout.ts:// <root-dir>/lib/layout.ts (inside getHomeSections)
{
type: 'content/message',
config: {
text: {
'en-US': 'Free shipping on orders over $50 — Use code FREESHIP',
'de-DE': 'Kostenloser Versand ab 50 € — Code: FREESHIP',
},
},
size: { xs: 12 },
background: 'Sage',
},
// <root-dir>/components/home/MessageBanner.tsx
import type { ItemProps } from '<server>/layout';
interface MessageBannerProps { text: string }
export default function MessageBanner({ config }: ItemProps<MessageBannerProps>) {
return (
<div className="py-2 text-center text-sm font-medium">
{config.text}
</div>
);
}
'content/message' type → MessageBanner mapping in the section-renderer registry (Item), lazy-loading the component with the framework's dynamic-import primitive.Checklist
- When showing discount badge/name: expand
masterVariant.price.discounted.discountandvariants[*].price.discounted.discountin search params -
DiscountCodeFormimported (not custom fetch) wherever discount codes are entered - commercetools Merchant Center: Product Discount created and active (if using product-level discounts)
- commercetools Merchant Center: Cart Discount created and active (if using cart-level discounts)
- commercetools Merchant Center: Discount Code created and linked to a Cart Discount (if using codes)
- Promotion banner added via Header (static) or layout sections (CMS-driven)
Recurring Orders — B2C
Table of Contents
- B2C Extension: Scoping and Auth
- B2C Extension: Line Items — originOrder Expand
- B2C Extension: Create Post-Checkout
- B2C Extension: Additional State Actions
- B2C Extension: API Routes
- B2C Extension: Account Pages
- Checklist
B2C Extension: Scoping and Auth
Scope recurring order list fetches to the authenticated customer:
where: customer(id="${customerId}")
customerId from the session. Always read customerId from getSession() on the server — never trust a client-supplied ID.B2C Extension: Line Items — originOrder Expand
originOrder — not cart — to access the line items:expand: ['originOrder']
sub.lineItems?.length ? sub.lineItems : sub.originOrder?.obj?.lineItems ?? []. The top-level lineItems on RecurringOrder is often empty even when the expand succeeds.nextOrderAt → nextOrderDate. This ensures UI components use a consistent field name regardless of which commercetools API version returns which field name.B2C Extension: Create Post-Checkout
recurrenceInfo.recurrencePolicy set, fetch the policy by ID and create one RecurringOrder.originOrder, nextOrderAt, and top-level schedule are commercetools extension fields not in RecurringOrderDraft. Cast the entire body as unknown:{
originOrder: { typeId: 'order', id: orderId },
cart: { typeId: 'cart', id: cartId },
customer: { typeId: 'customer', id: customerId },
startsAt: now.toISOString(),
nextOrderAt: computedDate.toISOString(),
recurringOrderState: 'Active',
schedule: { type: 'standard', value, intervalUnit },
}
nextOrderAt from the policy's schedule.value and schedule.intervalUnit: add N months or N×7 days to the current date. Fetch the full policy by ID at checkout time to get these values — do not trust the summary stored on the line item.B2C Extension: Additional State Actions
B2C supports two additional update actions beyond pause / resume / cancel:
action: 'setOrderSkipConfiguration'
skipConfigurationInputDraft: { type: 'Counter', totalToSkip: N }
action: 'setSchedule'
recurrencePolicy: { id: recurrencePolicyId } // or pass a raw schedule object
PUT /<api>/account/subscriptions/[id] endpoint, dispatched on an action field in the request body.B2C Extension: API Routes
| Method | Path | Action | Auth |
|---|---|---|---|
GET | /<api>/account/subscriptions | List customer's recurring orders | customerId |
GET | /<api>/account/subscriptions/[id] | Single with originOrder expand | customerId |
PUT | /<api>/account/subscriptions/[id] | All state actions (pause/resume/cancel/skip/setSchedule) | customerId |
GET | /<api>/recurrence-policies | List all policies | Session |
POST | /<api>/cart/items | Add line item; recurrencePolicyId optional | customerId |
PUT endpoint — not per-action routes. The action field in the request body dispatches to the correct commercetools update action. There is no separate POST creation route for subscriptions — creation happens post checkout..B2C Extension: Account Pages
Subscriptions are a personal account feature in B2C:
/account/subscriptions— list with status badges (Active / Paused / Cancelled)/account/subscriptions/[id]— detail with pause / resume / cancel / skip actions
Protect both routes with a customer auth guard — unauthenticated requests redirect to login.
Checklist
- List
whereclause usescustomer(id="${customerId}")— not BU scoping - All server endpoints validate
customerIdonly — nobusinessUnitKey -
customerIdalways read fromgetSession()— never from request body or query params - Always
expand: ['originOrder']— notcart - Fall back to
originOrder?.obj?.lineItemswhen top-levellineItemsis empty - Normalise
nextOrderAt→nextOrderDateat the API layer, not in the UI - Recurring orders created post-checkout, one per subscription line item
-
RecurringOrderDraftbody cast asunknown— extension fields not in SDK type -
nextOrderAtcomputed from policy schedule fetched at checkout time - Order not rolled back if
createRecurringOrderfails — log and continue - Cancel uses
'canceled'(single-l) as the commercetools state value - Skip uses
setOrderSkipConfiguration; schedule change usessetSchedule - Single
PUTroute handles all state actions viaactionfield dispatch - No dedicated subscription creation route — creation is inside the checkout route
Recurring Prices — B2C
Table of Contents
- B2C Extension: PDP Gate
- B2C Extension: Add to Cart with Recurrence
- Legacy Note: Older SDK Cast Pattern
- Checklist
B2C Extension: PDP Gate
recurringPrices.length > 0 for the selected variant. If the variant has no recurrencePrices entries, render the standard Add to Cart button only.In B2C there is no required login gate — anonymous users can also see subscription pricing. If your storefront requires login before subscribing, enforce that in the add-to-cart handler (redirect to login), not by hiding the selector.
B2C Extension: Add to Cart with Recurrence
apiRoot.carts() — not the as-associate chain. The recurrenceInfo field on CartAddLineItemAction is a commercetools extension not yet in the SDK type. Cast the entire action at the call site in <server>/ct/cart:const action = {
action: 'addLineItem',
productId, variantId, quantity,
...(recurrencePolicyId ? { recurrenceInfo: { ... } } : {}),
} as CartUpdateAction;
recurrenceInfo: undefined. The absence of the field is meaningful; an explicit undefined may behave differently depending on the serialiser.Checklist
- Recurring prices read from
variant.recurrencePrices[]— not filtered out ofvariant.prices[] - Mapped
Pricetype includesrecurrencePolicy?: { id: string } - Show subscription UI when
recurringPrices.length > 0for the selected variant — no product attribute check required - Cart operations use project-level
apiRoot.carts()— not as-associate chain -
addLineItemaction cast asCartUpdateActionwhen attachingrecurrenceInfo - One-time selection: omit
recurrenceInfoentirely — do not spreadrecurrenceInfo: undefined - Recurring prices filtered by current currency + country before passing to the selector
- Policy name resolved via
policies.find(p => p.id === price.recurrencePolicy.id)?.name; raw ID as fallback
B2C Superuser (CSR Impersonation)
csrId presence check — UI hiding the input is not sufficient.Key Takeaways
customerId, email, etc.) hold the impersonated customer's data. CSR identity lives in csrId, csrEmail, csrFirstName, csrLastName.CSR_GROUP_ID is the membership gate. Server-only env var — the commercetools Customer Group ID that marks CSR accounts. POST /<api>/auth/login checks this group before deciding whether to issue a normal session or return { requiresCsrEmail: true }.POST /<api>/auth/csr-login with both credential sets → session holds dual identity. GET /<api>/auth/superuser exposes CSR state to client components.csrId guard is non-negotiable. PUT /<api>/cart/items/[itemId]/price must return 403 when session.csrId is absent. A missing guard lets any authenticated customer override prices.SuperUserContext drives all CSR UI. A client state hook reads the <api>/auth/superuser server endpoint with ~30 s deduping. useSuperUser() in any client component exposes CSR state without prop drilling — yellow banner in Header, PriceOverrideInput in CartItem.Anti-Patterns
| Anti-pattern | Correct approach |
|---|---|
No csrId check on price override endpoint | Return 403 when session.csrId is absent — always, even if UI hides the input |
Exposing CSR_GROUP_ID to the client bundle | Server-only env var — never exposed to the client |
| CSR state in localStorage or client component state | Server-managed session; expose via the <api>/auth/superuser server endpoint read by a client state hook |
| UI-only gate on price override | Server must enforce; UI visibility is a UX courtesy, not a security control |
Reference
| Task | Reference |
|---|---|
| commercetools setup, session extension, login flow, price override endpoint, SuperUserContext, Header banner, CartItem PriceOverrideInput | superuser.md |
Superuser (CSR Impersonation)
csrId session guard on the price override endpoint. Missing it lets any authenticated user override line item prices.csrId check.Table of Contents
- Pattern 1: commercetools Setup
- Pattern 2: Session Extension
- Pattern 3: Login Flow
- Pattern 4: Price Override
- Pattern 5: SuperUserContext
- Pattern 6: UI
Pattern 1: commercetools Setup
CSR_GROUP_ID to the server environment. This is the commercetools Customer Group that identifies CSR agents. It is a server-only env var — never expose it to the client bundle.CSR_GROUP_ID=<customer-group-id-from-ct>
In commercetools Merchant Center:
- Customers → Customer Groups → Create "CSR Agents" group
- Copy the group ID to
CSR_GROUP_ID - Assign CSR agent customer accounts to that group
Pattern 2: Session Extension
Session interface with CSR fields. Normal customer fields (customerId, email, etc.) hold the impersonated customer's data when a CSR is active.// <root-dir>/<server>/session
export interface Session {
// Impersonated customer (or real customer when no CSR)
customerId?: string;
email?: string;
firstName?: string;
lastName?: string;
cartId?: string;
// CSR agent identity (present only during active impersonation)
csrId?: string;
csrEmail?: string;
csrFirstName?: string;
csrLastName?: string;
}
Pattern 3: Login Flow
Three server endpoints collaborate. Each reads its inputs from the request and the current session, and writes the session via the stack's session storage.
POST <api>/auth/login — authenticate, then branch on CSR group membership:loginCustomer(email, password).- Check whether the customer belongs to the CSR group — i.e.
customerGroup.idor anycustomerGroupAssignments[*].customerGroup.idequalsCSR_GROUP_ID. - If so, do not create a session yet (the CSR must still supply a customer to impersonate) — respond
{ requiresCsrEmail: true }. - Otherwise write a normal session (
{ customerId, email, ... }) and return.
POST <api>/auth/csr-login — called after /login returns requiresCsrEmail: true; body { csrEmail, csrPassword, impersonatedEmail }:loginCustomer(csrEmail, csrPassword)for the agent andgetCustomerByEmail(impersonatedEmail)for the target.- Write a dual-identity session: the impersonated customer in the normal fields (
customerId,email,firstName,lastName) and the agent in the CSR fields (csrId,csrEmail,csrFirstName,csrLastName).
GET <api>/auth/superuser — read the session and return { csrId, csrEmail, csrFirstName, csrLastName } when session.csrId is present, otherwise {}.Find the stack'sdata-loading.mdfor concrete server endpoints (auth, session read/write) pattern implementation.
Pattern 4: Price Override
PUT on a cart line item, e.g. <api>/cart/items/:itemId/price) reads the session and guards on session.csrId first — returning a 403 forbidden response when it is absent (only CSR agents may override prices). It then reads { centAmount, currencyCode } from the request and applies the commercetools cart update:const cart = await applyCartAction(session.cartId!, session.customerId, [
{
action: 'setLineItemPrice',
lineItemId: itemId,
externalPrice: { currencyCode, centAmount },
},
]);
// return mapCart(cart)
Find the stack'sdata-loading.mdfor concrete server endpoints endpoint (with thecsrId403 guard).
Pattern 5: SuperUserContext
{ csrId?, csrEmail?, csrFirstName?, csrLastName? }) to any client component without prop drilling:- A
SuperUserProvideruses a client state hook to fetch the<api>/auth/superuserserver endpoint (with ~30 s deduping), defaulting to{}, and provides the result through the context. - A
useSuperUser()accessor reads that context.
SuperUserProvider in the root/locale layout (server-rendered), wrapping the page children, so CSR state is available app-wide.Find the stack'sconcept-mapping.mdfor concrete client-state context + provider.
Pattern 6: UI
{ csrId, csrFirstName, csrLastName } from useSuperUser() and, when csrId is set, renders a yellow banner above the rest of the header (e.g. "CSR Mode — {csrFirstName} {csrLastName} impersonating customer").csrId from useSuperUser() and renders <PriceOverrideInput lineItemId={item.id} currentPrice={item.price} /> only when csrId is set — alongside the usual quantity, name, etc.Checklist
-
CSR_GROUP_IDset as a server-only env var (never exposed to the client bundle) -
Sessioninterface extended withcsrId,csrEmail,csrFirstName,csrLastName -
POST <api>/auth/loginreturns{ requiresCsrEmail: true }for CSR group members -
POST <api>/auth/csr-loginwrites dual identity to the session -
GET <api>/auth/superuserreturns CSR fields or{} - The price-override endpoint (
PUTon a cart line item) returns 403 whensession.csrIdis absent -
SuperUserProvidermounted in the root layout wrapping children - Yellow banner visible in Header during active impersonation
-
PriceOverrideInputrendered in CartItem only whencsrIdis set
commercetools B2C Storefront
Production-tested patterns for building a B2C storefront on commercetools with server-managed sessions, derived from the b2c-starter-kit — a working production storefront. The patterns are framework-neutral; load a framework adapter for the implementation primitives.
Shared foundation: BFF architecture, session setup, commercetools SDK singleton, project scaffold,COUNTRY_CONFIG, performance patterns, image config, and the shared auth base are in this skill'score/references. Find the adapter'soverview.mdit owns the file layout, render primitives, deploy, and the /commands.
Key Takeaways (B2C-specific)
anonymousCartId and anonymousCartSignInMode: 'MergeWithExistingCustomerCart' on login so the pre-login cart is preserved.en-US, de-DE — the same format commercetools uses. The framework routes locale-prefixed paths (/en-US/, /de-DE/, etc.). The your-shop-country-locale cookie stores the BCP-47 locale and drives which locale the entry redirect chooses on first visit.Reference Index
Shared Foundation
core/. For frontend implementation, see the stack's overview.md of the adapter.| Task | Reference |
|---|---|
| Scaffold a new project (deps, styling, locale routing) | Framework-specific example — Next.js: run /nextjs-setup-project |
| commercetools SDK singleton, server-managed sessions, BFF boundary | ct-client.md |
| Shared auth base: commercetools login, server endpoint, client state hook, logout | customer-auth.md |
Add a new country / currency / locale (COUNTRY_CONFIG) | add-country.md |
| Parallel fetching, server-side TTL caching, client-cache hydration, image optimization | performance.md |
| Product image URL transforms (CDN, Imgix, Cloudinary) | image-config.md |
Core — Green-Field Build (follow in order)
| Task | Reference |
|---|---|
| Category pages, product mapper, commercetools Search API, ProductCard/Grid | product-listing.md |
| PDP route, image gallery, variant selectors, AddToCartButton | product-detail.md |
| Cart CRUD, cart state/context, client state hook, mini-cart drawer | cart.md |
| Shipping methods, order placement, multi-step checkout, confirmation | checkout.md |
| Register, login, anonymous cart merge, protected account layout | customer-auth.md |
| Full-text search, facet config, URL state, renderers | search-facets.md |
Enhancement — Modify Existing Features
| Task | Reference |
|---|---|
| Add a new BFF endpoint + client state hook (the 3-layer pattern) | add-api.md — or run the b2c-api-scaffolder agent to generate the files automatically |
| Add a new standalone or CMS-driven page | add-page.md |
| Use or extend the shared UI component library | ui-components.md |
| Server-rendered vs client-fetched decisions, mappers, BFF shape, 409 retry | data-loading.md |
| Configure PDP variant selectors (blocklist, swatch, sort order) | variant-config.md |
Optional Features — Separate Skills
These features have their own skills with focused trigger descriptions. Load them when needed.
| Feature | Skill |
|---|---|
| CSR impersonation, dual session, line-item price override | superuser.md |
| Buy Online Pick Up In Store — channel API, per-store inventory | bopis.md |
| Product bundles — parent/child cart items, cascade updates | bundles.md |
| Product discounts, cart discounts, discount codes, promotion banners | promotions.md |
| Deploy to Vercel | Run /deploy-vercel — checks commercetools credentials, then hands off to Vercel's official agent skill |
| Deploy to Netlify | Run /deploy-netlify — checks commercetools credentials, then hands off to Netlify's official agent skill |
Priority Tiers (B2C-specific additions)
Shared CRITICAL/HIGH/MEDIUM rules (BFF, session secrets, parallel fetching, type safety, mappers, Product Search API, server-side TTL caching) are in this skill's top-level SKILL.md. Find adapter'soverview.mdfile for stack's specific priority.
HIGH
- Anonymous cart merge — Pass
anonymousCartIdto commercetools login so the cart is preserved on sign-in. - Client state invalidation — refresh/invalidate
KEY_CARTandKEY_ACCOUNTafter login/logout/order placement.
MEDIUM
- Single locale format — BCP-47 everywhere: URL segments, commercetools API calls, cookie, COUNTRY_CONFIG keys all use
en-US,de-DE. No conversion needed.
Anti-Patterns Quick Reference (B2C-specific)
Shared anti-patterns (apiRoot in a client component, endpoint fetch in a component, client-exposed secrets, etc.) are in this skill's top-level SKILL.md. Framework-specific anti-patterns (e.g. the locale-aware link) are in the adapter.
| Anti-pattern | Correct approach |
|---|---|
| Per-user data in a shared server-side TTL cache | Client state (client) or a direct commercetools call (per-request server) |
Product Detail Page — B2C
getAttributeLabels(bcp47) in parallel with the product and pass the result to any component that renders product attributes.getLocale() to get country, currency, and locale for the current session. Pass these to product and price fetches to ensure market-correct pricing.Product Listing
This reference covers category data fetching, the commercetools Product Search API, the product mapper, ProductCard/Grid components, and the server-rendered category page.
Table of Contents
- Pattern 1: Category Helper Functions
- Pattern 2: Product Mapper
- Pattern 3: Product Search API
- Pattern 4: Product UI Components
- Pattern 5: Category Page (server-rendered)
- Checklist
Pattern 1: Category Helper Functions
<server>/ct/categories key functions:getCategoryBySlug(slug, locale): fetch a category by its localized sluggetCategoryById(id, locale): fetch a category by IDgetCategoryTree(locale): fetch all categories (limit: 500, sorted byorderHint) and return as a nested tree
commercetools slug query format:where: \slug(${locale}="${slug}")`— locale is BCP-47 (e.g.en-US), matching both the URL segment and the COUNTRY_CONFIG key. commercetools stores slugs as{ "en-US": "my-slug" }`.
Pattern 2: Product Mapper
ProductProjection objects to components — this pushes too much data to frontend.<server>/mappers/product, components only receive Product from <server>/types:Functions to implement:
mapProduct(p, locale): maps aProductProjectionto the appProducttypemapVariant(v): maps variant fields — id, sku, images, price, prices, attributes, availabilitymapPrice(p): maps price — centAmount, currencyCode, discounted
Pattern 3: Product Search API
productProjectionParameters patterns.<server>/ct/search key functions for this storefront:searchProducts(params): queries using the v2 Product Search API. Supports text query,categoryIdfilter viacategoriesSubTree, pagination (limit/offset), and sortgetProductBySku(sku, locale, currency, country): fetches a single product by exact SKU match
Price selection: PasspriceCurrency+priceCountryinproductProjectionParametersso variants arrive with.pricealready resolved to the correct tier.
Pattern 4: Product UI Components
ProductCard: links to the PDP, shows product image, name, and price. If discounted, shows discounted price with original crossed out. Uses the framework's locale-aware linkProductGrid: renders a responsive grid ofProductCardcomponents; shows an empty state when no productsPagination: renders a Pagination component which modifies theoffset/limit- Other components to handle Client rendered components (sort, facets, etc)
Pattern 5: Category Page (server-rendered)
<server>/ct/* directly in a server-rendered load, parallel-fetch independent data:In the server-rendered category page/load:
- Read the route
slugand resolvecountry,currency,localefrom the session. - Parallel-fetch the independent data with
Promise.all:getCategoryBySlug(slug, locale)andgetCategoryTree(locale)at the same time. - If the category does not resolve, return the framework's not-found response.
- Build the breadcrumb by walking the in-memory category tree — no extra API calls.
- Call
searchProducts({ categoryId: category.id, locale, currency, country, ... }). - Render breadcrumb, heading,
ProductGrid, and pagination.
Find Stack'sdata-loading.mdfor more details of aconcrete server-rendered category page implementation.
Checklist
-
<server>/ct/categoriesexportsgetCategoryBySlug,getCategoryById,getCategoryTree -
getCategoryTreefetches withlimit: 500 -
<server>/mappers/productexportsmapProduct— components never receive raw commercetools types -
<server>/ct/searchusesapiRoot.products().search()(v2 API), not legacyproductProjections - Category page uses
Promise.allto fetch category + category tree in parallel - Breadcrumb walks the in-memory tree — no N+1 parent ID lookups
- Breadcrumb and pagination use the framework's locale-aware link — no bare
<a>tags - Prices display with discounted amount + strikethrough original when applicable
- The framework's not-found response returned when category slug doesn't resolve
UI Components
components/ui/ creates inconsistent styling and duplicated behaviour.<server>/../components/ui/. Check it before writing any interactive element from scratch.Stack-specific reference. The snippets below are a React + Tailwind reference implementation (the b2c-starter-kit'scomponents/ui/). The framework-neutral concept is: keep one shared, consistently-styled set of primitives (Button, Input, Card, Modal, Drawer, Select) and compose them everywhere instead of re-styling raw markup. On another stack, build the equivalent in that stack's component model.
Table of Contents
- Pattern 1: Check Before Writing
- Pattern 2: Button Component
- Pattern 3: Input Component
- Pattern 4: Drawer Component
- Pattern 5: Adding a New Component
- Pattern 6: Link Component
Pattern 1: Check Before Writing
// BAD
<button className="px-5 py-2.5 bg-black text-white rounded-lg hover:bg-gray-800">
Add to Cart
</button>
@/components/ui/:// GOOD
import Button from '@/components/ui/Button';
<Button variant="primary" onClick={handleAddToCart}>
Add to Cart
</Button>
components/ui/: Button, Input, Drawer, Badge, Spinner, Modal, Select.Pattern 2: Button Component
variant ('primary' | 'secondary' | 'outline' | 'ghost'), size ('sm' | 'md' | 'lg'), isLoading, disabled.import Button from '@/components/ui/Button';
// Primary CTA
<Button variant="primary" size="lg">
Checkout
</Button>
// Secondary action
<Button variant="secondary" size="md">
Save for Later
</Button>
// Outline / bordered
<Button variant="outline" size="sm">
View Details
</Button>
// Ghost / text-only
<Button variant="ghost" size="sm">
Remove
</Button>
// Loading state — shows spinner, disables click
<Button variant="primary" isLoading={submitting}>
Place Order
</Button>
// Disabled
<Button variant="primary" disabled>
Out of Stock
</Button>
// As a link (passes through HTML anchor attributes)
<Button variant="primary" as="a" href="/cart">
View Cart
</Button>
Pattern 3: Input Components
label, error. It is a forwardRef component — compatible with react-hook-form and similar.import Input from '@/components/ui/Input';
import { useRef } from 'react';
// Basic usage
<Input
label="Email address"
type="email"
placeholder="you@example.com"
onChange={(e) => setEmail(e.target.value)}
/>
// With validation error
<Input
label="Password"
type="password"
error="Password must be at least 8 characters"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
// With forwardRef (react-hook-form)
const { register, formState: { errors } } = useForm();
<Input
label="First name"
error={errors.firstName?.message}
{...register('firstName', { required: 'Required' })}
/>
Pattern 4: Drawer Component
isOpen, onClose, title, children, footer, position ('left' | 'right').import Drawer from '@/components/ui/Drawer';
import Button from '@/components/ui/Button';
// A client component holds the drawer's open/close state and passes it in via props:
function CartDrawer({ open, setOpen }: { open: boolean; setOpen: (v: boolean) => void }) {
return (
<>
<Button variant="primary" onClick={() => setOpen(true)}>
Open Cart
</Button>
<Drawer
isOpen={open}
onClose={() => setOpen(false)}
title="Your Cart"
position="right"
footer={
<Button variant="primary" className="w-full">
Proceed to Checkout
</Button>
}
>
{/* cart line items */}
<p className="text-sm text-gray-500">Your cart is empty.</p>
</Drawer>
</>
);
}
Thefooterslot is rendered at the bottom of the drawer, above the scroll area. Use it for sticky CTAs.
Pattern 5: Adding a New Component to components/ui/
components/ui/.// BAD — ui/ component importing from <server>/ct/
import { getProduct } from '<server>/ct/products';
export default function ProductBadge({ sku }: { sku: string }) {
// fetches product data — domain knowledge, not generic UI
}
// <root-dir>/components/ui/Badge.tsx
import { HTMLAttributes } from 'react';
type BadgeVariant = 'success' | 'warning' | 'error' | 'info';
interface BadgeProps extends HTMLAttributes<HTMLSpanElement> {
variant?: BadgeVariant;
}
const variantClasses: Record<BadgeVariant, string> = {
success: 'bg-green-100 text-green-800',
warning: 'bg-yellow-100 text-yellow-800',
error: 'bg-red-100 text-red-800',
info: 'bg-blue-100 text-blue-800',
};
export default function Badge({
variant = 'info',
className = '',
children,
...props
}: BadgeProps) {
return (
<span
className={`inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium ${variantClasses[variant]} ${className}`}
{...props}
>
{children}
</span>
);
}
components/ui/:- No imports from
<server>/ct/,<server>/mappers/, or domain hooks - Props interface must extend the relevant
HTML*Attributestype - Accept and spread
...propsso callers can addaria-*,data-*,classNameetc. - Export a single default component per file
Pattern 6: Link Component
<a> tag for internal navigation — causes a full page reload and bypasses the framework's locale-aware link.// BAD
<a href="/products">Browse products</a>
// GOOD
import Link from '@/i18n/routing';
<Link href="/products">Browse products</Link>
When to use each:
| Case | Use |
|---|---|
| Internal route (same origin) | <Link href="..."> |
External URL (https://...) | <a href="..." target="_blank" rel="noopener noreferrer"> |
| Button that navigates programmatically | router.push(...) via the framework's client navigation/query-param API |
Button component — pass as="a" only for external links; use <Link> wrapping for internal ones:import Link from '@/i18n/routing';
import Button from '@/components/ui/Button';
// Internal CTA — Link wraps Button
<Link href="/cart">
<Button variant="primary">View Cart</Button>
</Link>
// External CTA — Button renders as <a>
<Button variant="outline" as="a" href="https://docs.example.com" target="_blank" rel="noopener noreferrer">
Read Docs
</Button>
Never use<a href="...">for routes inside the app.<Link>prefetches the destination, preserves scroll position, and avoids full-page reloads.
Checklist
-
components/ui/checked before writing raw HTML for buttons, inputs, drawers, badges - New generic UI components placed in
components/ui/(not in feature folders) - Props interface extends the appropriate
HTML*Attributestype - No
<server>/ct/imports insidecomponents/ui/files -
...propsspread passed through to the underlying HTML element -
<Link href="...">used for all internal navigation — no bare<a>tags pointing to internal routes - External links use
<a target="_blank" rel="noopener noreferrer">(not<Link>)
Variant Config
<root-dir>/<server>/ct/variant-config. Edit the config — never components.All five config variables live in one file. Changing them requires no component edits.
Table of Contents
- Pattern 1: Blocklist
- Pattern 2: Renderer Map
- Pattern 3: Color Code
- Pattern 4: Sort Order
- Pattern 5: Info Attributes
- Config Summary
Pattern 1: Blocklist
VARIANT_SELECTOR_BLOCKLIST: string[] — attribute names that are never shown as selectors. Add any attribute that should not appear as a clickable option on the PDP.Example:
// <root-dir>/<server>/ct/variant-config
export const VARIANT_SELECTOR_BLOCKLIST: string[] = [
'color-code', // hex value — companion to 'color-label', shown as swatch fill
'colorCode',
'finish-code',
'sku',
'articleNumber',
// Add names here to hide them from the selector UI
];
Any attribute that appears inPDP_INFO_ATTRIBUTESshould also be added here — otherwise it will render as both a selector and an info block.
Pattern 2: Renderer Map
VARIANT_RENDERER_MAP: Record<string, VariantRenderer> — maps an attribute name to a renderer. Attributes not listed default to 'pill'.Example:
// <root-dir>/<server>/ct/variant-config
export type VariantRenderer = 'pill' | 'color';
export const VARIANT_RENDERER_MAP: Record<string, VariantRenderer> = {
'color-label': 'color', // circular swatch, uses VARIANT_COLOR_CODE_ATTR for fill
'finish': 'color',
'size': 'pill', // explicit pill (same as default)
// Unlisted attributes → 'pill'
};
Renderers:
'pill'— rectangular chip with the attribute value as text'color'— circular swatch; the fill colour comes from the companion attribute inVARIANT_COLOR_CODE_ATTR
Pattern 3: Color Code
VARIANT_COLOR_CODE_ATTR: Record<string, string> — maps a display attribute (e.g. 'color-label') to its companion hex attribute (e.g. 'color-code'). Used by the 'color' renderer to determine the swatch background colour.// <root-dir>/<server>/ct/variant-config
export const VARIANT_COLOR_CODE_ATTR: Record<string, string> = {
'color-label': 'color-code', // variant.attributes['color-code'] = '#FF5733'
'finish': 'finish-code',
};
color-code) must also be in VARIANT_SELECTOR_BLOCKLIST so it is not rendered as its own selector.Pattern 4: Sort Order
VARIANT_SORT_ORDER: string[] — explicit left-to-right order of attribute selectors. Attributes not in this list appear after the listed ones in their natural (API) order.// <root-dir>/<server>/ct/variant-config
export const VARIANT_SORT_ORDER: string[] = [
'color-label', // shown first
'size', // shown second
'width', // shown third
// Everything else appended after in natural order
];
Pattern 5: Info Attributes
PDP_INFO_ATTRIBUTES: string[] — attributes rendered as text sections below the description, not as selectors. Values are rendered inside <pre> blocks to preserve formatting.// <root-dir>/<server>/ct/variant-config
export const PDP_INFO_ATTRIBUTES: string[] = [
'material-composition',
'care-instructions',
'country-of-origin',
'description-long',
];
Always add info attributes toVARIANT_SELECTOR_BLOCKLISTas well to avoid duplicate rendering.
Config Summary
| Variable | Type | Purpose |
|---|---|---|
VARIANT_SELECTOR_BLOCKLIST | string[] | Attribute names never shown as selectors |
VARIANT_RENDERER_MAP | Record<string, VariantRenderer> | Maps attribute → 'pill' or 'color' renderer |
VARIANT_COLOR_CODE_ATTR | Record<string, string> | Maps display attribute → companion hex attribute for color swatches |
VARIANT_SORT_ORDER | string[] | Left-to-right display order; unlisted attributes appear after |
PDP_INFO_ATTRIBUTES | string[] | Attributes shown as text info blocks below description (in <pre>) |
Wishlists — B2C
Table of Contents
- B2C Extension: API Chain and Ownership
- B2C Extension: Create Draft
- B2C Extension: Client State Hook and Mutation
- B2C Extension: UI — Header Icon
- B2C Extension: UI — Heart Icon on PDP and PLP
- B2C Extension: Pages
- Checklist
B2C Extension: API Chain and Ownership
apiRoot.shoppingLists() — not the as-associate chain. commercetools does not restrict this endpoint by customer, so ownership must be enforced in app code on single-item fetches: after retrieving a list by ID, check that list.customer?.id === customerId and return 404 if it does not match. This prevents ID-guessing attacks where one customer could read another's wishlist.where: customer(id="${customerId}") filter on list fetches scopes the results to the authenticated customer without needing the as-associate chain.B2C Extension: Create Draft
name and customer. Do not include a store field — B2C wishlists are not store-scoped and should be visible regardless of which storefront the customer visits.name: { [locale]: name }
customer: { id: customerId, typeId: 'customer' }
B2C Extension: Client State Hook and Mutation
[KEY_WISHLISTS, customerId]. The hook should only fire when customerId is available — pass null as the key when the customer is not yet resolved to avoid fetching as anonymous.[KEY_WISHLISTS, customerId] entry. Scope the invalidation to that key tuple explicitly — do not invalidate the entire client state-manager/cache, to avoid touching unrelated entries.For the add/remove heart icon, the mutation should feel instant. Apply an optimistic update: immediately toggle the local state, fire the request in the background, and revert only on error.
Find the stack'sconcept-mapping.mdfor concrete state and cache implementation.
B2C Extension: UI — Header Icon
A wishlist icon in the global header gives customers persistent access to their saved items. It should:
- Show the count of total items across all wishlists as a badge (sum of
lineItems.lengthacross all lists) - Navigate to
/wishlistson click - Render as an empty icon when the customer is not logged in (no badge, click redirects to login)
- Use the same icon weight and size as the cart icon in the header for visual consistency
The count should come from the same client state hook used by the wishlist pages — no separate fetch needed.
B2C Extension: UI — Heart Icon on PDP and PLP
The heart icon is the primary entry point for adding or removing a product from a wishlist.
- Filled heart = product is already in at least one wishlist
- Outline heart = product is not in any wishlist
- Clicking an outline heart adds the product to the customer's default (or only) wishlist
- Clicking a filled heart removes the product from all wishlists it appears in
- If the customer has no wishlists yet, the first click creates a default one named "My Wishlist" and adds the item
- If the customer is not logged in, clicking the heart redirects to login (with a
?redirectparam to come back)
- PDP: prominent button or icon near the Add to Cart CTA — visually secondary to it
- PLP: overlaid icon in the top-right corner of the product card, visible on hover (desktop) or always visible (mobile)
isSaved state. Pass variantId (or productId as fallback) as the lookup key.B2C Extension: Pages
/wishlists/:/wishlists— list all of the customer's wishlists with item count and thumbnail preview/wishlists/[id]— detail view with item cards, add-to-cart per item, and remove-from-list action
Protect both routes with an auth guard — unauthenticated requests redirect to login.
Checklist
- commercetools calls use project-level
apiRoot.shoppingLists()— not the as-associate chain - Ownership check in app code after single-list fetch:
list.customer?.id === customerId→ 404 on mismatch - Create draft has no
storefield - Server endpoints validate
customerIdonly (notbusinessUnitKey) - client state-manager/cache key is
[KEY_WISHLISTS, customerId]; fires only whencustomerIdis resolved - All mutations re-fetch/invalidate
[KEY_WISHLISTS, customerId]after completing - Heart icon on PDP and PLP with optimistic toggle
- Auto-creates default wishlist on first heart click if customer has none
- Heart redirects unauthenticated users to login with
?redirectparam - Header icon shows total item count badge from the same client state hook
- Pages at
/wishlists/— not under/dashboard/ -
expand: ['lineItems[*].variant']on all list fetches
Adding a BFF API Endpoint
This reference covers adding a new server endpoint + commercetools helper + client-state hook — the three-layer BFF pattern every data source must follow.
Table of Contents
- Pattern 1: Data Flow Rule
- Pattern 2: Cache Key
- Pattern 3: Server Endpoint
- Pattern 4: commercetools Helper Function
- Pattern 5: Client State Hook with Mutations
- Checklist
Pattern 1: Data Flow Rule
<server>/ct/* in a client component or calling fetch('/<api>/*') directly inside a component:// WRONG — leaks server code into the browser bundle
import { getCustomerOrders } from '<server>/ct/auth';
// WRONG — direct fetch in component, no cache key
const res = await fetch('/<api>/orders');
Client component
→ client data hook (<root-dir>/hooks/*.ts) — calls fetch('/<api>/…') / the framework's loader
→ server endpoint — server-only, calls <server>/ct/*
→ <server>/ct/<namespace>.ts — server-only, calls apiRoot
→ commercetools API
<server>/types instead:// ✅ correct
import type { Product } from '<server>/types';
// ❌ wrong — even for types only
import type { ProductProjection } from '<server>/ct/search';
Pattern 2: Cache Key
'widgets', another on `widget-${id}` ad-hoc).<server>/cache-keys:// <server>/cache-keys
export const KEY_WIDGETS = 'widgets';
export function keyWidget(id: string) {
return `widget-${id}`;
}
// Tuple key for locale-parameterised data
export function keyShippingMethods(country: string, currency: string) {
return ['shipping-methods', country, currency] as const;
}
Pattern 3: Server Endpoint
// WRONG — commercetools logic leaks into the endpoint
export async function GET() {
const { body } = await apiRoot.orders().get({ queryArgs: { where: `...` } }).execute();
return json({ orders: body.results });
}
<server>/ct/<namespace>.ts, and returns JSON. It does exactly three things: validate session → call the namespace helper → return JSON (401 when unauthenticated, 500 with the error message on failure). It never contains a raw SDK call.Example: Next.js, the concrete server endpoint (withNextResponse) and the{auth,account,cart,checkout,shipping-methods,channels}endpoint directory conventions. Find the stack'sconcept-mapping.mdfor concrete server endpoints.
Pattern 4: commercetools Helper Function
<server>/ct/<namespace>.ts:// WRONG — commercetools call in the endpoint
const { body } = await apiRoot.orders().withId({ ID: id }).get().execute();
// <server>/ct/widgets
import { apiRoot } from './client';
export async function getWidgets(customerId: string) {
const { body } = await apiRoot
.widgets()
.get({ queryArgs: { where: `customerId = "${customerId}"` } })
.execute();
return body.results;
}
export async function createWidget(data: Record<string, unknown>) {
const { body } = await apiRoot.widgets().post({ body: data }).execute();
return body;
}
| File | Owns |
|---|---|
<server>/ct/auth | signInCustomer, signUpCustomer, getCustomerById, updateCustomer |
<server>/ct/cart | All cart + order operations |
<server>/ct/orders | getCustomerOrders, getOrderById |
<server>/ct/search | searchProducts, getProductBySku |
<server>/ct/categories | getCategoryTree, getCategoryBySlug |
<server>/ct/wishlist | Shopping list operations |
Pattern 5: Client State Hook with Mutations
deleteWidget that only does fetch('/<api>/widgets/<id>', { method: 'DELETE' }) leaves the UI stale until the next revalidation.- A read hook is keyed by
KEY_WIDGETSand reads the widgets list from the widgets endpoint (GET /<api>/widgets). It does not revalidate on focus. The fetcher returns a safe default ([]) when the response is not ok — read hooks never throw. - A mutations module wraps each write (
createWidget,deleteWidget). Each write calls the endpoint, throws on a non-ok response (surfacing the server's error message), then updatesKEY_WIDGETSdirectly from the response body without a refetch. A delete also clears the detail keykeyWidget(id). (Updating from the response body is preferred over a blind revalidation, which costs an extra round-trip.)
Mutations always throw — the component wraps the call intry/catchand shows the error. Read hooks return safe defaults (null,[]) on failure — never throw.
[KEY_WIDGETS, country, currency] — built from the framework's locale/currency context, and read only once both parts are present (otherwise the key is null and the read is skipped). This refetches automatically when the locale tuple changes.Find the stack'sconcept-mapping.mdfor concrete state and cache implementation.
Checklist
- Cache key(s) added to
<server>/cache-keys - Server endpoint validates session before accessing user data
- commercetools calls in
<server>/ct/<namespace>.ts— not inside the endpoint - Read hook does not revalidate on focus (exception: the cart hook does — cart must stay fresh when the user returns from another tab)
- Mutations throw on error; read hooks return safe defaults
- Mutations update the client state-manager/cache from the response body — no refetch
- Types exported from the hook file — not from
<server>/ct/ - No endpoint (
fetch('/<api>/*')) calls directly in components
Add Country / Locale
COUNTRY_CONFIG object. Update it once, then add a messages file, routing entry, and hero config.Table of Contents
- Pattern 1: Single Source of Truth
- Pattern 2: Routing Update
- Pattern 3: Message File
- Pattern 4: Hero Config
Pattern 1: Single Source of Truth
// BAD — scattered across files
// In cart.ts:
currency: 'EUR'
// In checkout.ts:
country: 'DE'
// In the header component:
const locales = ['en-US', 'de-DE'];
COUNTRY_CONFIG in <server>/utils only:// <root-dir>/<server>/utils
export const COUNTRY_CONFIG: Record<string, CountryConfig> = {
'en-US': {
locale: 'en-US', // BCP-47 — used as COUNTRY_CONFIG key, URL segment, and in commercetools API calls
currency: 'USD', // ISO 4217
country: 'US', // ISO 3166-1 alpha-2
label: 'United States',
flag: '🇺🇸',
},
'de-DE': {
locale: 'de-DE',
currency: 'EUR',
country: 'DE',
label: 'Germany',
flag: '🇩🇪',
},
// ADD NEW COUNTRY HERE:
'fr-FR': {
locale: 'fr-FR',
currency: 'EUR',
country: 'FR',
label: 'France',
flag: '🇫🇷',
},
};
locale value is used directly in commercetools search queries (language: locale). The currency is used when creating carts. Everything else derives from this map — no other files need direct currency/country hardcoding.Pattern 2: Routing Update
COUNTRY_CONFIG keys, never be hardcoded — otherwise it drifts every time a country is added. In practice this is Object.keys(COUNTRY_CONFIG) fed into the routing configuration, with a default locale (e.g. en-US).- Update the Merchant center by adding the new country, language and currency from Settings > Project settings
- Update and add a new entry to
COUNTRY_CONFIGis all that's needed — the locale list updates automatically. The COUNTRY_CONFIG key is the BCP-47 locale (e.g.fr-FR), the same format commercetools uses for API calls, and the URL segment matches the key exactly:/fr-FR/,/de-DE/.
The routing wiring itself is framework-specific — find the adapter'sproject-layout.md.
Pattern 3: Message File
// BAD — wrong filename, won't be picked up by the framework's i18n loader
messages/fr.json
messages/fr-fr.json
messages/FR.json
messages/<BCP-47>.json matching the key in COUNTRY_CONFIG:# Copy the closest existing locale as a starting point
cp <root-dir>/messages/de-DE.json <root-dir>/messages/fr-FR.json
fr-FR.json. The filename must exactly match the COUNTRY_CONFIG key (e.g. fr-FR.json for the 'fr-FR' entry).// <root-dir>/messages/fr-FR.json (excerpt)
{
"common": {
"addToCart": "Ajouter au panier",
"checkout": "Passer à la caisse",
"search": "Rechercher"
},
"cart": {
"empty": "Votre panier est vide",
"subtotal": "Sous-total"
}
}
Checklist
- New entry added to
COUNTRY_CONFIGin<root-dir>/<server>/utilswithlocale,currency,country,label - Locale list in the framework's routing config derives from
COUNTRY_CONFIGkeys (not hardcoded) — Adapter'sproject-layout.md -
<root-dir>/messages/<BCP-47>.jsoncreated with all translation keys - commercetools Merchant Center: prices defined for the new currency in the product catalogue
- commercetools Merchant Center: shipping zones/methods set up for the new country
Adding a New Page
Two shapes: a standalone page (most cases) or a layout/sections CMS page (marketing). The decisions below are framework-agnostic; the concrete Next.js primitives are linked at each point.
Decisions (framework-agnostic)
- Server-rendered by default. A page that needs first-paint data is a server-rendered load that fetches via
<server>/ct/*— never a client component fetching commercetools directly, and never the commercetools SDK called inline in the page. Add a client component only for interactivity. - Resolve route params, then fetch. Read the route's dynamic params (e.g.
[id],[slug]) and the locale, fetch the data, and return the framework's not-found response when a required resource is missing — don't render a fallback. - SEO metadata. Every page declares a title and description; dynamic pages derive them from the fetched resource, fetched with the same context as the page so the SEO data can't diverge.
- Locale-aware links only. Use the framework's locale-aware link/navigation primitive — never a bare anchor or a non-locale link, which produces broken
/en-US/en-US/...URLs. - Keep interactivity at the leaves. Keep the page server-rendered and extract interactive UI (event handlers, local state) into client components, passing plain data down — never make the whole page a client component just to handle a
select/button.
Stack mapping
Each decision above maps onto concrete framework primitives — the server-rendered page shell, route-param resolution, the not-found response, the page-metadata API, the locale-aware link/navigation primitive, and the client-component boundary for interactivity.
Find the adapter'sconcept-mapping.mdandbest-practices/for concrete not-found/redirect, metadata API, locale-aware link, client boundary implementation.
Checklist
- Page is server-rendered by default; data fetched via
<server>/ct/*, not the SDK inline - Route params resolved before fetching; not-found response returned for missing required resources
- SEO title + description present (static or derived from the fetched resource)
- Links use the framework's locale-aware primitive — never a bare/non-locale link
- Interactive UI extracted into client components; the page itself stays server-rendered
- Translations added for every active locale (e.g.
messages/<locale>.json)
Cart
cartId are the most common production bugs. Every write path must re-fetch version and retry.This reference covers commercetools cart creation, all server endpoints, the cart client-state hook, the cart provider, the mini-cart drawer, and the full cart page.
Table of Contents
- Pattern 1: commercetools Cart Helper Functions
- Pattern 2: Cart Server Endpoints
- Pattern 3: Cart Client State Hook
- Pattern 4: Cart Provider
- Pattern 5: Mini-Cart Drawer
- Checklist
Pattern 1: commercetools Cart Helper Functions
<server>/ct/cart (key functions):- getCart: get current user's active cart by ID or Key.
- create a new cart: this function should create a new cart for anonymous or logged in customer for current country/currency.
- add lineitem: add a lineitem by its' SKU or (product ID, variant ID)
- remove lineitem: remove a lineitem by its' ID
- change lineitem quantity
- redeem a discount code: should return the error back to frontend for display
- remove applied discount code
import { apiRoot } from './client';
import type { BaseAddress, ShippingMethodResourceIdentifier } from '@commercetools/platform-sdk';
export async function exampleFunction(cartId: string) {
const returnValue = await apiRoot.carts()...
return returnValue;
}
Pattern 2: Cart Server Endpoints
- If the session has no
cartId, return{ cart: null }. - Otherwise load the cart via
getCart(session.cartId)and:- If the cart is not
Active(e.g.Ordered,Merged), clearcartIdfrom the session and return{ cart: null }— the client should see an empty cart. - If
getCartthrows (cart not found in commercetools), clear the stalecartIdfrom the session and return{ cart: null }. - Otherwise return
{ cart }.
- If the cart is not
<server>/ct/cart helper. Each endpoint that touches the session also writes the updated session back on the way out.Example Next.js shape: these cart server endpoints (usingNextResponse+setSessionCookie) follow the standard BFF endpoint shell — find adapter'sdata-loading.md.
Pattern 3: Cart Client State Hook
fetch('/<api>/cart') directly in a component.- A cart read hook is keyed by
KEY_CARTand reads the cart from the cart endpoint (GET /<api>/cart). The fetcher returnsnullwhen the response is not ok — it never throws. Unlike other reads, this hook does revalidate on focus, so the cart stays fresh when the user returns from another tab. It accepts an optional server-fetched cart to seed the cache (eliminating the first-render loading state). - A cart mutations module exposes all cart writes (add/remove line item, change quantity, discount). Each write calls the matching endpoint and then updates
KEY_CARTdirectly from the API response body without a refetch — always prefer this over a blind revalidation, which costs a second round-trip.
Cart type is declared in the shared types module and imported by the hook — never imported from @commercetools/platform-sdk.Find the stack'sconcept-mapping.mdfor concrete state and cache implementation.
Pattern 4: Cart Provider
- reads the cart via the cart client-state hook (seeded with a server-fetched
initialCart) and re-exposescart+isLoading; - owns the mini-cart open/close flag (
showMiniCart,openMiniCart,closeMiniCart) in local client state; - exposes an
addToCart(productId, variantId, quantity?)convenience that calls the mutations module'saddItemand then opens the mini-cart; - re-exposes the full cart mutations module as
mutateCart; - provides a
useCartContext()accessor that throws if used outside the provider.
initialCart server-side when session.cartId exists (getCart(session.cartId).catch(() => null)) and pass it to the provider to eliminate the client-side loading state.Find the stack'sdata-loading.mdfor concrete layout-level hydration pattern implementation.
Pattern 5: Mini-Cart Drawer
cart greenfield skill. Key points:- Renders only when
showMiniCart === true - Backdrop click calls
closeMiniCart() - Items use
mutateCart.removeLineItem()for optimistic removal - "Proceed to Checkout" link closes the mini-cart
commercetools Concurrency Notes
version integer. If two requests arrive simultaneously (e.g. address + shipping method fired at the same time from the checkout page), one will be rejected with 409 ConcurrentModification.Checklist
-
<server>/ct/cartcreates carts withshippingMode: 'Single' - The cart GET endpoint discards non-Active carts and clears
cartIdfrom session - The add-item endpoint creates a cart on demand if
cartIdis absent - The cart mutations module updates the client state-manager/cache from the response body — no extra refetch
- The cart provider wraps the app at the root layout with
initialCartfrom the server -
KEY_CARTfrom<server>/cache-keysis the single client state-manager/cache key for cart data
Checkout Page
This reference covers the shared checkout structure used by both B2C and B2B storefronts: the multi-step page shell, shipping method selection, payment via the Checkout frontend SDK, and the confirmation page. Address step details and order placement are storefront-specific — see the relevant extension file.
Table of Contents
- Pattern 1: Multi-Step Checkout Page Structure
- Pattern 2: Address Step
- Pattern 3: Shipping Method Selection
- Pattern 4: Payment Step — Checkout Frontend SDK
- Pattern 5: Confirmation Page
- Checklist
Pattern 1: Multi-Step Checkout Structure
addresses, shipping, payment. The index reads the cart state and redirects to the furthest step the user can access. The decision is framework-agnostic:hasAddr = !!(cart.shippingAddress?.streetName && cart.billingAddress?.streetName);hasMethod = !!cart.shippingInfo.hasAddr && hasMethod→payment; elsehasAddr→shipping; else →addresses.- Each step repeats the guard and redirects back when prerequisites are unmet (e.g. on
shippingwith no address →addresses). - Wait until the cart has loaded before deciding (skip while
cart === undefined).
Layout: two-column grid — steps on the left (3 cols), sticky order summary on the right (2 cols).
The index and step components are client components that drive step changes through the framework's client navigation (locale-aware replace).
Find the adapter'sconcept-mapping.mdfor the client-navigation shell implementation.
Pattern 2: Address Step
Address step details differ between storefronts — saved address sources and validation rules vary. See the storefront-specific extension for the full address step implementation.
- Only store address details when moving to the next step
- Display the "State" field only when the selected country requires it
Pattern 3: Shipping Method Selection
currency from getLocale(), loads getShippingMethods(), filters to methods with a matching rate, and returns { shippingMethods } (or [] on failure).country + currency (null until both are known, so it doesn't fetch prematurely). Configure it not to re-fetch on tab focus — shipping methods change rarely.shippingMethodId and update the client state-manager/cache/state from the response (no refetch).Pattern 4: Payment Step — Checkout Frontend SDK
The payment step is handled entirely by the Checkout frontend SDK, which renders the full payment UI and drives order placement.
Reference: See the Checkout frontend SDK implementation skill for full setup, component mounting, and event handling.
Key rules:
- Do not implement a custom payment form — mount the SDK component and let it manage the flow.
- The SDK handles order creation internally; do not create a method/call to handle order creation.
- After the SDK signals order completion, clear
cartIdfrom the session and redirect to the confirmation page.
Pattern 5: Confirmation Page
orderId from the URL. Do not rely on the client state cache here — the order may not yet appear in a freshly revalidated client state-manager/cache. Fetch getOrderById(orderId) in a try/catch; on failure, show a minimal confirmation without line items./checkout/confirmation?orderId=<id> on success.Find the adapter'sconcept-mapping.md. Example: Next.js: the Server Component shell (app/[locale]/checkout/confirmation/[orderId]/page.tsxwithawait params) is in the adapter's concept-mapping.md.
Checklist
- Checkout index redirects to the correct step based on cart state
- Step skip guards redirect back if prerequisites are not met
- The shipping-methods endpoint filters by session currency
- Address changes debounced to update cart address method
- Payment step mounts the Checkout frontend SDK — no custom payment form
-
cartIdcleared from session after the SDK signals order completion - Confirmation page is server-rendered and fetches the order by ID from commercetools
commercetools Client & Session
apiRoot, getSession, and the BFF boundary being correctly wired.This reference covers the commercetools SDK singleton, environment setup, server-managed sessions, and the BFF (Backend-for-Frontend) architecture that prevents credential leaks.
Architecture assumption — a server tier exists. The BFF and secret rules below require a server-side tier (SSR, server components, or a standalone BFF service) that holds secrets and proxies commercetools. For the concrete framework binding (file paths, cookie read/write API, route shape), see the matching stack adapter underreferences/stack/— e.g. the Next.js stack.
Table of Contents
- Pattern 1: SDK Client Singleton
- Pattern 2: Environment Variables
- Pattern 3: Session Management
- Pattern 4: BFF Boundary
- Pattern 5: commercetools Helper Function Shape
- Pattern 6: Connection Health Check
- Checklist
Pattern 1: SDK Client Singleton
ClientBuilder singleton pattern, package installation, and the rule: one apiRoot module-level export in <server>/ct/client — never instantiate ClientBuilder inside a page, component, or server endpoint.Pattern 2: Environment Variables
.env template, auth URLs by region, and required API client scopes.SESSION_SECRET. A stateful BFF instead keeps its session-store credentials server-only.Pattern 3: Session Management
cartId or customerId in localStorage or a non-HTTP-only cookie — readable by XSS and not server-authoritative.- Stateless BFF — encode the session as a signed token (e.g. a JWT signed with a server-only secret) in an HTTP-only cookie. No server-side storage.
- Stateful BFF — keep the session in a server-side store (Redis, DB, edge KV) keyed by an opaque session id in an HTTP-only cookie.
sameSite: 'lax', path: '/', ~30-day lifetime), the session is read/written only on the server, and the client never sees session secrets or raw commercetools credentials.getSession() (current session, or {} if none/invalid), getLocale() (resolve country/currency/locale from the session, falling back to the your-shop-country-locale cookie + COUNTRY_CONFIG), a write step (sign a token or upsert the store record), and set/clear of the opaque cookie.// <server>/session — the session shape is portable; storage + cookie binding are stack-specific
export interface Session {
customerId?: string;
customerEmail?: string;
customerFirstName?: string;
customerLastName?: string;
cartId?: string;
country?: string;
currency?: string;
locale?: string;
// B2B adds: businessUnitKey, storeKey, storeId, distributionChannelId, supplyChannelId, productSelectionId
}
| Field | Set when | Cleared when |
|---|---|---|
customerId | Login/register | Logout |
cartId | Cart created or login | Order placed |
country/currency/locale | Country selector | Never (persists) |
The storage mechanism and cookie read/write binding are stack-specific.
Find the adapter'sdata-loading.mdfor parrents of implementation. Example Next.js (stateless BFF): ajose-signed JWT in an HTTP-only cookie viacookies()(next/headers) +NextResponse.cookies, withgetSession/getLocale/createSessionToken/setSessionCookie/clearSessionCookie— see the full<server>/sessionmodule in the adapter's data-loading.md.
Pattern 4: BFF Boundary
<server>/ct/* directly from a client component or a browser-side fetcher.Browser component
→ client data hook (hooks/*.ts) — calls fetch('/<api>/...') / the framework's data loader
→ server endpoint — server-only, calls <server>/ct/*
→ <server>/ct/<namespace>.ts — server-only, calls apiRoot
→ commercetools API
The server endpoint is your framework's request handler (Next.js Route Handler, Remix action/loader, Express route, etc.). Its concrete shape — and the rule that it does only three things (validate session → call <server>/ct/<namespace>.ts → return JSON) — is in adapter's data-loading.md file.
Pattern 5: commercetools Helper Function Shape
apiRoot.carts().withId()...execute() directly in a server endpoint. Or calling the commercetools REST API with raw fetch() — the SDK handles OAuth token lifecycle, automatic token refresh, and type safety; bypassing it means managing all of that manually.<server>/ct/ file:// <server>/ct/<namespace>.ts
import { apiRoot } from './client';
export async function getThings(id: string) {
// Destructure body from the SDK response — every .execute() returns { body, statusCode, headers }
const { body } = await apiRoot.things().withId({ ID: id }).get().execute();
return body;
}
| File | Owns |
|---|---|
<server>/ct/client | apiRoot singleton |
<server>/ct/auth | signInCustomer, signUpCustomer, getCustomerById, updateCustomer |
<server>/ct/cart | All cart operations (create, addLineItem, removeLineItem, discounts, shipping) |
<server>/ct/orders | getOrderById, getCustomerOrders |
<server>/ct/categories | getCategoryBySlug, getCategoryTree |
<server>/ct/search | searchProducts, getProductBySku |
Pattern 6: Connection Health Check
apiRoot.get().execute() and returns the project key.Checklist
- SDK singleton and env vars set up per sdk-setup.md
- All commercetools calls go through
apiRoot— no rawfetch()to commercetools REST endpoints - Any session-signing secret / session-store credential is strong, server-only, and never exposed to the client bundle
- The session module exports
getSession,getLocale,createSessionToken,setSessionCookie,clearSessionCookie - Health check returns
{"ok":true}with your project key
Customer Authentication — Shared Foundation
customer-auth.md.Table of Contents
- Pattern 1: commercetools Login Endpoint
- Pattern 2: Server Endpoint Structure
- Pattern 3: useAccount Client State Hook
- Pattern 4: Logout — Session and client state-manager/cache Clearing
- Checklist
Pattern 1: commercetools Login Endpoint
apiRoot.customers().login() — this endpoint does not exist in commercetools SDK v2:// WRONG — SDK v2 does not have this endpoint
const { body } = await apiRoot.customers().login().post({ body: { email, password } }).execute();
apiRoot.login().post():// <server>/ct/auth
export async function loginCustomer(email: string, password: string) {
const { body } = await apiRoot.login().post({ body: { email, password } }).execute();
return body.customer;
}
This is the only valid login endpoint across all commercetools SDK v2 storefronts.
Pattern 2: Server Endpoint Structure
Login, register, and logout are BFF server endpoints — never called client-side from components directly.
Browser component
→ client data hook (a per-domain auth hook or useAccount) — calls fetch('/<api>/auth/...')
→ server endpoint — server-only, reads/writes the session, calls <server>/ct/auth
→ <server>/ct/auth — calls apiRoot
- Validate that
emailandpasswordare present (400 otherwise). - Call
loginCustomer(email, password)(which usesapiRoot.login().post()— Pattern 1). - Build a server-managed session carrying at minimum
customerId,customerEmail,customerFirstName,customerLastNameand persist it. The storage mechanism (a signed token in a cookie, or a server-side session store) is a stack choice. - Return the customer object as JSON.
// <server>/ct/auth — the commercetools call is portable
export async function loginCustomer(email: string, password: string) {
const { body } = await apiRoot.login().post({ body: { email, password } }).execute();
return body.customer;
}
B2C login handlers also merge the anonymous cart. B2B login handlers also resolve BU/store/channel fields. Each domain'scustomer-auth.mdshows the full handler with these additions.
The concrete login server endpoint follows the BFF endpoint shell, find it indata-loading.mdof the adapter's.
Pattern 3: useAccount Client State Hook
customerId from localStorage or a cookie on the client — not reactive, not server-safe.useAccount client-state hook backed by a /<api>/auth/me (or /<api>/account/profile) server endpoint: the hook is keyed by KEY_ACCOUNT, reads the current customer from the account-profile endpoint (GET /<api>/account/profile), and does not revalidate on focus. Its fetcher returns null when the response is not ok. It exposes the current user plus a way to update the cached value after a profile change.null if unauthenticated, or if getCustomerById(session.customerId) throws. It never throws to the client.Find the adapter'sconcept-mapping.mdto see client state/cache implementation.
B2B storefronts useGET /<api>/auth/meand an auth-context wrapper in addition to the hook — see B2Bcustomer-auth.mdfor the full pattern.
Pattern 4: Logout — Session and client state-manager/cache Clearing
KEY_ACCOUNT leaves stale cart/order data in the client state-manager/cache.- Calls the logout server endpoint (
POST /<api>/auth/logout). - Evicts every user-scoped cache key from the client state-manager/cache, setting each to a safe empty value without a refetch — at minimum
KEY_ACCOUNTandKEY_CART(B2B also evictsKEY_BUSINESS_UNITS). - Navigates to
/loginusing the framework's client navigation.
locale, currency, country and omits all user fields (customerId, cartId, and any domain-specific fields), then returns success.Checklist
-
<server>/ct/authusesapiRoot.login().post()— NOTapiRoot.customers().login() - Login endpoint writes the session with at minimum
customerIdand customer name fields -
useAccounthook usesKEY_ACCOUNTas its cache key and does not revalidate on focus - Logout endpoint preserves
locale,currency,countryand clears user fields - Logout clears both
KEY_ACCOUNTandKEY_CARTfrom the client state-manager/cache
- B2C: see b2c/customer-auth.md for anonymous cart merge and protected layout
- B2B: see b2b/customer-auth.md for BU auto-selection, channel resolution, and the auth-context wrapper
Data Loading
<server>/ct/* in a hook are the most common violations. commercetools types must never reach a component — map them at the commercetools layer.Table of Contents
- Pattern 1: Server-rendered vs Client-fetched Decision
- Pattern 2: commercetools Type Boundary
- Pattern 3: BFF API Route Shape
- Pattern 4: Version Conflict
- Pattern 5: Server-Side Caching
- Checklist
Pattern 1: Server-rendered vs Client-fetched Decision
| Data | Pattern | Reason |
|---|---|---|
| Initial product list | Server-rendered | First paint, SEO, no spinner |
| Category tree | Server-rendered + TTL cache | Stable, needs SSR |
| Cart | Client-fetched (client state) | Changes after add/remove actions |
| Account / orders | Client-fetched (client state) | Changes after login |
| Search results | Server-rendered (via URL params) | SEO, shareable URLs |
Rules:
- Server-rendered pages fetch on the server and call
<server>/ct/*directly — no client-side bundle unless the page needs browser APIs - Pass
sessionto commercetools functions rather than callinggetSession()inside each function - Return a not-found response for missing required resources
Find adapter'sdata-loading.mdfile for implementation of this decision (async Server Component vs SWR hook → Route Handler)
Pattern 2: commercetools Type Boundary
<server>/ct/. Components import from <server>/types — never from @commercetools/platform-sdk.<server>/mappers/. Each file maps one commercetools resource to one app type:| File | Maps |
|---|---|
<server>/mappers/product | ProductProjection → Product |
<server>/mappers/category | commercetools Category → app Category |
<server>/mappers/cart | commercetools Cart → app Cart |
<server>/mappers/order | commercetools Order → app Order |
<server>/mappers/line-item | commercetools LineItem → app LineItem |
<server>/mappers/customer | commercetools Customer → app Account |
<server>/mappers/money | commercetools TypedMoney → app Money |
<server>/mappers/facet | commercetools facet results → FacetResult[] |
getLocalizedString(field, locale) resolves LocalizedString to a plain string — falls back to default locale then first available. Call it only inside <server>/ct/ or <server>/mappers/, never in components.Pattern 3: BFF Server Endpoint Shape
A server endpoint (your framework's request handler) has exactly three responsibilities — no more:
- Validate session
- Call
<server>/ct/<namespace>.ts— never the commercetools SDK directly - Return JSON with the correct status
fetch('/<api>/*')) directly in a component — put it in a client data hook (hooks/*Api.ts).The concrete login server endpoint follows the BFF endpoint shell, find it indata-loading.mdof the adapter's.
Pattern 4: Version Conflict
version. When two requests arrive simultaneously one will be rejected with 409 ConcurrentModification. Re-fetch the entity's version before the action.<server>/ct/<entity>.ts (or a route-handler-level helper), not scattered across components.
For example when updating cart fetch the cart version using the refetch logic in <server>/ct/cart and use it in the cart update.Pattern 5: Server-Side Caching
<server>/ct/* call for user-specific data.Prefer a real cache primitive over module-level variables — module-level caches reset on cold starts and are not shared across serverless instances.
Checklist
-
<server>/ct/never imported in client components — import types from<server>/types - commercetools responses mapped to app types inside
<server>/ct/<namespace>.tsvia mappers -
getLocalizedStringcalled only in<server>/ct/or<server>/mappers/ - Components import from
<server>/types— never from@commercetools/platform-sdk - All independent server-side fetches use
Promise.all - Server endpoints have exactly 3 responsibilities: validate session, call
<server>/ct/, return JSON - Endpoint calls (
fetch('/<api>/*')) live in client data hooks (hooks/*Api.ts), not in components - Avoid Cart version conflict by refetch — logic lives in
<server>/ct/cart - Stable public data cached with the framework's server-side cache-with-TTL — never per-user data
Image Config
<root-dir>/<server>/ct/image-config. Edit the config — never components.best-practices/image.md file.Table of Contents
- Pattern 1: Three Transform Functions
- Pattern 2: Suffix Pattern
- Pattern 3: CDN Hostname Replacement
- Pattern 4: Imgix and Cloudinary
- Pattern 5: Adding a New Context
Pattern 1: Three Transform Functions
// <root-dir>/<server>/ct/image-config
/**
* ProductCard on listing/search pages.
*/
export function transformListingImageUrl(url: string): string {
return url; // identity by default — override below
}
/**
* Main carousel image on the PDP.
*/
export function transformDetailImageUrl(url: string): string {
return url;
}
/**
* Thumbnail strip on the PDP.
*/
export function transformThumbnailImageUrl(url: string): string {
return url;
}
https://storage.googleapis.com/merchant-center-europe/...) and returns the transformed URL. Keep the signature — components call these by name.The framework's image optimizer should be disabled — the commercetools CDN rejects optimizer query params (?url=...&w=...&q=...), and these functions handle sizing directly. (Next.js:images.unoptimized: true— see the adapter.)
Pattern 2: Suffix Pattern
// <root-dir>/<server>/ct/image-config
// Inserts '-medium' before the last extension, e.g.:
// .../product.jpg → .../product-medium.jpg
// .../product.jpg?v=2 → .../product-medium.jpg?v=2
function addSuffix(url: string, suffix: string): string {
return url.replace(/(\.[^./?#]+)($|\?)/, `${suffix}$1$2`);
}
export function transformListingImageUrl(url: string): string {
return addSuffix(url, '-medium'); // e.g. product-medium.jpg
}
export function transformDetailImageUrl(url: string): string {
return addSuffix(url, '-large');
}
export function transformThumbnailImageUrl(url: string): string {
return addSuffix(url, '-small');
}
Pattern 3: CDN Hostname Replacement
Swap the GCS origin for a custom CDN hostname:
// <root-dir>/<server>/ct/image-config
const CDN = 'https://cdn.example.com';
const ORIGIN = 'https://storage.googleapis.com';
export function transformListingImageUrl(url: string): string {
return url.replace(ORIGIN, CDN);
}
export function transformDetailImageUrl(url: string): string {
return url.replace(ORIGIN, CDN);
}
export function transformThumbnailImageUrl(url: string): string {
return url.replace(ORIGIN, CDN);
}
Combine with the suffix pattern if the CDN also uses filename-based sizing.
Pattern 4: Imgix and Cloudinary
// <root-dir>/<server>/ct/image-config
const IMGIX_BASE = 'https://mystore.imgix.net';
const ORIGIN = 'https://storage.googleapis.com/my-bucket';
export function transformListingImageUrl(url: string): string {
const path = url.replace(ORIGIN, '');
return `${IMGIX_BASE}${path}?w=400&h=500&fit=crop&auto=format`;
}
export function transformDetailImageUrl(url: string): string {
const path = url.replace(ORIGIN, '');
return `${IMGIX_BASE}${path}?w=800&h=1000&fit=crop&auto=format`;
}
export function transformThumbnailImageUrl(url: string): string {
const path = url.replace(ORIGIN, '');
return `${IMGIX_BASE}${path}?w=100&h=125&fit=crop&auto=format`;
}
// <root-dir>/<server>/ct/image-config
const CLD = 'https://res.cloudinary.com/my-cloud/image/fetch';
export function transformListingImageUrl(url: string): string {
return `${CLD}/w_400,h_500,c_fill,f_auto,q_auto/${encodeURIComponent(url)}`;
}
export function transformDetailImageUrl(url: string): string {
return `${CLD}/w_800,h_1000,c_fill,f_auto,q_auto/${encodeURIComponent(url)}`;
}
export function transformThumbnailImageUrl(url: string): string {
return `${CLD}/w_100,h_125,c_fill,f_auto,q_auto/${encodeURIComponent(url)}`;
}
Pattern 5: Adding a New Context
image-config.ts and import it in the component:// <root-dir>/<server>/ct/image-config
// New context: cart line item thumbnail
export function transformCartImageUrl(url: string): string {
return addSuffix(url, '-thumb');
}
// <root-dir>/components/cart/CartItem.tsx
import { transformCartImageUrl } from '<server>/ct/image-config';
// ...render with the framework's image primitive using transformCartImageUrl(item.imageUrl)
image-config.ts means a single config change updates all instances.Recurring Orders
RecurringOrder resource does not carry its own line items, which surprises most implementers. Always expand originOrder or the subscription management UI will have nothing to display.RecurringOrder represents an active subscription. It references the originating order, a RecurrencePolicy (the schedule), and the customer or business unit that owns it. commercetools handles the actual re-ordering on schedule; the storefront manages the lifecycle (pause, resume, cancel) and displays the subscription status to the user.Table of Contents
- Pattern 1: Resources and SDK Accessors
- Pattern 2: Fetching Recurring Orders
- Pattern 3: State Transitions
- Pattern 4: Creating a Recurring Order
- Pattern 5: Recurrence Policies
- Pattern 6: Server Endpoints
- Pattern 7: Client State and Cache
- Tips and Tricks
Pattern 1: Resources and SDK Accessors
| Resource | SDK Accessor | Notes |
|---|---|---|
RecurringOrder | apiRoot.recurringOrders() | Project-level — not under as-associate |
RecurrencePolicy | apiRoot.recurrencePolicies() | Project-level; defines schedule |
apiRoot. commercetools does not yet expose recurring orders under the as-associate endpoint; authorization is enforced in the app via scoped where clauses.Pattern 2: Fetching Recurring Orders
Scoping
where clause. Without scoping, an admin-credential client returns all recurring orders in the project.- B2C:
customer(id="${customerId}") - B2B:
businessUnit(key="${businessUnitKey}")
where clause.The line items problem
RecurringOrder does not carry its own lineItems array. Always expand originOrder:expand: ['originOrder']
Omitting the expand means the subscription UI has no items to display.
lineItems field on RecurringOrder is often empty even when the expand succeeds. Always read from originOrder.obj.lineItems as the authoritative source.Pattern 3: State Transitions
setRecurringOrderState update action with a read-then-write pattern:- Fetch the recurring order to get its current
version - POST with that
versionand thesetRecurringOrderStateaction
| Intent | recurringOrderState value |
|---|---|
| Pause | { type: 'paused' } |
| Resume | { type: 'active' } |
| Cancel | { type: 'canceled' } |
There is no optimistic locking retry in the standard implementation — a 409 version conflict surfaces to the user as an error.
skip, setSchedule in B2C).Pattern 4: Creating a Recurring Order
RecurringOrder per subscription line item — not one per order.createRecurringOrder fails, log the error but do not roll back the placed order. The customer completed their purchase; the subscription record can be re-created or investigated separately. Rolling back the order would be a worse outcome.recurrenceInfo attached to the cart's line items when they were added — it does not need to be re-specified on the draft body. See the context-specific files for the exact draft shape.Pattern 5: Recurrence Policies
Policies are defined in commercetools — never hardcode schedule options in the app. Fetch them project-level:
apiRoot.recurrencePolicies().get({ queryArgs: { limit: 20 } })
Two client state hooks serve different consumers:
- one returns
Map<policyId, humanLabel>for inline display in cart items and mini cart - one returns the full
RecurrencePolicy[]for the PDP selector and subscription pages
Both hooks must share the same client state-manager/cache key so only one HTTP request is made.
formatInterval(schedule) helper converts { intervalUnit, value } to a human label (e.g. "Every 2 months"). It must handle both singular ('month') and plural ('months') forms of intervalUnit — commercetools data uses both.Pattern 6: Server Endpoints
Standard server-endpoint structure:
| Method | Path | Action | Notes |
|---|---|---|---|
GET | /<api>/[prefix] | List recurring orders | Scoped by owner; auth fields differ by context |
GET | /<api>/[prefix]/[id] | Fetch single order | Always expand: ['originOrder'] |
PUT or POST | /<api>/[prefix]/[id] | State transitions | See context-specific for endpoint style |
GET | /<api>/recurrence-policies | List all policies | No owner scoping needed |
POST /<api>/[prefix] for creation — recurring orders are created from within the checkout server endpoint.Pattern 7: Client State and Cache
Use a client state cache for recurring order data — it changes only on explicit user action (pause, resume, cancel).
After any state-change action, invalidate (or update from the response) both the list and the individual item so both the list view and the detail view reflect the change without a full page reload.
customerId or businessUnitKey) so the cache auto-invalidates when the user switches context.Find the stack'sconcept-mapping.mdfor concrete state and cache implementation.
Tips and Tricks
canceled not cancelled: commercetools expects single-l. The UI may display "Cancelled" with double-l but the API value must be 'canceled'. Sending 'cancelled' causes a silent 400 or an unexpected state.recurrencePolicyId is not a first-class field: RecurringOrder does not have a top-level recurrencePolicyId. Derive it by inspecting originOrder.obj.lineItems for recurrenceInfo.recurrencePolicy.id. This derivation belongs in the mapper.manage_recurring_orders scope separately from the admin/tools client. Do not assume admin scopes cover storefront calls.Recurring Prices
RecurrencePolicy. The policy reference is the link that connects a price to a schedule.Table of Contents
- Pattern 1: The Recurring Price Signal
- Pattern 2: BFF Mapper
- Pattern 3: PDP Separation and Gate
- Pattern 4: Policy Selector Component
- Pattern 5: Add to Cart with Recurrence
- Tips and Tricks
Pattern 1: The Recurring Price Signal
recurrencePolicy reference. A price without this reference is a one-time price. The reference holds only the policy id — the full policy name and schedule must be resolved separately from the recurrence policies list.variant.recurrencePrices[]. This is a first-class field in the commercetools platform SDK — available in both B2B and B2C, no cast required, no extra expand needed. The array comes back automatically in the standard product projection alongside variant.prices[].recurrencePrices has the same money shape as a regular price entry plus a recurrencePolicy: { id, typeId: 'recurrence-policy' } reference.Price type in app code should expose recurrencePolicy?: { id: string } so the rest of the app can check presence without knowing the commercetools field structure.Pattern 2: BFF Mapper
<server>/mappers/product), not in server endpoints or components. The mapper is the single place that reads from the commercetools SDK response. By the time a Price object reaches the UI, it should already have recurrencePolicy normalised to { id: string } | undefined.Price interface must include:recurrencePolicy?: { id: string } // present iff this is a recurring price
v.recurrencePrices in the variant mapper alongside v.prices. The recurrencePolicy reference is already typed in the SDK — no cast required. Preserve it as-is on each mapped price entry.Pattern 3: PDP Separation and Gate
recurrencePolicy:regularPrice— the first price withoutrecurrencePolicyrecurringPrices— all prices withrecurrencePolicy
Gate the subscription UI on two conditions:
- A context-specific eligibility check — see B2B/B2C files (e.g. login state, BU membership)
recurringPrices.length > 0for the selected variant
If either condition is false, render the standard Add to Cart button only.
PDPAddToCart → PDPActions → SubscribeAndSave / SubscribeAndSaveBoxregularPrice and recurringPrices should happen as high in the tree as possible (in PDPAddToCart) so that leaf components receive typed, already-partitioned data rather than raw arrays.Pattern 4: Policy Selector Component
The subscription selector lets users choose between a one-time purchase and one of the available recurring schedules.
oneTimePrice— the regular price for the one-time optionrecurringPrices— array of recurring prices, each with arecurrencePolicy.idpolicies— the fullRecurrencePolicy[]list for resolving names and schedulesvalue—'one-time'or arecurrencePolicyIdonChange— callback receiving'one-time'or the selected policy ID
price.recurrencePolicy.id → policies.find(p => p.id === id)?.name. Render the raw ID as a fallback if the policy is not in the list.availablePolicies to those that have a matching entry in recurringPrices. Showing all policies would let users select a schedule with no price defined for the current variant.Pattern 5: Add to Cart with Recurrence
When the user confirms their selection:
- If selected value is
'one-time': add the line item with norecurrenceInfo. Do not passrecurrencePolicyIdat all — do not pass it asundefinedornull. - If selected value is a policy ID: attach
recurrenceInfoto theaddLineItemaction.
recurrenceInfo shape:recurrenceInfo: {
recurrencePolicy: { typeId: 'recurrence-policy', id: recurrencePolicyId },
priceSelectionMode: 'Fixed',
}
priceSelectionMode: 'Fixed' tells commercetools to use the specific recurring price for this policy, not to run dynamic pricing.Tips and Tricks
recurringPrices may contain entries for multiple currencies and countries. Filter to the current locale's currency + country before passing them to the selector.formatInterval(policy.schedule) helper to convert { intervalUnit: 'Months', value: 2 } into "Every 2 months". Handle both singular and plural variants of intervalUnit (e.g. 'month' and 'months') — commercetools data uses both forms inconsistently.Map<id, label> for inline display in cart items and mini cart, and one returning the full RecurrencePolicy[] for the selector. Both must share the same client state-manager/cache key so only one HTTP request is made.Find the stack'sconcept-mapping.mdfor concrete implementation.
Performance
This reference covers parallel data fetching, server-side TTL caching for stable data, hydrating the client state-manager/cache from server-fetched data, image optimization, and the anti-patterns that crater TTFB. The decisions here are framework-agnostic; the Next.js mechanics are linked at each point.
Table of Contents
- Pattern 1: Parallel Fetching on the Server
- Pattern 2: TTL Cache for Stable commercetools Data
- Pattern 3: Hydrate the client state-manager/cache from the Server
- Pattern 4: Image Optimization → see image-config.md
- Pattern 5: N+1 Anti-Patterns to Avoid
- Checklist
Pattern 1: Parallel Fetching on the Server
// WRONG — sequential waterfall
const session = await getSession(); // 50 ms
const locale = await getLocale(); // 50 ms
const categories = await getCategoryTree(locale); // 200 ms
// Total: 300 ms
Promise.all for all independent fetches:// CORRECT — parallel, total ≈ longest individual fetch
const [session, locale, messages, validCountryConfig] = await Promise.all([
getSession(),
getLocale(),
getMessages(),
getValidCountryConfig(), // cached with a server-side TTL cache
]);
// Total: ~50 ms (session/locale win, messages/validation cached)
// server-rendered category page
const [category, categoryTree] = await Promise.all([
getCategoryBySlug(slug, locale),
getCategoryTree(locale),
]);
if (!category) return; // return the framework's not-found response here
// Then build the breadcrumb by walking the in-memory tree — zero extra commercetools calls
const flat = categoryTree.flat();
let current = category;
while (current.parent) {
const parent = flat.find((c) => c.id === current.parent?.id);
if (parent) { breadcrumb.unshift({ name: parent.name, slug: parent.slug }); current = parent; }
else break;
}
Rule: If two fetches don't depend on each other's output, they must run inPromise.all. The most common violation is awaitinggetSession()before callinggetLocale()when neither needs the other.
Pattern 2: TTL Cache for Stable commercetools Data
COUNTRY_CONFIG against the project's apiRoot.get() countries/currencies/languages once and reuse the result:// <server>/ct/locale-validation — the fetch + filter is portable
async function fetchValidCountryConfig() {
const res = await apiRoot.get().execute();
const { countries = [], currencies = [], languages = [] } = res.body;
return Object.fromEntries(
Object.entries(COUNTRY_CONFIG).filter(([country, config]) =>
countries.includes(country) &&
currencies.includes(config.currency) &&
languages.some((l: string) => l.toLowerCase() === config.locale.toLowerCase())
)
);
}
// Wrap fetchValidCountryConfig in the framework's TTL cache (≈300s) and export the cached version.
| Data | Cache TTL | Reason |
|---|---|---|
| commercetools project config (countries, currencies) | 300 s | Changes only on project reconfiguration |
| Category tree | 60 s | Rarely edited; high reuse across pages |
| Shipping methods | 60 s | Rarely edited; no per-user variation |
| Product prices | Do not cache | Can change on promotion rules; per-currency |
| Cart data | Do not cache | Per-session, changes frequently |
Never cache per-user or per-session data in a shared server-side cache — it is shared across all requests. Use a client-side cache (the client-state hook) or a direct per-request<server>/ct/*call for user-specific data.
Find thedata-loading.mdfile of the adapter's to see framework's server-side cache-with-TTL patterns.
Pattern 3: Hydrate the client state-manager/cache from the Server
- Pre-fetch the cart server-side only if
session.cartIdexists; if it is stale or non-Active, leave it null and let the client clear it. - Build the initial user object from session fields — no extra commercetools call needed.
customerId, customerEmail, customerFirstName, customerLastName. For the account avatar and navigation this is sufficient; a full commercetools customer fetch is only needed on the account profile page where the user updates fields.Pattern 4: Image Optimization
- Never use a raw
<img>— use the framework's image primitive so below-fold images lazy-load automatically. - One LCP image per page gets priority — the PDP main carousel image or hero banner only. Product card images on listing pages must not.
- Keep any framework image-optimizer disabled — the commercetools CDN rejects optimizer query params; the transform functions handle sizing.
See adapter'sbest-practices/image.mdfile.
Pattern 5: N+1 Anti-Patterns to Avoid
Category breadcrumb — N+1 parent lookups
// WRONG — O(depth) commercetools calls
let current = category;
while (current.parent) {
current = await getCategoryById(current.parent.id, locale); // commercetools call per level
breadcrumb.unshift(current);
}
// CORRECT — 1 commercetools call for the whole tree, O(n) in-memory walk
const [category, categoryTree] = await Promise.all([
getCategoryBySlug(slug, locale),
getCategoryTree(locale), // fetches all categories (limit: 500)
]);
const flat = categoryTree.flat();
let current = category;
while (current.parent) {
const parent = flat.find((c) => c.id === current.parent?.id);
if (parent) { breadcrumb.unshift({ name: parent.name, slug: parent.slug }); current = parent; }
else break;
}
Product card prices — N+1 price fetches
// WRONG — 1 extra commercetools call per product
for (const product of products) {
product.price = await getVariantPrice(product.id, currency, country);
}
priceCurrency + priceCountry in the search query body:// CORRECT — commercetools resolves prices in the same search response
const body: ProductSearchRequest = {
productProjectionParameters: {
priceCurrency: currency,
priceCountry: country,
},
// ...
};
// Variants arrive with .price already set — no extra fetch
Account page — serial user + orders fetch
// WRONG — sequential
const customer = await getCustomerById(session.customerId);
const orders = await getCustomerOrders(session.customerId);
customerId from session:// CORRECT
const [customer, ordersResult] = await Promise.all([
getCustomerById(session.customerId),
getCustomerOrders(session.customerId, 5), // last 5 for the dashboard
]);
Checklist
- All independent server-side fetches use
Promise.all - Stable commercetools data (category tree, shipping methods, project config) wrapped in a server-side TTL cache
- The TTL cache is never used for per-user or per-session data
- The client state-manager/cache (cart, account) is pre-populated from server-fetched data — no spinner flash on first paint
-
initialUseris built from session fields — no extragetCustomerByIdcall at the root - One LCP image per page uses the priority hint; product card images do not — see image-config.md
- Category breadcrumb walks the in-memory tree — not individual
getCategoryByIdcalls - Product search passes
priceCurrency/priceCountry— no post-query price fetches
Product Detail Page (PDP)
Shared patterns for PDP across B2C and B2B storefronts. Individual storefront references extend this. The patterns are framework-agnostic.
Route Structure
Pick one identifier and use it consistently:
- SKU-based: route keyed by
[sku](e.g./p/[sku]) - Product ID-based: route keyed by
[productId]
getProductBy* helper must match the chosen identifier.PDP Page (server-rendered)
Promise.all — never waterfall serial fetches:const [product, attributeLabels, ...rest] = await Promise.all([
getProductBySku(sku, ...).catch(() => null),
getAttributeLabels(locale).catch(() => ({})),
// any other data fetching
...
]);
// when product is null, return the framework's not-found response
concept-mapping.md.Variant URL Strategy
[sku] URL segment — the server-rendered page re-runs automatically. No client-side fetch needed.- Product lookup always uses
skuorproductId, neverslug slugin the URL is the category slug — for breadcrumb only
Components
- Image gallery — images from the active variant
- Variant selector — lists all SKUs; clicking one updates the URL (the server-rendered page re-runs)
- Availability indicator — per-variant in/out-of-stock
- Price display — correct price; crossed-out original when discounted; handles recurring prices (see commercetools-knowledge MCP → Recurrence Policies)
- Add to Cart button — disabled when variant is out of stock or has no price
- Breadcrumb — uses the framework's locale-aware link — no bare
<a>
Metadata
// e.g. title: product.metaTitle ?? product.name; description: product.metaDescription ?? product.description
const product = await getProductBySku(sku, ...).catch(() => null);
if (!product) return {};
generateMetadata — see the adapter's metadata reference.)Attribute Labels
getAttributeLabels(bcp47) loads localised attribute display names from commercetools product types. Fetch it in parallel with the product and pass to any component rendering product attributes — never hardcode attribute names in the UI.Checklist
- Route uses consistent identifier (SKU or product ID — not both)
- SEO metadata returns title + description, derived from the product with matching context
- The not-found response is returned when the product doesn't resolve
-
Promise.allfor all independent fetches — no waterfalls - Breadcrumb uses the framework's locale-aware link — no bare
<a> - Variant selector pushes new URL — no client-side state
- Discount price shown with original crossed out
- Out-of-stock variants disable Add to Cart
-
getAttributeLabels(bcp47)fetched in parallel with the product
Search facets
Full-stack faceted search for a commercetools storefront — deriving facets from product-type attribute definitions, mapping commercetools attribute types to the correct ProductSearchFacetExpression shape, returning facet results from the search function, building a client-side filter panel that reads/writes URL params, and translating those selections back into a commercetools postFilter. Use this skill whenever you are adding facets to a commercetools Product Search request, building filter UI data, implementing getSearchableAttributes, wiring facet selections to the URL, or deciding whether an attribute should produce a distinct vs ranges facet expression.
What this skill covers
postFilter on the next search call.Source of truth: product type attribute definitions
isSearchable
flag on each AttributeDefinition. Read from this — facets then stay in sync with the catalog
schema without any manual configuration.Fetch all product types and flatten their attribute arrays. Deduplicate by attribute name (first occurrence wins) because multiple product types often share attribute names, and the field path in the search index is the same regardless of which type defined it.
AttributeDefinition[] directly from the SDK — it already contains everything needed
to derive both the facet expression shape and the postFilter field paths, so no parallel
intermediate type is needed.unstable_cache from next/cache).
Product type schemas change rarely; the savings on every search call are meaningful.Mapping attribute types to facet expressions
AttributeType.name values map almost directly to SearchFieldType. The only mechanical
transformation: for set types, prefix the inner type name with set_ (e.g. set_enum).reference and nested — they are not meaningfully facetable.number, money, date, datetime, time (and set_* variants){ from: 0 } is a valid starting point — it surfaces counts and can be
replaced with business-defined buckets later.boolean, enum, lenum, text, ltext (and set_* variants)The lenum and enum subfield constraint
lenum and enum attributes store their value as { key, label }. commercetools only allows querying by subfields —
point the facet field at variants.attributes.<name>.key and use enum for both as the effective
fieldType (not lenum), because the key is a plain enum key. Same rule for set_lenum and set_enum → field
at .key, fieldType: set_enum.This is the only type that requires a path suffix.
boolean, text, ltext) carries its own type name as fieldType — the enum
special-case does not generalise. Hardcoding 'enum' for all distinct types will cause commercetools to reject or
misinterpret fields whose actual type differs.Always-present facets
Always include these two regardless of attribute configuration, first in the array:
- Stock —
variants.availability.isOnStock,fieldType: boolean,distinct - Price —
variants.prices.centAmount,fieldType: number,ranges
Language on every facet expression
language on every distinct facet — both in the facet
expressions sent to commercetools and in every exact expression in the postFilter. commercetools needs it to
resolve localized bucket labels for ltext and localized enum labels. Passing it uniformly
to all fields (including non-localized ones like enum and boolean) is safe — commercetools ignores
it where it doesn't apply — and avoids per-type branching.ranges facets and range filter expressions do not carry a language field since numeric and
temporal values are locale-independent.Returning facet results from the search function
searchProducts should return both body.facets (the response facet - data ask commercetools-developer-tips about ProductSearchFacetResult) and searchRequest
(the full request object that was sent). The client needs the request to match each response
facet by name and determine whether it is a distinct or ranges type — that information
lives in the request expressions, not in the response.name field is the stable link between request and response. Use the attribute name
directly for attribute facets, and short descriptive names (isOnStock, price) for the
hardcoded fields.Building the postFilter from URL selections
field path, fieldType, and kind (distinct | ranges). This is built at the same
time as the expressions so no second pass over attributes is needed.Record<string, string> from f_* URL
params), translate them into a commercetools _SearchQuery for postFilter:- Distinct, single value —
exactexpression withfield,fieldType,language,value - Distinct, multiple values — wrap multiple
exactexpressions inor - Boolean — parse
"true"/"false"string to a boolean before putting it inexact - Ranges — parse the bucket key (format:
<from>-<to>,*for open-ended) into numericgte/ltebounds; useSearchNumberRangeExpressionfornumber/money,SearchLongRangeExpressionotherwise - Multiple active facets — combine all per-facet clauses with
and
postFilter (not query) so that facet counts reflect the full catalog while results are
filtered — this is the standard UX expectation for a filter sidebar.URL convention and client-side state
f_<name> URL param. This keeps selections shareable,
bookmarkable, and readable by the server page for the next render.- Distinct multi-select:
f_color=red,blue - Range:
f_price=1000-5000(matching the commercetools bucket key format) - Boolean:
f_isOnStock=true
offset to 0 — otherwise users land mid-paginated results.Client-side filter panel
facets and searchRequest as
serializable props from the server-rendered page. It:- Reads
f_*URL params from the framework's query-param API (Next.js:useSearchParams) to reconstruct current selections - Builds a name→expression lookup from
searchRequest.facetsto know each facet's kind - Iterates the response facets, skipping any with no non-zero buckets (nothing to show)
- Dispatches to a
DistinctFacetorRangeFacetcomponent based on the request expression kind - Shows an
ActiveFiltersstrip at the top when any selections are active - Updates the URL via the framework's client navigation on every selection change, preserving unrelated params
If the framework requires a boundary around client query-param access during server rendering, wrap the panel accordingly on the server page.
Find the adapter'sconcept-mapping.mdfor concrete route boundary implementation.
Distinct vs Range rendering
Checklist
- Never use any, unknown for type checking specially for facets. Always use types provided by @commercetools/platform-sdk
- Skip
referenceandnestedattribute types - Mapped
setattribute type toset_*inner type name
Shopping Lists
ShoppingList resource. The critical difference is the API chain used to access them: project-level vs. as-associate. Using the wrong chain is a security and scoping bug, not just a style issue.addLineItem, removeLineItem — so most patterns are identical once you pick the right API chain.Table of Contents
- Pattern 1: The Two API Chains
- Pattern 2: commercetools Helper Functions
- Pattern 3: Server Endpoints
- Pattern 4: Client State Hook
- Pattern 5: Mapper
- Tips and Tricks
Pattern 1: The Two API Chains
| Context | commercetools chain | Ownership enforced by |
|---|---|---|
| B2C personal wishlist | apiRoot.shoppingLists() (project-level) | App code — verify list.customer.id === customerId after a by-ID fetch |
| B2B purchase list | apiRoot.asAssociate()...shoppingLists() (as-associate) | commercetools itself — non-members receive a 403 |
Never mix the chains. A wishlist fetched through the as-associate chain would inherit BU scoping it should not have. A purchase list fetched through the project-level chain would bypass associate-permission enforcement entirely.
Pattern 2: commercetools Helper Functions
<server>/ct/shopping-lists (or <server>/ct/wishlists / <server>/ct/purchase-lists) should export:- List all for the current owner (customer or BU) — paginated, sorted by
lastModifiedAt desc - Get by ID — includes an ownership check before returning
- Create — accepts a
nameand any context-specific fields (see B2B/B2C extensions) - Rename — single
changeNameupdate action - Add item —
addLineItemwithproductId,variantId, andquantity - Remove item —
removeLineItembylineItemId - Delete — by ID and version
version. Fetch it fresh before sending the update if the caller does not already hold it — stale versions cause 409 conflicts.ShoppingList responses must be mapped to an app type (Wishlist or PurchaseList) before leaving <server>/ct/. Components must never import from @commercetools/platform-sdk.Pattern 3: Server Endpoints
Shopping list server endpoints follow the standard BFF shape: validate session → call commercetools helper → return mapped result. The session fields required differ by context:
| Context | Required session fields |
|---|---|
| B2C wishlist | customerId |
| B2B purchase list | customerId + businessUnitKey |
Route structure is symmetric in both cases:
| Method | Path | Intent |
|---|---|---|
GET | /<api>/[resource] | List all for current owner |
POST | /<api>/[resource] | Create a new list |
GET | /<api>/[resource]/[id] | Fetch a single list by ID |
PUT | /<api>/[resource]/[id] | Rename the list |
DELETE | /<api>/[resource]/[id] | Delete the list |
POST | /<api>/[resource]/[id]/items | Add an item |
DELETE | /<api>/[resource]/[id]/items | Remove an item |
Pattern 4: Client State Hook
Shopping lists change only on explicit user action (create, rename, add item, remove item, delete), so they are a good fit for a client-state hook that does not revalidate on focus.
The client state-manager/cache key must encode the ownership scope:
| Context | Cache key tuple |
|---|---|
| B2C wishlist | [KEY, customerId] |
| B2B purchase list | [KEY, businessUnitKey] |
After any mutation (create, rename, add item, remove item, delete), revalidate the same key tuple as above so the list refreshes without a full page reload. Do not optimistically update the cache for list creation; let the re-fetch confirm the new ID and version.
Find the stack'sconcept-mapping.mdfor concrete state and cache implementation.
Pattern 5: Mapper
ShoppingList to the app type. At minimum it should resolve:id,versionname— resolved fromLocalizedStringviagetLocalizedString(list.name, locale)lineItems— mapped to an array of{ lineItemId, productId, variantId, quantity, name, image, price }whereimageandpricecome from the expanded variant
expand: ['lineItems[*].variant'] on list fetches so the mapper has the variant data it needs.Tips and Tricks
list.customer?.id matches the session customerId in app code. Return a generic 404 — not a 403 — to avoid confirming that the ID exists for a different customer.expand on list fetches: Without expand: ['lineItems[*].variant'] the line items contain only IDs. Always include this expansion so image, price, and display name are available without a second round-trip.LocalizedString even for single-locale projects. This avoids a data migration later and is what commercetools expects on the create draft.Error Handling
Critical Gotcha: Never Wrap Navigation APIs in try-catch
redirect(), notFound(), forbidden(), and unauthorized() throw special internal errors. A catch block will swallow them and silently break navigation.'use server'
import { redirect, notFound } from 'next/navigation'
// Bad: catch block eats the redirect — navigation never happens
async function submitOrder(formData: FormData) {
try {
const order = await placeOrder(formData)
redirect(`/order-confirmation/${order.id}`) // throws internally
} catch (error) {
return { error: 'Failed' } // redirect is silently caught here
}
}
// Good: call redirect outside the try block
async function submitOrder(formData: FormData) {
let order
try {
order = await placeOrder(formData)
} catch (error) {
return { error: 'Failed to place order' }
}
redirect(`/order-confirmation/${order.id}`)
}
// Good alternative: re-throw with unstable_rethrow
import { unstable_rethrow } from 'next/navigation'
async function submitOrder(formData: FormData) {
try {
const order = await placeOrder(formData)
redirect(`/order-confirmation/${order.id}`)
} catch (error) {
unstable_rethrow(error) // re-throws redirect/notFound/forbidden/unauthorized
return { error: 'Failed to place order' }
}
}
Applies to all five navigation functions:
redirect()— 307 temporary redirectpermanentRedirect()— 308 permanent redirectnotFound()— rendersnot-found.tsxforbidden()— rendersforbidden.tsxunauthorized()— rendersunauthorized.tsx
error.tsx — Route Segment Error Boundary
Catches errors thrown in a route segment and all its children. Must be a Client Component.
// app/[locale]/shop/error.tsx
'use client'
export default function Error({
error,
reset,
}: {
error: Error & { digest?: string }
reset: () => void
}) {
return (
<div>
<h2>Something went wrong</h2>
<button onClick={() => reset()}>Try again</button>
</div>
)
}
reset() re-renders the segment without a full page reload.global-error.tsx — Root Layout Error Boundary
<html> and <body> tags because the layout is unavailable.// app/global-error.tsx
'use client'
export default function GlobalError({
error,
reset,
}: {
error: Error & { digest?: string }
reset: () => void
}) {
return (
<html>
<body>
<h2>Something went wrong</h2>
<button onClick={() => reset()}>Try again</button>
</body>
</html>
)
}
not-found.tsx — 404 Pages
Triggering Not Found
notFound() when a resource doesn't exist:// app/[locale]/products/[slug]/page.tsx
import { notFound } from 'next/navigation'
export default async function ProductPage({
params,
}: {
params: Promise<{ slug: string; locale: string }>
}) {
const { slug, locale } = await params
const product = await getProduct(slug, locale)
if (!product) {
notFound() // renders nearest not-found.tsx
}
return <ProductDetail product={product} />
}
Scoped not-found.tsx
// app/[locale]/products/not-found.tsx
export default function ProductNotFound() {
return (
<div>
<h2>Product not found</h2>
<p>The product you're looking for doesn't exist or has been removed.</p>
</div>
)
}
not-found.tsx next to the route segment it covers. Errors bubble up to the nearest ancestor that has one.Auth Error Pages
Trigger and render auth-specific error pages:
// Server Component or Server Action
import { forbidden, unauthorized } from 'next/navigation'
async function Page() {
const session = await getSession()
if (!session) {
unauthorized() // renders unauthorized.tsx (401)
}
if (!session.hasAdminAccess) {
forbidden() // renders forbidden.tsx (403)
}
return <AdminDashboard />
}
// app/[locale]/forbidden.tsx
export default function Forbidden() {
return <div>You don't have access to this resource.</div>
}
// app/[locale]/unauthorized.tsx
export default function Unauthorized() {
return <div>Please log in to continue.</div>
}
Error Hierarchy
Errors bubble up to the nearest boundary:
app/
├── global-error.tsx # Catches errors in root layout.tsx
├── [locale]/
│ ├── error.tsx # Catches errors across all locale routes
│ ├── not-found.tsx # 404 for all locale routes
│ ├── forbidden.tsx # 403 for all locale routes
│ ├── unauthorized.tsx # 401 for all locale routes
│ └── products/
│ ├── error.tsx # Catches errors in /products/* only
│ ├── not-found.tsx # 404 for /products/* only
│ └── [slug]/
│ └── page.tsx
error.tsx does not catch errors thrown in the same segment's layout.tsx — those bubble up to the parent's error boundary.Redirects
import { redirect, permanentRedirect } from 'next/navigation'
// 307 Temporary — use for most cases (login required, form success)
redirect('/login')
// 308 Permanent — use for URL migrations (browsers and search engines cache this)
permanentRedirect('/new-product-url')
router.push() there.Image Optimization
Project Rule: unoptimized: true Is Intentional
site/next.config.ts sets images.unoptimized: true. Do not remove it.403/400 when Next.js appends ?url=...&w=...&q=... optimization params. Sizing is handled explicitly in site/lib/ct/image-config.ts transform functions.// site/next.config.ts — do not change
const nextConfig: NextConfig = {
images: { unoptimized: true },
};
Product Images: Always Go Through image-config.ts
image-config.ts:import { transformListingImageUrl } from '@/lib/ct/image-config'
import Image from 'next/image'
// Listing / search result card
<Image
src={transformListingImageUrl(item.imageUrl)}
alt={item.name}
width={400}
height={500}
/>
// PDP main carousel image
<Image
src={transformDetailImageUrl(product.imageUrl)}
alt={product.name}
fill
sizes="(max-width: 768px) 100vw, 50vw"
priority
/>
// PDP thumbnail strip
<Image
src={transformThumbnailImageUrl(product.imageUrl)}
alt={product.name}
width={80}
height={100}
/>
image-config.ts updates all instances at once.Always Use next/image — Never <img>
unoptimized: true, next/image still prevents layout shift, lazy-loads below-fold images, and enforces explicit dimensions.// Bad
<img src={url} alt="Product" />
// Good
import Image from 'next/image'
<Image src={url} alt="Product" width={400} height={500} />
Non-Product Images (CMS banners, avatars, icons)
remotePatterns in next.config.ts:// site/next.config.ts
images: {
unoptimized: true,
remotePatterns: [
{
protocol: 'https',
hostname: 'assets.example.com',
pathname: '/media/**',
},
],
},
fill + sizes
fill with sizes or Next.js downloads the largest variant:// Bad: missing sizes — downloads the biggest image
<Image src={url} alt="Banner" fill />
// Good
<Image src={url} alt="Banner" fill sizes="100vw" />
// Good: responsive grid
<Image src={url} alt="Card" fill sizes="(max-width: 768px) 100vw, 33vw" />
position: relative and an explicit height.Priority for LCP Images
priority to the first visible image (hero, PDP carousel):// Hero banner — renders immediately, no lazy-load
<Image src={url} alt="Hero" fill sizes="100vw" priority />
// Product cards below the fold — omit priority (lazy-loaded by default)
<Image src={url} alt="Card" width={400} height={500} />
Blur Placeholder (non-product images)
// Local static image — blur hash inferred automatically
import heroImage from './hero.png'
<Image src={heroImage} alt="Hero" placeholder="blur" />
// Remote image — provide blurDataURL or use a background color
<Image
src="https://assets.example.com/banner.jpg"
alt="Banner"
width={1200}
height={400}
placeholder="blur"
blurDataURL="data:image/jpeg;base64,/9j/4AAQSkZJRg..."
/>
Common Mistakes
// Bad: native img — no lazy-load, no dimension enforcement
<img src={url} alt="Product" />
// Bad: fill without sizes — downloads largest image
<Image src={url} alt="Hero" fill />
// Bad: inline URL transform — bypasses image-config.ts
<Image src={`${url}?w=400`} alt="Product" width={400} height={500} />
// Bad: wrong dimensions (aspect ratio only, not display size)
<Image src={url} alt="Hero" width={16} height={9} />
// Good: actual display size or fill + sizes
<Image src={url} alt="Hero" fill sizes="100vw" style={{ objectFit: 'cover' }} />
Metadata
Rule: Metadata Lives in Server Components Only
metadata and generateMetadata cannot be used in Client Components. If a page has 'use client', either remove it (move client logic to child components and files) or extract metadata to a parent layout.Static Metadata
// app/[locale]/my-page/page.tsx
import type { Metadata } from 'next'
export const metadata: Metadata = {
title: 'My Page', // root layout template appends '| Home'
description: 'Page description for SEO',
}
title values are automatically formatted as My Page | Home.Dynamic Metadata
generateMetadata when the title or description depends on fetched data (PDP, category pages, blog posts):// app/[locale]/products/[slug]/page.tsx
import type { Metadata } from 'next'
interface Props {
params: Promise<{ slug: string; locale: string }>
}
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { slug, locale } = await params
const product = await getProduct(slug, locale).catch(() => null)
if (!product) return { title: 'Not Found' }
return {
title: product.name,
description: product.description,
}
}
params is a Promise in Next.js 15+ — always await it.Avoid Duplicate Fetches
generateMetadata and the page component often need the same data. Wrap the fetch in React cache() so it runs only once per request:// lib/ct/products.ts
import { cache } from 'react'
export const getProduct = cache(async (slug: string, locale: string) => {
return await apiRoot.products().withSlug(slug).get({ /* ... */ }).execute()
})
// app/[locale]/products/[slug]/page.tsx
import { getProduct } from '@/lib/ct/products'
import { notFound } from 'next/navigation'
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { slug, locale } = await params
const product = await getProduct(slug, locale)
if (!product) return { title: 'Not Found' }
return { title: product.name, description: product.description }
}
export default async function ProductPage({ params }: Props) {
const { slug, locale } = await params
const product = await getProduct(slug, locale) // same call — deduplicated by cache()
if (!product) notFound()
return <ProductDetail product={product} />
}
OG Images
opengraph-image.png (static) or opengraph-image.tsx (dynamic) in the route segment:// app/[locale]/products/[slug]/opengraph-image.tsx
import { ImageResponse } from 'next/og'
export const alt = 'Product'
export const size = { width: 1200, height: 630 }
export const contentType = 'image/png'
type Props = { params: Promise<{ slug: string; locale: string }> }
export default async function Image({ params }: Props) {
const { slug, locale } = await params
const product = await getProduct(slug, locale)
return new ImageResponse(
(
<div
style={{
background: '#fff',
width: '100%',
height: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: 64,
fontWeight: 'bold',
}}
>
{product?.name ?? 'Product'}
</div>
),
{ ...size }
)
}
- Use
next/og, not@vercel/og - No
searchParamsaccess — use routeparamsonly - Do not set
export const runtime = 'edge'— default Node.js runtime works fine ImageResponseuses Flexbox; CSS Grid is not supported; all styles must be inline objects
opengraph-image.png at the root covers both Open Graph and Twitter (Twitter falls back to OG).Metadata File Conventions
app/ (or any route segment) are picked up automatically — no code needed:| File | Purpose |
|---|---|
favicon.ico | Browser tab icon |
opengraph-image.png / .tsx | OG + Twitter card image |
sitemap.ts | Sitemap (use generateSitemaps for large catalogs) |
robots.ts | Crawl directives |
Server Component Boundaries
onChange, onClick, onSubmit) is illegal on a Server Component's JSX. This applies to all interactive elements including — but not limited to — <select>, <input>, and <button>.onChange handler directly on a <select> inside a Server Component.'use client' component and pass only plain data props (strings, arrays, objects) to it from the Server Component.// WRONG — Server Component
export default async function LocaleSwitcher() {
const locales = await getLocales();
return <select onChange={(e) => switchLocale(e.target.value)}>...</select>;
}
// CORRECT — split into two files
// LocaleSwitcherClient.tsx
'use client';
export default function LocaleSwitcherClient({ locales }: { locales: string[] }) {
return <select onChange={(e) => switchLocale(e.target.value)}>...</select>;
}
// LocaleSwitcher.tsx (Server Component)
import LocaleSwitcherClient from './LocaleSwitcherClient';
export default async function LocaleSwitcher() {
const locales = await getLocales();
return <LocaleSwitcherClient locales={locales} />;
}
The boundary rule: a Server Component can render a Client Component, but cannot pass functions as props across that boundary. Pass data down; let the Client Component own all event handling.
Error Handling
Critical Gotcha: Never Wrap Navigation APIs in try-catch
redirect(), notFound(), forbidden(), and unauthorized() throw special internal errors. A catch block will swallow them and silently break navigation.'use server'
import { redirect, notFound } from 'next/navigation'
// Bad: catch block eats the redirect — navigation never happens
async function submitOrder(formData: FormData) {
try {
const order = await placeOrder(formData)
redirect(`/order-confirmation/${order.id}`) // throws internally
} catch (error) {
return { error: 'Failed' } // redirect is silently caught here
}
}
// Good: call redirect outside the try block
async function submitOrder(formData: FormData) {
let order
try {
order = await placeOrder(formData)
} catch (error) {
return { error: 'Failed to place order' }
}
redirect(`/order-confirmation/${order.id}`)
}
// Good alternative: re-throw with unstable_rethrow
import { unstable_rethrow } from 'next/navigation'
async function submitOrder(formData: FormData) {
try {
const order = await placeOrder(formData)
redirect(`/order-confirmation/${order.id}`)
} catch (error) {
unstable_rethrow(error) // re-throws redirect/notFound/forbidden/unauthorized
return { error: 'Failed to place order' }
}
}
Applies to all five navigation functions:
redirect()— 307 temporary redirectpermanentRedirect()— 308 permanent redirectnotFound()— rendersnot-found.tsxforbidden()— rendersforbidden.tsxunauthorized()— rendersunauthorized.tsx
error.tsx — Route Segment Error Boundary
Catches errors thrown in a route segment and all its children. Must be a Client Component.
// app/[locale]/shop/error.tsx
'use client'
export default function Error({
error,
reset,
}: {
error: Error & { digest?: string }
reset: () => void
}) {
return (
<div>
<h2>Something went wrong</h2>
<button onClick={() => reset()}>Try again</button>
</div>
)
}
reset() re-renders the segment without a full page reload.global-error.tsx — Root Layout Error Boundary
<html> and <body> tags because the layout is unavailable.// app/global-error.tsx
'use client'
export default function GlobalError({
error,
reset,
}: {
error: Error & { digest?: string }
reset: () => void
}) {
return (
<html>
<body>
<h2>Something went wrong</h2>
<button onClick={() => reset()}>Try again</button>
</body>
</html>
)
}
not-found.tsx — 404 Pages
Triggering Not Found
notFound() when a resource doesn't exist:// app/[locale]/products/[slug]/page.tsx
import { notFound } from 'next/navigation'
export default async function ProductPage({
params,
}: {
params: Promise<{ slug: string; locale: string }>
}) {
const { slug, locale } = await params
const product = await getProduct(slug, locale)
if (!product) {
notFound() // renders nearest not-found.tsx
}
return <ProductDetail product={product} />
}
Scoped not-found.tsx
// app/[locale]/products/not-found.tsx
export default function ProductNotFound() {
return (
<div>
<h2>Product not found</h2>
<p>The product you're looking for doesn't exist or has been removed.</p>
</div>
)
}
not-found.tsx next to the route segment it covers. Errors bubble up to the nearest ancestor that has one.Auth Error Pages
Trigger and render auth-specific error pages:
// Server Component or Server Action
import { forbidden, unauthorized } from 'next/navigation'
async function Page() {
const session = await getSession()
if (!session) {
unauthorized() // renders unauthorized.tsx (401)
}
if (!session.hasAdminAccess) {
forbidden() // renders forbidden.tsx (403)
}
return <AdminDashboard />
}
// app/[locale]/forbidden.tsx
export default function Forbidden() {
return <div>You don't have access to this resource.</div>
}
// app/[locale]/unauthorized.tsx
export default function Unauthorized() {
return <div>Please log in to continue.</div>
}
Error Hierarchy
Errors bubble up to the nearest boundary:
app/
├── global-error.tsx # Catches errors in root layout.tsx
├── [locale]/
│ ├── error.tsx # Catches errors across all locale routes
│ ├── not-found.tsx # 404 for all locale routes
│ ├── forbidden.tsx # 403 for all locale routes
│ ├── unauthorized.tsx # 401 for all locale routes
│ └── products/
│ ├── error.tsx # Catches errors in /products/* only
│ ├── not-found.tsx # 404 for /products/* only
│ └── [slug]/
│ └── page.tsx
error.tsx does not catch errors thrown in the same segment's layout.tsx — those bubble up to the parent's error boundary.Redirects
import { redirect, permanentRedirect } from 'next/navigation'
// 307 Temporary — use for most cases (login required, form success)
redirect('/login')
// 308 Permanent — use for URL migrations (browsers and search engines cache this)
permanentRedirect('/new-product-url')
router.push() there.Image Optimization
Project Rule: unoptimized: true Is Intentional
<root-dir>/next.config.ts sets images.unoptimized: true. Do not remove it.403/400 when Next.js appends ?url=...&w=...&q=... optimization params. Sizing is handled explicitly in <root-dir>/lib/ct/image-config.ts transform functions.// <root-dir>/next.config.ts — do not change
const nextConfig: NextConfig = {
images: { unoptimized: true },
};
Product Images: Always Go Through image-config.ts
next/image).image-config.ts:import { transformListingImageUrl } from '@/lib/ct/image-config'
import Image from 'next/image'
// Listing / search result card
<Image
src={transformListingImageUrl(item.imageUrl)}
alt={item.name}
width={400}
height={500}
/>
// PDP main carousel image
<Image
src={transformDetailImageUrl(product.imageUrl)}
alt={product.name}
fill
sizes="(max-width: 768px) 100vw, 50vw"
priority
/>
// PDP thumbnail strip
<Image
src={transformThumbnailImageUrl(product.imageUrl)}
alt={product.name}
width={80}
height={100}
/>
image-config.ts updates all instances at once.Always Use next/image — Never <img>
unoptimized: true, next/image still prevents layout shift, lazy-loads below-fold images, and enforces explicit dimensions.// Bad
<img src={url} alt="Product" />
// Good
import Image from 'next/image'
<Image src={url} alt="Product" width={400} height={500} />
Non-Product Images (CMS banners, avatars, icons)
remotePatterns in next.config.ts:// <root-dir>/next.config.ts
images: {
unoptimized: true,
remotePatterns: [
{
protocol: 'https',
hostname: 'assets.example.com',
pathname: '/media/**',
},
],
},
fill + sizes
fill with sizes or Next.js downloads the largest variant:// Bad: missing sizes — downloads the biggest image
<Image src={url} alt="Banner" fill />
// Good
<Image src={url} alt="Banner" fill sizes="100vw" />
// Good: responsive grid
<Image src={url} alt="Card" fill sizes="(max-width: 768px) 100vw, 33vw" />
position: relative and an explicit height.Priority for LCP Images
priority to the first visible image (hero, PDP carousel):// Hero banner — renders immediately, no lazy-load
<Image src={url} alt="Hero" fill sizes="100vw" priority />
// Product cards below the fold — omit priority (lazy-loaded by default)
<Image src={url} alt="Card" width={400} height={500} />
Blur Placeholder (non-product images)
// Local static image — blur hash inferred automatically
import heroImage from './hero.png'
<Image src={heroImage} alt="Hero" placeholder="blur" />
// Remote image — provide blurDataURL or use a background color
<Image
src="https://assets.example.com/banner.jpg"
alt="Banner"
width={1200}
height={400}
placeholder="blur"
blurDataURL="data:image/jpeg;base64,/9j/4AAQSkZJRg..."
/>
Common Mistakes
// Bad: native img — no lazy-load, no dimension enforcement
<img src={url} alt="Product" />
// Bad: fill without sizes — downloads largest image
<Image src={url} alt="Hero" fill />
// Bad: inline URL transform — bypasses image-config.ts
<Image src={`${url}?w=400`} alt="Product" width={400} height={500} />
// Bad: wrong dimensions (aspect ratio only, not display size)
<Image src={url} alt="Hero" width={16} height={9} />
// Good: actual display size or fill + sizes
<Image src={url} alt="Hero" fill sizes="100vw" style={{ objectFit: 'cover' }} />
Metadata
Rule: Metadata Lives in Server Components Only
metadata and generateMetadata cannot be used in Client Components. If a page has 'use client', either remove it (move client logic to child components and files) or extract metadata to a parent layout.Static Metadata
// app/[locale]/my-page/page.tsx
import type { Metadata } from 'next'
export const metadata: Metadata = {
title: 'My Page', // root layout template appends '| Home'
description: 'Page description for SEO',
}
title values are automatically formatted as My Page | Home.Dynamic Metadata
generateMetadata when the title or description depends on fetched data (PDP, category pages, blog posts):// app/[locale]/products/[slug]/page.tsx
import type { Metadata } from 'next'
interface Props {
params: Promise<{ slug: string; locale: string }>
}
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { slug, locale } = await params
const product = await getProduct(slug, locale).catch(() => null)
if (!product) return { title: 'Not Found' }
return {
title: product.name,
description: product.description,
}
}
params is a Promise in Next.js 15+ — always await it.Avoid Duplicate Fetches
generateMetadata and the page component often need the same data. Wrap the fetch in React cache() so it runs only once per request:// lib/ct/products.ts
import { cache } from 'react'
export const getProduct = cache(async (slug: string, locale: string) => {
return await apiRoot.products().withSlug(slug).get({ /* ... */ }).execute()
})
// app/[locale]/products/[slug]/page.tsx
import { getProduct } from '@/lib/ct/products'
import { notFound } from 'next/navigation'
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { slug, locale } = await params
const product = await getProduct(slug, locale)
if (!product) return { title: 'Not Found' }
return { title: product.name, description: product.description }
}
export default async function ProductPage({ params }: Props) {
const { slug, locale } = await params
const product = await getProduct(slug, locale) // same call — deduplicated by cache()
if (!product) notFound()
return <ProductDetail product={product} />
}
OG Images
opengraph-image.png (static) or opengraph-image.tsx (dynamic) in the route segment:// app/[locale]/products/[slug]/opengraph-image.tsx
import { ImageResponse } from 'next/og'
export const alt = 'Product'
export const size = { width: 1200, height: 630 }
export const contentType = 'image/png'
type Props = { params: Promise<{ slug: string; locale: string }> }
export default async function Image({ params }: Props) {
const { slug, locale } = await params
const product = await getProduct(slug, locale)
return new ImageResponse(
(
<div
style={{
background: '#fff',
width: '100%',
height: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: 64,
fontWeight: 'bold',
}}
>
{product?.name ?? 'Product'}
</div>
),
{ ...size }
)
}
- Use
next/og, not@vercel/og - No
searchParamsaccess — use routeparamsonly - Do not set
export const runtime = 'edge'— default Node.js runtime works fine ImageResponseuses Flexbox; CSS Grid is not supported; all styles must be inline objects
opengraph-image.png at the root covers both Open Graph and Twitter (Twitter falls back to OG).Metadata File Conventions
app/ (or any route segment) are picked up automatically — no code needed:| File | Purpose |
|---|---|
favicon.ico | Browser tab icon |
opengraph-image.png / .tsx | OG + Twitter card image |
sitemap.ts | Sitemap (use generateSitemaps for large catalogs) |
robots.ts | Crawl directives |
Server Component Boundaries
onChange, onClick, onSubmit) is illegal on a Server Component's JSX. This applies to all interactive elements including — but not limited to — <select>, <input>, and <button>.onChange handler directly on a <select> inside a Server Component.'use client' component and pass only plain data props (strings, arrays, objects) to it from the Server Component.// WRONG — Server Component
export default async function LocaleSwitcher() {
const locales = await getLocales();
return <select onChange={(e) => switchLocale(e.target.value)}>...</select>;
}
// CORRECT — split into two files
// LocaleSwitcherClient.tsx
'use client';
export default function LocaleSwitcherClient({ locales }: { locales: string[] }) {
return <select onChange={(e) => switchLocale(e.target.value)}>...</select>;
}
// LocaleSwitcher.tsx (Server Component)
import LocaleSwitcherClient from './LocaleSwitcherClient';
export default async function LocaleSwitcher() {
const locales = await getLocales();
return <LocaleSwitcherClient locales={locales} />;
}
The boundary rule: a Server Component can render a Client Component, but cannot pass functions as props across that boundary. Pass data down; let the Client Component own all event handling.
Concept → Next.js Primitive Mapping
commercetools-storefront skill states every rule in framework-neutral language; this table resolves each concept to its Next.js (App Router) primitive. When a generic reference says "see your framework adapter", it means this file.Path & state conventions
The generic skill writes paths and client-side data access as stack-neutral placeholders. This stack pins them:
| Generic placeholder | Next.js (this stack) |
|---|---|
<root-dir>/ — application root directory | site/ |
<server>/ — server-side code root | lib/ |
<api>/ — client-facing API surface the browser calls | app/api/ (Route Handlers — app/api/<resource>/route.ts) |
<server>/ct/* — commercetools helpers | lib/ct/* |
<server>/ct/client — apiRoot singleton | lib/ct/client.ts |
<server>/types — app type-mapping root (boundary types) | lib/types.ts |
<server>/mappers/ — commercetools→app mappers | lib/mappers/ |
<server>/cache-keys — client-state keys | lib/cache-keys.ts |
<server>/session — session read/write module | lib/session.ts |
<server>/utils — shared utils (COUNTRY_CONFIG, money/locale) | lib/utils.ts |
| Client state — mutable per-user data layer | SWR (useSWR + mutate / SWRConfig); see Client state hooks |
| Client state hook | a SWR hook in hooks/*.ts ('use client') |
| Client state provider | a React context in context/*.tsx |
| Server-managed session | a signed JWT in an HTTP-only cookie (jose, stateless BFF); see data-loading.md |
Lookup table
| Generic concept | Next.js primitive (App Router) |
|---|---|
| Server-rendered data load | async Server Component (app/[locale]/.../page.tsx) calling lib/ct/* directly |
| Resolve route params | const { slug, locale } = await params — params is a Promise in Next 15+ |
| Server endpoint (BFF) | Route Handler app/api/<resource>/route.ts exporting GET/POST/PATCH/DELETE |
| Server endpoint directory layout | app/api/{auth,account,cart,checkout,shipping-methods,channels}/... |
| Client component / browser bundle | 'use client' file |
| Read/write the (server-managed) session | lib/session.ts using cookies() from next/headers + NextResponse.cookies.set(...); signed-JWT-in-cookie (stateless BFF) — see data-loading.md |
| Not-found response | notFound() from next/navigation → renders not-found.tsx |
| Redirect | redirect() / permanentRedirect() from next/navigation (server); useRouter().push() (client) — never wrap redirect() in try/catch |
| Route-segment error boundary | error.tsx / global-error.tsx |
| Auth-gated responses | unauthorized() → unauthorized.tsx (401); forbidden() → forbidden.tsx (403) |
| Client-side navigation | useRouter() from @/i18n/routing (router.push/router.replace) |
| Locale-aware link primitive | import { Link } from '@/i18n/routing' — never bare next/link |
| Locale routing config | i18n/routing.ts (defineRouting + createNavigation) + i18n/request.ts (getRequestConfig); next-intl@^4 — see project-layout.md |
| Locale URL prefix + redirect | proxy.ts middleware + localePrefix: 'always'; routes under app/[locale]/ |
| Server-side cache-with-TTL for stable CT data | unstable_cache(fn, [key], { revalidate }) from next/cache — never per-user/session — see data-loading.md |
| Per-request fetch dedup (metadata + page) | cache() from react wrapping the lib/ct/* fetch — see best-practices/metadata.md |
| Hydrate client state-manager/cache from server (no spinner flash) | SWRConfig fallback={{ [KEY_CART]: initialCart, [KEY_ACCOUNT]: initialUser }} in app/layout.tsx — see data-loading.md |
| Root layout / locale layout | app/layout.tsx (root) + app/[locale]/layout.tsx (providers: NextIntlClientProvider, CartProvider) |
| Page-level SEO metadata | export const metadata (static) / export async function generateMetadata (dynamic) — Server Components only — see best-practices/metadata.md |
| OG/social card image | opengraph-image.tsx via next/og ImageResponse |
| Product image rendering | next/image with unoptimized: true — see best-practices/image.md |
| Health check (verify CT credentials) | app/api/health/route.ts → apiRoot.get().execute() (delete before deploy) |
| App framework config | next.config.ts wrapped with createNextIntlPlugin('./i18n/request.ts') |
| Styling | Tailwind v4 — no config file, @tailwindcss/postcss, @import 'tailwindcss' in globals.css |
| Deploy target | vercel.json / netlify.toml (repo root); /nextjs-deploy-vercel, /nextjs-deploy-netlify |
| Scaffold a new project | /nextjs-setup-project |
| Add a locale | /nextjs-add-locale |
Portable, not remapped: the commercetools SDK calls (apiRoot.*, the as-associate chain), the mappers, andgetLocalizedString/formatMoneyare identical in both skills — only their location (<server>/→lib/) and the render/state primitives around them differ. SWR andjoseare this stack's realizations of the generic client state and server-managed session concepts.
Server Component page shape
The generic "server-rendered data load" for catalog/immutable data:
// app/[locale]/category/[slug]/page.tsx — async Server Component
export default async function CategoryPage({ params }: { params: Promise<{ slug: string; locale: string }> }) {
const { slug, locale } = await params; // params is a Promise in Next 15+
const [category, categoryTree] = await Promise.all([ // generic rule: parallel independent fetches
getCategoryBySlug(slug, locale),
getCategoryTree(locale),
]);
if (!category) notFound(); // generic "not-found response"
// ... build breadcrumb by walking categoryTree in memory (no extra ct calls)
}
- All page components are
asyncby default — add'use client'only when the page needs browser APIs. notFound(),redirect()etc. come fromnext/navigationand must be called outside anytry/catch— see best-practices/error-handling.md.
Client navigation & step routing
The generic "client-side navigation" (e.g. checkout step guards):
// app/[locale]/checkout/page.tsx — 'use client'
'use client';
import { useRouter } from '@/i18n/routing'; // locale-aware, NOT next/navigation directly
export default function CheckoutIndexPage() {
const router = useRouter();
const { data: cart } = useCartSWR();
useEffect(() => {
if (cart === undefined) return; // still loading
const hasAddr = !!(cart?.shippingAddress?.streetName && cart?.billingAddress?.streetName);
const hasMethod = !!cart?.shippingInfo;
if (hasAddr && hasMethod) router.replace('/checkout/payment');
else if (hasAddr) router.replace('/checkout/shipping');
else router.replace('/checkout/addresses');
}, [cart]);
return null;
}
core/checkout-page.md; only the useRouter/router.replace mechanism is Next-specific.Confirmation page (server-rendered, fetch by id)
// app/[locale]/checkout/confirmation/[orderId]/page.tsx — Server Component
export default async function ConfirmationPage({ params }: { params: Promise<{ locale: string; orderId: string }> }) {
const { orderId } = await params;
let order = null;
try { order = await getOrderById(orderId); } catch { /* show minimal confirmation */ }
return (/* success indicator, order number, line-item summary */);
}
core/checkout-page.md; the Server Component + async params shape is the Next mapping.Client state hooks (SWR)
// hooks/useWidgets.ts — 'use client'
'use client';
import useSWR, { useSWRConfig } from 'swr';
import { KEY_WIDGETS } from '@/lib/cache-keys';
export function useWidgets() {
return useSWR(KEY_WIDGETS, async () => {
const res = await fetch('/api/widgets');
return res.ok ? (await res.json()).widgets ?? [] : [];
}, { revalidateOnFocus: false }); // exception: the cart hook uses revalidateOnFocus: true
}
export function useWidgetMutations() {
const { mutate } = useSWRConfig();
async function createWidget(data) {
const res = await fetch('/api/widgets', { method: 'POST', body: JSON.stringify(data) });
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || 'Failed');
mutate(KEY_WIDGETS, (await res.json()).widgets, { revalidate: false }); // update from response, no refetch
}
return { createWidget };
}
- Cache keys live in
lib/cache-keys.ts(generic:<server>/cache-keys); BU-scoped state uses a[KEY, businessUnitKey]tuple. - Mutations throw; read hooks return safe defaults (
null/[]). - Update from the response body (
mutate(KEY, data, { revalidate: false })) — no extra round-trip. - Seed from the server with
SWRConfig fallback(see data-loading.md) to avoid a first-paint spinner.
Data Loading — Next.js Implementation
commercetools-storefront skill decides what loads where:- Catalog / immutable data (category pages, PDPs, search results) → server-rendered load, calling
lib/ct/*directly. - Mutable per-user state (cart, account, orders, quotes) → client-fetched via SWR → server endpoint →
lib/ct/*.
core/data-loading.md in the generic skill.Session module — lib/session.ts (signed-JWT realization)
jose, HS256, 30-day expiry, SESSION_SECRET ≥ 32 chars), read/written only server-side, exposing getSession / getLocale / createSessionToken / setSessionCookie / clearSessionCookie. (A stateful BFF would instead persist session state in a server-side store keyed by an opaque cookie — same surface, different storage.) The cookie read/write binding uses cookies() from next/headers and NextResponse.cookies:// lib/session.ts
import { SignJWT, jwtVerify } from 'jose';
import { cookies } from 'next/headers';
import { NextResponse } from 'next/server';
import { COUNTRY_CONFIG, DEFAULT_LOCALE } from '@/lib/utils';
const SECRET = new TextEncoder().encode(
process.env.SESSION_SECRET || 'dev-only-fallback-32-char-key!!'
);
const COOKIE_NAME = 'your-store-session';
export interface Session {
customerId?: string;
customerEmail?: string;
customerFirstName?: string;
customerLastName?: string;
cartId?: string;
country?: string;
currency?: string;
locale?: string;
// B2B adds: businessUnitKey, storeKey, storeId, distributionChannelId, supplyChannelId, productSelectionId
}
export async function getSession(): Promise<Session> {
const cookieStore = await cookies();
const token = cookieStore.get(COOKIE_NAME)?.value;
if (!token) return {};
try {
const { payload } = await jwtVerify(token, SECRET);
const { iat, exp, ...session } = payload as Session & { iat?: number; exp?: number };
return session;
} catch {
return {};
}
}
export async function getLocale(): Promise<{ country: string; currency: string; locale: string }> {
const session = await getSession();
if (session.country && session.currency && session.locale) {
return { country: session.country, currency: session.currency, locale: session.locale };
}
const cookieStore = await cookies();
// Cookie stores the BCP-47 locale directly (e.g. 'en-US', 'de-DE') — same as COUNTRY_CONFIG key
const locale = cookieStore.get('your-shop-country-locale')?.value || DEFAULT_LOCALE.locale;
const config = COUNTRY_CONFIG[locale] || COUNTRY_CONFIG[DEFAULT_LOCALE.locale];
return { country: config.country, currency: config.currency, locale: config.locale };
}
export async function createSessionToken(data: Session): Promise<string> {
return new SignJWT(data as Record<string, unknown>)
.setProtectedHeader({ alg: 'HS256' })
.setIssuedAt()
.setExpirationTime('30d')
.sign(SECRET);
}
export function setSessionCookie(response: NextResponse, token: string): NextResponse {
response.cookies.set(COOKIE_NAME, token, {
httpOnly: true,
sameSite: 'lax',
maxAge: 30 * 24 * 60 * 60,
path: '/',
});
return response;
}
export function clearSessionCookie(response: NextResponse): NextResponse {
response.cookies.set(COOKIE_NAME, '', { httpOnly: true, sameSite: 'lax', maxAge: 0, path: '/' });
return response;
}
BFF Route Handler shape
lib/ct/<namespace>.ts → return JSON) maps to a Next.js Route Handler:// app/api/<resource>/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { getSession } from '@/lib/session';
import { getWidgets } from '@/lib/ct/widgets';
export async function GET(_req: NextRequest) {
const session = await getSession();
if (!session.customerId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
try {
const widgets = await getWidgets(session.customerId);
return NextResponse.json({ widgets });
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : 'Failed to fetch widgets';
return NextResponse.json({ error: msg }, { status: 500 });
}
}
lib/ct/<namespace>.ts (generic rule). The data flow is: 'use client' hook → fetch('/api/...') → Route Handler → lib/ct/* → apiRoot.app/api/
auth/ login, register, logout, me
account/ orders, addresses, payments, wishlist
cart/ cart CRUD, line-items, discount
checkout/ order creation
shipping-methods/ shipping options by locale
channels/ store channels (BOPIS)
Server-side caching — unstable_cache
unstable_cache from next/cache:// lib/ct/locale-validation.ts
import { unstable_cache } from 'next/cache';
import { apiRoot } from './client';
import { COUNTRY_CONFIG } from '@/lib/utils';
async function fetchValidCountryConfig() {
const res = await apiRoot.get().execute();
const { countries = [], currencies = [], languages = [] } = res.body;
return Object.fromEntries(
Object.entries(COUNTRY_CONFIG).filter(([country, config]) =>
countries.includes(country) &&
currencies.includes(config.currency) &&
languages.some((l: string) => l.toLowerCase() === config.locale.toLowerCase())
)
);
}
export const getValidCountryConfig = unstable_cache(
fetchValidCountryConfig,
['locale-validation'],
{ revalidate: 300 }
);
| Data | Cache TTL | Reason |
|---|---|---|
| commercetools project config (countries, currencies) | 300 s | Changes only on project reconfiguration |
| Category tree | 60 s | Rarely edited; high reuse across pages |
| Shipping methods | 60 s | Rarely edited; no per-user variation |
| Product prices | Do not cache | Change on promotion rules; per-currency |
| Cart / account data | Do not cache | Per-session, changes frequently |
Preferunstable_cacheover module-level variables — module-level caches reset on cold starts and aren't shared across serverless instances. Its cache is shared across all requests, so never cache per-user or per-session data with it; use SWR (client) or a direct per-requestlib/ct/*call (server) for user-specific data.
SWR hydration from the server — SWRConfig fallback
SWRConfig's fallback in the root layout:// app/layout.tsx (Server Component)
export default async function RootLayout({ children }) {
const [session, messages, { locale }] = await Promise.all([
getSession(),
getMessages(),
getLocale(),
]);
// Pre-fetch cart if present; build user object from session fields (no extra ct call)
let initialCart = null;
if (session.cartId) {
try { initialCart = await getCart(session.cartId); } catch { /* SWR clears stale cartId */ }
}
const initialUser = session.customerId
? { id: session.customerId, email: session.customerEmail, firstName: session.customerFirstName, lastName: session.customerLastName }
: null;
return (
<html lang={locale}>
<body>
<NextIntlClientProvider messages={messages}>
{/* KEY_CART / KEY_ACCOUNT pre-filled — useCartSWR and useAccount render immediately */}
<SWRConfig value={{ fallback: { [KEY_CART]: initialCart, [KEY_ACCOUNT]: initialUser } }}>
{children}
</SWRConfig>
</NextIntlClientProvider>
</body>
</html>
);
}
customerId/customerEmail/customerFirstName/customerLastName, so initialUser needs no commercetools fetch — a full getCustomerById is only needed on the account profile page.Async params and request dedup
params(andsearchParams) arePromises in Next 15+ — alwaysawaitthem in pages,generateMetadata, andopengraph-image.tsx.- When
generateMetadataand the page component fetch the same resource, wrap thelib/ct/*fetch in Reactcache()so it runs once per request. See best-practices/metadata.md.
Connection health check
// app/api/health/route.ts ← DELETE before deploying
import { NextResponse } from 'next/server';
import { apiRoot } from '@/lib/ct/client';
export async function GET() {
try {
const { body } = await apiRoot.get().execute();
return NextResponse.json({ ok: true, projectKey: body.key });
} catch (e) {
return NextResponse.json({ ok: false, error: String(e) }, { status: 500 });
}
}
curl http://localhost:8888/api/health
# → {"ok":true,"projectKey":"your-project-key"}
Next.js Stack — commercetools Storefront
Next.js stack adapter. This is the Next.js (App Router) implementation layer for thecommercetools-storefrontskill. That skill states every commercetools fact and B2C/B2B rule as a framework-neutral decision; this stack maps each one to Next.js 16 + next-intl v4 + Tailwind v4 primitives and ships the/nextjs-*commands. Use it together with the skill'score/,b2c/, andb2b/references when the storefront's frontend is Next.js.
When you read a rule in the generic skill phrased as "server-rendered data load", "server endpoint", "the framework's locale-aware link", or "the framework's server-side cache-with-TTL primitive", come here for the concrete Next.js mapping.
Reference Index
| Task | Reference |
|---|---|
| Generic concept → Next.js primitive lookup table; routing, navigation, error, and metadata shells | concept-mapping.md |
Project layout (<root-dir>/, app/[locale]/, app/api/), next.config.ts, next-intl wiring, proxy.ts, Tailwind v4, version gates, deploy files | project-layout.md |
The Next.js side of data loading: lib/session.ts (jose), BFF Route Handler shape, unstable_cache, SWRConfig hydration, async params, health-check route | data-loading.md |
next/image config, unoptimized, remotePatterns, fill+sizes, LCP priority | best-practices/image.md |
Static & dynamic metadata, generateMetadata, OG images, React cache() dedup | best-practices/metadata.md |
| Server vs Client Component boundary, no-function-props rule | best-practices/server-components.md |
error.tsx, not-found.tsx, redirect()/notFound() gotchas, unstable_rethrow | best-practices/error-handling.md |
Commands
| Task | Command |
|---|---|
| Scaffold a new Next.js + commercetools storefront | Run /nextjs-setup-project |
| Deploy to Vercel | Run /nextjs-deploy-vercel |
| Deploy to Netlify | Run /nextjs-deploy-netlify |
Priority Tiers
CRITICAL
- Next.js version — Always use
next@^16. Never write"next": "15.x". Next.js 15.x has known security vulnerabilities. - next-intl version — Always use
next-intl@^4, compatible withnext@^16.
HIGH
- Locale-aware link —
import { Link } from '@/i18n/routing', never bareimport Link from 'next/link'. The next-intlLinkpreserves the active locale prefix. - Server Component boundary — never pass a function prop (
onClick/onChange) across the server→client boundary. Extract interactive UI into a'use client'child and pass plain data. See best-practices/server-components.md. - Navigation APIs are not catchable — never wrap
redirect()/notFound()/forbidden()/unauthorized()intry/catch; they throw internal control-flow errors. Call them outside thetry, or re-throw withunstable_rethrow. See best-practices/error-handling.md.
Anti-Patterns Quick Reference
| Anti-pattern | Correct approach |
|---|---|
"next": "15.x" or next-intl < 4 | next@^16 and next-intl@^4 |
import Link from 'next/link' in a page component | import { Link } from '@/i18n/routing' |
Removing images.unoptimized: true from next.config.ts | Keep it — the commercetools CDN rejects Next's optimizer query params |
redirect() / notFound() inside a try/catch | Call outside the try, or unstable_rethrow(error) |
metadata / generateMetadata in a 'use client' page | Metadata is Server-Component-only — move client logic to a child |
unstable_cache for per-user/session data | Only for stable public data; per-user state uses SWR |
How this maps to the generic skill
- "server-rendered data load" →
asyncServer Component callinglib/ct/*directly - "server endpoint (BFF)" → Route Handler
app/api/<resource>/route.ts - "client-fetched mutable state" → SWR hook → Route Handler
- "the framework's server-side cache-with-TTL primitive" →
unstable_cache - "the framework's locale-aware link / client navigation" →
Link/useRouterfrom@/i18n/routing
lib/ct/*, lib/mappers/*, lib/types.ts, lib/cache-keys.ts, hooks/*, context/* — are identical in both skills; this adapter does not redefine them.Next.js Project Layout
commercetools-storefront patterns assume when the framework is Next.js. The /nextjs-setup-project command scaffolds all of it; this file documents the resulting layout and the load-bearing config so you can reason about it or repair a partial setup.Version gates (CRITICAL)
| Package | Required | Why |
|---|---|---|
next | ^16 (must be > 16.0.0) | Next.js 15.x has known security vulnerabilities |
next-intl | ^4 | Compatible with next@^16 locale routing |
@commercetools/platform-sdk | ^8 | Storefront SDK version |
@commercetools/ts-client | ^4 | Token + middleware client |
swr, jose, tailwindcss @tailwindcss/postcss postcss. Scaffold with create-next-app@^16 using --app --typescript --tailwind=false (passing --tailwind would install Tailwind v3).Directory structure
<repo root>/
├── vercel.json # Vercel build config (next to <root-dir>/, NOT inside it)
├── netlify.toml # Netlify build config (next to <root-dir>/)
└── <root-dir>/
├── app/
│ ├── layout.tsx # root layout — SWRConfig fallback hydration
│ ├── [locale]/ # locale-prefixed routes (page.tsx, layout.tsx, error.tsx, not-found.tsx)
│ └── api/ # BFF Route Handlers (auth, account, cart, checkout, shipping-methods, channels)
├── lib/
│ ├── ct/ # server-only commercetools helpers + client.ts singleton + image-config.ts
│ ├── mappers/ # commercetools → app type mappers
│ ├── session.ts # jose JWT session (see data-loading.md)
│ ├── types.ts # app types (components import from here)
│ ├── cache-keys.ts # SWR cache keys
│ └── utils.ts # COUNTRY_CONFIG, formatMoney, getLocalizedString
├── hooks/ # 'use client' SWR hooks
├── context/ # React context providers (CartContext, etc.)
├── components/{ui,layout,product}/
├── i18n/
│ ├── routing.ts # next-intl defineRouting + createNavigation
│ └── request.ts # next-intl getRequestConfig
├── messages/ # <locale>.json message catalogs
├── proxy.ts # locale middleware
├── next.config.ts # createNextIntlPlugin + images config
└── postcss.config.mjs # @tailwindcss/postcss
Co-located with a Connect connector? If<root-dir>/(e.g.site/) is a sibling of aconnect.yamland its connector apps in the same repo, the storefront still deploys exactly as above — keep the platform's project root scoped to<root-dir>/so it ignores the connector code. For the monorepo layout and why the connector apps must be root siblings, see the commercetools-connect skill's monorepo-with-storefront.md.
next-intl locale routing
i18n/routing.ts derives locales from COUNTRY_CONFIG and exports the locale-aware navigation primitives — always import Link/useRouter/redirect from here, never from next/link or next/navigation directly in locale-prefixed UI:// i18n/routing.ts
import { defineRouting } from 'next-intl/routing';
import { createNavigation } from 'next-intl/navigation';
import { COUNTRY_CONFIG } from '@/lib/utils';
export const routing = defineRouting({
locales: Object.keys(COUNTRY_CONFIG) as [string, ...string[]],
defaultLocale: 'en-US',
localePrefix: 'always',
});
export const { Link, redirect, usePathname, useRouter, getPathname } = createNavigation(routing);
i18n/request.ts loads the per-locale messages via getRequestConfig. next.config.ts wires the plugin and the image config:// next.config.ts
import type { NextConfig } from 'next';
import createNextIntlPlugin from 'next-intl/plugin';
const withNextIntl = createNextIntlPlugin('./i18n/request.ts');
const nextConfig: NextConfig = {
images: {
unoptimized: true, // commercetools CDN rejects Next's optimizer query params — keep it
remotePatterns: [
{ protocol: 'https', hostname: 'storage.googleapis.com' },
{ protocol: 'https', hostname: '**' },
],
},
};
export default withNextIntl(nextConfig);
proxy.ts is the locale middleware: it skips /api, /_next, files; passes through already-locale-prefixed paths (setting x-next-intl-locale); and otherwise redirects to /<locale>/... using the your-shop-country-locale cookie (BCP-47) or the default. Its matcher is ['/((?!api|_next|favicon|.*\\..*).*)', '/'].Tailwind v4
postcss.config.mjs uses @tailwindcss/postcss; app/globals.css starts with @import 'tailwindcss'; and declares theme tokens via @theme { ... }. Use @source inline('...') to safelist dynamically-composed class names (e.g. grid column spans).Deploy
<root-dir>/ with npm run build and publish .next. Config files live at the repo root:// vercel.json
{ "buildCommand": "npm run build", "outputDirectory": ".next", "installCommand": "npm install", "framework": "nextjs" }
# netlify.toml
[build]
base = "site"
command = "npm run build"
publish = ".next"
[build.environment]
NODE_VERSION = "22"
/nextjs-deploy-vercel or /nextjs-deploy-netlify — they enforce the Frontend (non-admin) API client, verify SESSION_SECRET ≥ 32 chars, and walk through project import and env vars. Delete app/api/health/route.ts before deploying.Commands
| Task | Command |
|---|---|
| Scaffold the project (steps above, automated) | /nextjs-setup-project |
Add a country/locale (COUNTRY_CONFIG, i18n/routing, messages, hero config) | /nextjs-add-locale |
| Deploy to Vercel | /nextjs-deploy-vercel |
| Deploy to Netlify | /nextjs-deploy-netlify |
Error Handling
Critical: createError, navigateTo, and abortNavigation Are Control Flow
createError({ ..., fatal: true }) throws to interrupt rendering; navigateTo and abortNavigation change navigation. In route middleware they must be returned — a bare call without return does nothing useful and can let navigation continue.// app/middleware/auth.ts
export default defineNuxtRouteMiddleware((to) => {
const { loggedIn } = useUserSession()
// Bad: not returned — navigation continues anyway
// if (!loggedIn.value) navigateTo('/login')
// Good: return the result
if (!loggedIn.value) return navigateTo('/login')
})
abortNavigation(err?) stops navigation and (optionally) raises an error; it is middleware-only and must be returned:export default defineNuxtRouteMiddleware((to) => {
const { user } = useUserSession()
if (!user.value?.isAdmin) return abortNavigation(createError({ statusCode: 403 }))
})
definePageMeta({ middleware: ['auth'] }); a *.global.ts file runs on every route.Triggering Errors — createError
createError when a resource doesn't exist or a request fails. On the server it always renders the error page; on the client you need fatal: true for the full-screen error page (otherwise it surfaces in the nearest <NuxtErrorBoundary>):<!-- app/pages/products/[slug].vue -->
<script setup lang="ts">
const slug = useRoute().params.slug as string
const { data: product } = await useAsyncData(`product:${slug}`, () =>
$fetch(`/api/products/${slug}`).catch(() => null)
)
if (!product.value) {
throw createError({ statusCode: 404, statusMessage: 'Product not found', fatal: true })
}
</script>
createError after the data resolves — not inside a try that catches and discards it.createError to send an HTTP error; the storefront's useFetch/$fetch receives it as a rejected promise:// server/api/widgets/index.get.ts
export default defineEventHandler(async (event) => {
const { user } = await requireUserSession(event) // throws 401 automatically
const widget = await getWidget(user.id)
if (!widget) throw createError({ statusCode: 404, statusMessage: 'No widget' })
return widget
})
createError({ statusCode, statusMessage, fatal?, data?, cause? }) — statusCode/statusMessage are the canonical fields.Root Error Page — app/error.vue
error prop and replaces the normal layout. clearError({ redirect }) clears the error state and (optionally) navigates away:<!-- app/error.vue -->
<script setup lang="ts">
import type { NuxtError } from '#app'
const props = defineProps<{ error: NuxtError }>()
const handleError = () => clearError({ redirect: '/' })
</script>
<template>
<div>
<h1>{{ error.statusCode }}</h1>
<p>{{ error.statusCode === 404 ? 'Page not found' : 'Something went wrong' }}</p>
<button @click="handleError">Back to home</button>
</div>
</template>
error.statusCode to render 404 vs 500 messaging — there is one root error page, not per-segment files.Scoped Boundary — <NuxtErrorBoundary>
#error slot exposes error and clearError; the boundary auto-clears on route change:<template>
<NuxtErrorBoundary @error="logError">
<RecommendationsWidget />
<template #error="{ error, clearError }">
<p>Couldn't load recommendations.</p>
<button @click="clearError">Retry</button>
</template>
</NuxtErrorBoundary>
</template>
createError({ fatal: true }) for the things a page can't render without (the product itself).Redirects
// In a page setup, middleware (return it), or plugin:
return navigateTo('/login') // default 302
return navigateTo('/new-url', { redirectCode: 301 }) // permanent
return navigateTo('https://example.com', { external: true }) // external requires external:true
navigateTo('/path', { replace: true }) // replace history entry
navigateTo is for the app runtime. In a Nitro route use sendRedirect instead — navigateTo is not available server-side:// server/api/old-path.get.ts
export default defineEventHandler((event) => {
return sendRedirect(event, '/new-path', 301)
})
Error Surface Summary
| Need | Use |
|---|---|
| 404 / fatal page error | throw createError({ statusCode, fatal: true }) → app/error.vue |
| HTTP error from a Nitro route | throw createError({ statusCode, statusMessage }) |
| Require auth in a Nitro route | requireUserSession(event) (auto-401) |
| Gate a route in the app | route middleware → return navigateTo(...) / return abortNavigation(...) |
| Non-blocking section failure | <NuxtErrorBoundary> |
| Redirect (app) | return navigateTo(...) |
| Redirect (Nitro) | sendRedirect(event, ...) |
| Read current error state | useError() |
Image Optimization
Project Rule: provider: 'none' Is Intentional
nuxt.config.ts sets image: { provider: 'none' }. Do not change it to ipx or another optimizer.storage.googleapis.com) returns 403/400 when an optimizer appends ?w=...&q=... params. The none provider is a pure pass-through: it returns the original URL untouched, ignores modifiers, and never appends params — while keeping <NuxtImg> ergonomics (loading, sizes, placeholder, preload). Sizing is handled explicitly in shared/utils/ / server/utils/ct/image-config.ts transform functions.// nuxt.config.ts — do not change provider
export default defineNuxtConfig({
image: {
provider: 'none',
domains: ['storage.googleapis.com'], // allow the remote host (env: NUXT_IMAGE_DOMAINS)
},
})
@nuxt/imageusesdomainsto allow-list remote hosts (there is noremotePatternskey). Add any non-CT host (CMS banners, avatars) to this array.
Product Images: Always Go Through image-config
<NuxtImg>).Components never build image URLs inline — import the transform:
<script setup lang="ts">
import { transformListingImageUrl, transformDetailImageUrl, transformThumbnailImageUrl } from '#shared/utils/image-config'
</script>
<template>
<!-- Listing / search result card -->
<NuxtImg :src="transformListingImageUrl(item.imageUrl)" :alt="item.name" width="400" height="500" loading="lazy" />
<!-- PDP main image — LCP, preload with high priority, no lazy -->
<NuxtImg
:src="transformDetailImageUrl(product.imageUrl)"
:alt="product.name"
width="800" height="1000"
sizes="100vw md:50vw"
:preload="{ fetchPriority: 'high' }"
/>
<!-- PDP thumbnail strip -->
<NuxtImg :src="transformThumbnailImageUrl(product.imageUrl)" :alt="product.name" width="80" height="100" loading="lazy" />
</template>
Never inline a URL transform in a component — changing the transform updates all instances at once.
Always Use <NuxtImg> — Never <img>
provider: 'none', <NuxtImg> still prevents layout shift (explicit width/height), lazy-loads below-fold images, and gives you sizes/densities/placeholder for free.<!-- Bad -->
<img :src="url" alt="Product" />
<!-- Good -->
<NuxtImg :src="url" alt="Product" width="400" height="500" />
sizes and densities
sizes is space-separated screen:width pairs (Tailwind-aligned breakpoints). densities covers HiDPI:<!-- responsive grid card -->
<NuxtImg :src="url" alt="Card" width="400" height="500" sizes="100vw sm:50vw md:33vw" densities="x1 x2" loading="lazy" />
width="16" height="9" tells the browser the image is 16px wide.Priority for LCP Images
:preload="{ fetchPriority: 'high' }" to the first visible image (hero, PDP main) and omit loading="lazy". Everything below the fold stays loading="lazy":<!-- Hero / LCP — renders immediately -->
<NuxtImg :src="url" alt="Hero" width="1200" height="600" sizes="100vw" :preload="{ fetchPriority: 'high' }" />
<!-- Below the fold — lazy -->
<NuxtImg :src="url" alt="Card" width="400" height="500" loading="lazy" />
Placeholder
placeholder shows a blurred/low-res stand-in until the image loads. As a boolean it auto-derives; pass [w, h, q, blur] to tune:<NuxtImg :src="url" alt="Banner" width="1200" height="400" placeholder />
<NuxtImg :src="url" alt="Banner" width="1200" height="400" :placeholder="[50, 25, 75, 5]" />
Common Mistakes
<!-- Bad: native img — no lazy-load, no dimension enforcement -->
<img :src="url" alt="Product" />
<!-- Bad: switching provider back to an optimizer — CT CDN 403s on appended params -->
<!-- image: { provider: 'ipx' } -->
<!-- Bad: inline URL transform — bypasses image-config -->
<NuxtImg :src="`${url}?w=400`" alt="Product" width="400" height="500" />
<!-- Bad: aspect-ratio numbers as dimensions -->
<NuxtImg :src="url" alt="Hero" width="16" height="9" />
<!-- Good: real display size + responsive sizes -->
<NuxtImg :src="url" alt="Hero" width="1200" height="600" sizes="100vw" />
Metadata
Rule: Set Meta with useSeoMeta
useSeoMeta() is the type-safe way to set SEO tags from any page or component setup. It runs on the server (so crawlers see the tags) and updates reactively on the client. useHead() covers anything useSeoMeta doesn't (the title template, arbitrary <link>/<script>).// app/pages/my-page.vue — static
useSeoMeta({
title: 'My Page', // title template appends the site name
description: 'Page description for SEO',
ogTitle: 'My Page',
ogDescription: 'Page description for SEO',
})
Title Template
app/app.vue (or the default layout) so every page title is suffixed consistently:// app/app.vue
useHead({
titleTemplate: (chunk) => (chunk ? `${chunk} – Your Store` : 'Your Store'),
})
title: 'Cart' then renders as Cart – Your Store.Dynamic Metadata
useSeoMeta so it stays reactive as the data resolves:<!-- app/pages/products/[slug].vue -->
<script setup lang="ts">
const slug = useRoute().params.slug as string
const { data: product } = await useAsyncData(`product:${slug}`, () =>
$fetch(`/api/products/${slug}`).catch(() => null)
)
if (!product.value) {
throw createError({ statusCode: 404, statusMessage: 'Product not found', fatal: true })
}
useSeoMeta({
title: () => product.value?.name,
description: () => product.value?.description,
ogImage: () => product.value?.imageUrl,
ogType: 'product',
})
</script>
useAsyncData result is deduped by key and ships in the SSR payload — the same fetch feeds both the page body and the meta tags; there is no second request.OG Images — nuxt-og-image v6
nuxt-og-image. In v6, defineOgImageComponent is deprecated — use defineOgImage('<ComponentName>', props). Renderer deps are no longer bundled; install them explicitly (the Takumi renderer is the default and supports Tailwind v4):npm install -D nuxt-og-image
# add 'nuxt-og-image' to modules in nuxt.config.ts
<!-- app/components/OgImage/Product.takumi.vue (.takumi suffix selects the renderer) -->
<script setup lang="ts">
const { title = 'Product', price = '' } = defineProps<{ title?: string; price?: string }>()
</script>
<template>
<div class="h-full w-full flex flex-col items-center justify-center bg-white">
<h1 class="text-[72px] font-black px-20 text-center">{{ title }}</h1>
<p v-if="price" class="text-[40px] mt-6">{{ price }}</p>
</div>
</template>
<!-- in the PDP -->
<script setup lang="ts">
defineOgImage('Product.takumi', { title: product.value.name, price: formattedPrice.value })
</script>
app/components/OgImage/ (auto-registered as templates). The inline <OgImage> component is the declarative equivalent of defineOgImage.Metadata File Conventions
| File / mechanism | Purpose |
|---|---|
app/public/favicon.ico | Browser tab icon |
defineOgImage(...) / <OgImage> | OG + Twitter card image (nuxt-og-image) |
server/routes/sitemap.xml.ts or @nuxtjs/sitemap | Sitemap (use the module for large catalogs) |
server/routes/robots.txt.ts or @nuxtjs/robots | Crawl directives |
useSeoMeta covers twitterCard/twitterTitle/etc. directly, so a Twitter card needs no separate file — set twitterCard: 'summary_large_image' and Twitter falls back to the OG image.Rendering Boundary (SSR / Client)
setup runs in both passes. Two boundaries matter for a commercetools storefront: the secret boundary (app/ must never reach server/) and the hydration boundary (server and client must render the same markup, and browser-only APIs must not run during SSR).The secret boundary: never import server/ into the app
server/utils/ct/. The Vue app bundle ships to the browser — importing server/ code into a page or component would leak those secrets and break the build.// WRONG — pulls server-only code (and secrets) into the client bundle
import { getProduct } from '~~/server/utils/ct/products'
const product = await getProduct(slug)
// CORRECT — go through the BFF; useAsyncData runs it in-process during SSR
const { data: product } = await useAsyncData(`product:${slug}`, () => $fetch(`/api/products/${slug}`))
server/api/. See concept-mapping.md and data-loading.md.Don't call bare $fetch in setup
$fetch('/api/...') in setup runs on the server and again on the client during hydration — two requests, and the result isn't in the payload. Use useFetch/useAsyncData for initial data (deduped, SSR-payload-hydrated); reserve $fetch for event handlers and Pinia store actions.// Bad — double fetch, no payload hydration
const cart = await $fetch('/api/cart')
// Good — single SSR fetch, hydrated on the client
const { data: cart } = await useFetch('/api/cart')
// Good — $fetch is correct inside an event handler / store action
async function addToCart(sku: string) {
await $fetch('/api/cart/line-items', { method: 'POST', body: { sku } })
}
Browser-only code: onMounted, import.meta.client, <ClientOnly>
window, document, localStorage, and IntersectionObserver don't exist during SSR. Touching them in setup crashes the server render.// Bad — runs during SSR, ReferenceError: window is not defined
const width = window.innerWidth
// Good — client-only lifecycle
const width = ref(0)
onMounted(() => { width.value = window.innerWidth })
// Good — guard a one-off
if (import.meta.client) { /* browser-only */ }
window at module load, a payment iframe), wrap them in <ClientOnly> and provide a #fallback to reserve layout space:<template>
<ClientOnly>
<PaymentWidget :cart="cart" />
<template #fallback>
<div class="h-40 animate-pulse rounded bg-gray-100" />
</template>
</ClientOnly>
</template>
Hydration safety: server and client must match
Date.now(), Math.random(), timezone, or window during the initial render — compute those in onMounted, or render them inside <ClientOnly>.<!-- Bad: server time ≠ client time → mismatch -->
<p>{{ new Date().toLocaleTimeString() }}</p>
<!-- Good -->
<ClientOnly><p>{{ now }}</p></ClientOnly>
Shared state: useState and Pinia, never a module-level ref
ref leaks one user's state into another's response. Use useState(key, init) (request-isolated, hydrates via payload) for simple shared values, or a Pinia store for richer state.// Bad — shared across requests on the server, cross-request state leak
const selectedCountry = ref('US')
// Good — request-isolated, SSR-hydrated
export const useSelectedCountry = () => useState('selectedCountry', () => 'US')
useState, useAsyncData/useFetch results, and Pinia stores — is serialized into the Nuxt payload and reused on hydration without refetching. See data-loading.md.Quick reference
| Concern | Rule |
|---|---|
| commercetools / secrets | Only in server/; reach via a Nitro route, never import server/ into the app |
| Initial data | useFetch / useAsyncData — not bare $fetch in setup |
| Mutations / events | $fetch inside handlers and store actions |
window / document / localStorage | onMounted or import.meta.client; whole component → <ClientOnly> |
| Non-deterministic render (time, random) | compute in onMounted or wrap in <ClientOnly> |
| Shared state | useState(key, init) or Pinia — never a module-level ref |
Concept → Nuxt Primitive Mapping
commercetools-storefront skill states every rule in framework-neutral language; this table resolves each concept to its Nuxt 4 primitive. When a generic reference says "see your framework adapter", it means this file.Path & state conventions
app/ (the Vue app, srcDir, runs server + client), server/ (Nitro, server-only), and shared/ (isomorphic, auto-imported in both):| Generic placeholder | Nuxt (this stack) |
|---|---|
<root-dir>/ — application root directory | storefront/ (project root; nuxt.config.ts lives here, Vue source under storefront/app/) |
<server>/ — server-side code root | server/ (Nitro — never imported by the app) |
<api>/ — client-facing API surface the browser calls | server/api/ (Nitro routes — server/api/<resource>.ts) |
<server>/ct/* — commercetools helpers | server/utils/ct/* (auto-imported in server code) |
<server>/ct/client — apiRoot singleton | server/utils/ct/client.ts |
<server>/types — app type-mapping root (boundary types) | shared/types/ (auto-imported in both app and server) |
<server>/mappers/ — commercetools→app mappers | server/utils/mappers/ |
<server>/cache-keys — client-state keys | Pinia store ids + useAsyncData keys (a shared/keys.ts is optional) |
<server>/session — session read/write module | nuxt-auth-utils (sealed cookie); thin helpers in server/utils/session.ts |
<server>/utils — shared utils (COUNTRY_CONFIG, money/locale) | shared/utils/ (auto-imported both sides — these are isomorphic) |
| Client state — mutable per-user data layer | Pinia (app/stores/*.ts); useState for simple shared values; see Client state stores |
| Client state hook | a Pinia store (app/stores/*.ts) or a composable (app/composables/*.ts) |
| Client state provider | none needed — Pinia is globally available; SSR state hydrates via the Nuxt payload |
| Server-managed session | a sealed (encrypted) cookie via nuxt-auth-utils (stateless BFF); see data-loading.md |
Whyshared/for types andCOUNTRY_CONFIG: the generic skill files these under<server>/, but in Nuxt they must be importable from Vue components too (a card formats money; a page imports theProductboundary type).shared/utils/**andshared/types/**auto-import in both runtimes and may not import Vue or Nitro APIs — keeping them isomorphic. Secret-bearing code (apiRoot) stays inserver/utils/, nevershared/.
Lookup table
| Generic concept | Nuxt 4 primitive |
|---|---|
| Server-rendered data load (catalog/immutable) | useAsyncData(key, () => $fetch('/api/...')) or useFetch('/api/...') in app/pages/.../[slug].vue; runs during SSR, result serialized to the payload |
| Resolve route params (page) | const route = useRoute(); route.params.slug — reactive, available on server and client |
| Resolve route params (Nitro) | getRouterParam(event, 'slug'); query via getQuery(event) |
| Server endpoint (BFF) | Nitro route server/api/<resource>.ts exporting defineEventHandler; method suffixes .get.ts / .post.ts / .patch.ts / .delete.ts |
| Server endpoint directory layout | server/api/{auth,account,cart,checkout,shipping-methods,channels}/... |
| Client component / browser-interactive UI | a Vue SFC under app/components/; wrap browser-only UI in <ClientOnly> — see best-practices/rendering.md |
| Read/write the (server-managed) session | nuxt-auth-utils in server/: getUserSession / setUserSession / requireUserSession / clearUserSession; sealed-cookie (stateless BFF) — see data-loading.md |
| Not-found response | throw createError({ statusCode: 404, fatal: true }) → renders app/error.vue |
| Redirect | return navigateTo('/path', { redirectCode: 301 }) (app/middleware); sendRedirect(event, '/path', 302) (Nitro) — in middleware the call must be returned |
| Route-segment error boundary | root app/error.vue; component-scoped <NuxtErrorBoundary> |
| Auth-gated responses | requireUserSession(event) (throws 401 in Nitro); throw createError({ statusCode: 403 }) for forbidden |
| Client-side navigation | navigateTo(localePath('/checkout/payment'), { replace: true }) |
| Locale-aware link primitive | <NuxtLink :to="localePath('/path')"> via useLocalePath() — never a bare path string |
| Locale routing config | @nuxtjs/i18n v10 in nuxt.config.ts (i18n: { locales, defaultLocale, strategy: 'prefix' }) + i18n/i18n.config.ts; messages in i18n/locales/ — see project-layout.md |
| Locale URL prefix | strategy: 'prefix' — the module owns prefixing, detection, and redirect |
| Server-side cache-with-TTL for stable CT data | defineCachedFunction(fn, { maxAge, name, getKey, swr }) or routeRules cache — never per-user/session — see data-loading.md |
| Per-request fetch dedup | useAsyncData/useFetch dedupe by key automatically; a Nitro defineCachedFunction dedupes within its TTL |
| Hydrate client state-manager/cache from server (no spinner flash) | the Nuxt payload — useAsyncData/useFetch and Pinia state serialize on the server and hydrate without refetch — see data-loading.md |
| Root layout / providers | app/app.vue + app/layouts/default.vue; Pinia and i18n register via modules (no manual provider) |
| Page-level SEO metadata | useSeoMeta({ ... }) (reactive getters for dynamic data) + useHead for the title template — see best-practices/metadata.md |
| OG/social card image | nuxt-og-image v6: defineOgImage('Name.takumi', props) + a component in app/components/OgImage/ |
| Product image rendering | <NuxtImg> with image: { provider: 'none' } — see best-practices/image.md |
| Health check (verify CT credentials) | server/api/health.get.ts → apiRoot.get().execute() (delete before deploy) |
| App framework config | nuxt.config.ts (modules, runtimeConfig, i18n, image, nitro, vite) |
| Styling | Tailwind v4 — @tailwindcss/vite plugin + @import "tailwindcss" in a registered CSS file |
| Deploy target | Nitro presets — nitro: { preset: 'vercel' } / 'netlify' (auto-detected in CI) |
Portable, not remapped: the commercetools SDK calls (apiRoot.*, the as-associate chain), the mappers, andgetLocalizedString/formatMoneyare identical to the generic skill — only their location (<server>/→server/utils/andshared/) and the render/state primitives around them differ.nuxt-auth-utils, Pinia, and@nuxtjs/i18nare this stack's realizations of the generic server-managed session, client state, and locale routing concepts.
Page shape (server-rendered data load)
useAsyncData runs it during SSR (in-process, no network hop) and ships the result in the payload:<!-- app/pages/category/[slug].vue -->
<script setup lang="ts">
const route = useRoute()
const slug = route.params.slug as string
// Parallel independent fetches — both run server-side, both land in the payload
const { data, error } = await useAsyncData(`category:${slug}`, () =>
Promise.all([
$fetch(`/api/category/${slug}`),
$fetch('/api/category-tree'),
]).then(([category, tree]) => ({ category, tree }))
)
if (!data.value?.category) {
throw createError({ statusCode: 404, statusMessage: 'Category not found', fatal: true })
}
// build breadcrumb by walking data.value.tree in memory (no extra request)
</script>
- Pages render on the server first, then hydrate. Anything that touches
window/documentbelongs inonMountedor<ClientOnly>— see best-practices/rendering.md. createError({ ..., fatal: true })renders the full error page on the client too; on the server it always does. Call it after the data resolves, not inside atrythat swallows it.
Client navigation & step routing
The generic "client-side navigation" (e.g. checkout step guards):
<!-- app/pages/checkout/index.vue -->
<script setup lang="ts">
const localePath = useLocalePath()
const cart = useCartStore()
watchEffect(() => {
if (cart.cart === null) return // still loading
const hasAddr = !!(cart.cart.shippingAddress?.streetName && cart.cart.billingAddress?.streetName)
const hasMethod = !!cart.cart.shippingInfo
if (hasAddr && hasMethod) navigateTo(localePath('/checkout/payment'), { replace: true })
else if (hasAddr) navigateTo(localePath('/checkout/shipping'), { replace: true })
else navigateTo(localePath('/checkout/addresses'), { replace: true })
})
</script>
core/checkout-page.md; only the navigateTo + localePath mechanism is Nuxt-specific.Confirmation page (server-rendered, fetch by id)
<!-- app/pages/checkout/confirmation/[orderId].vue -->
<script setup lang="ts">
const route = useRoute()
const { data: order } = await useAsyncData(
`order:${route.params.orderId}`,
() => $fetch(`/api/checkout/order/${route.params.orderId}`).catch(() => null)
)
// render success indicator, order number, line-item summary from order.value
</script>
core/checkout-page.md; the useAsyncData + route-param shape is the Nuxt mapping.Client state stores (Pinia)
$fetch, update state from the response body, and throw on error. Stores in app/stores/ are auto-imported, and their state hydrates from the SSR payload with no refetch.// app/stores/widgets.ts
export const useWidgetsStore = defineStore('widgets', () => {
const widgets = ref<Widget[]>([]) // safe default
const count = computed(() => widgets.value.length)
async function load() {
widgets.value = (await $fetch('/api/widgets')).widgets ?? []
}
async function create(data: NewWidget) {
// action throws on failure; component decides how to surface it
const res = await $fetch('/api/widgets', { method: 'POST', body: data })
widgets.value = res.widgets // update from response — no extra round-trip
}
return { widgets, count, load, create }
})
- Store ids are the cache identity (generic:
<server>/cache-keys); BU-scoped state keys its server route bybusinessUnitKey(/api/widgets?bu=...), not a separate store per BU. - Actions throw; state getters expose safe defaults (
null/[]). - Update from the response body — assign the returned object; no follow-up fetch.
- Seed from the server by populating the store during SSR (a plugin or
callOnce) so first paint has data — see data-loading.md. For a single value (e.g. selected locale/country) preferuseStateover a full store.
Data Loading — Nuxt Implementation
commercetools-storefront skill decides what loads where:- Catalog / immutable data (category pages, PDPs, search results) → server-rendered load via
useAsyncData/useFetchagainst a Nitro route, cached with a Nitro TTL. - Mutable per-user state (cart, account, orders, quotes) → a Pinia store whose actions call
$fetch('/api/...').
server/api/ does, delegating to server/utils/ct/*. This file pins those decisions to Nuxt 4 primitives. The decision rule itself is generic — see core/data-loading.md.commercetools client — server/utils/ct/client.ts
apiRoot singleton is server-only. Credentials come from runtimeConfig (no public key), so they never enter the client bundle:// server/utils/ct/client.ts
import { ClientBuilder } from '@commercetools/ts-client'
import { createApiBuilderFromCtpClient } from '@commercetools/platform-sdk'
let _apiRoot: ReturnType<typeof createApiBuilderFromCtpClient> | null = null
export function getApiRoot() {
if (_apiRoot) return _apiRoot
const c = useRuntimeConfig() // server-only secrets
const client = new ClientBuilder()
.withClientCredentialsFlow({
host: c.ctAuthUrl,
projectKey: c.ctProjectKey,
credentials: { clientId: c.ctClientId, clientSecret: c.ctClientSecret },
})
.withHttpMiddleware({ host: c.ctApiUrl })
.build()
_apiRoot = createApiBuilderFromCtpClient(client).withProjectKey({ projectKey: c.ctProjectKey })
return _apiRoot
}
// nuxt.config.ts — runtimeConfig keys (server-only unless under public)
export default defineNuxtConfig({
runtimeConfig: {
ctProjectKey: '', // NUXT_CT_PROJECT_KEY
ctClientId: '', // NUXT_CT_CLIENT_ID
ctClientSecret: '', // NUXT_CT_CLIENT_SECRET — secret, server-only
ctAuthUrl: '', // NUXT_CT_AUTH_URL
ctApiUrl: '', // NUXT_CT_API_URL
// public: {} // nothing about commercetools belongs here
},
})
server/utils/ are auto-imported across server code, so getApiRoot() and the server/utils/ct/* helpers need no import statement inside Nitro routes.Session — nuxt-auth-utils sealed cookie (stateless BFF)
nuxt-auth-utils: session data is encrypted and stored in the cookie itself (no server store), read and written only in server/. Requires NUXT_SESSION_PASSWORD ≥ 32 chars.Session interface. Non-secret fields go at the top level (exposed to the client through useUserSession()); commercetools tokens go under secure, which is stripped from the client payload:// shared/types/session.ts — augment the module's session type
declare module '#auth-utils' {
interface User { id: string; email: string; firstName?: string; lastName?: string }
interface UserSession {
cartId?: string
country?: string
currency?: string
locale?: string
// B2B adds: businessUnitKey, storeKey, storeId, distributionChannelId, supplyChannelId, productSelectionId
}
interface SecureSessionData {
// server-only — never sent to the browser
ctAccessToken?: string
ctRefreshToken?: string
}
}
export {}
// server/api/auth/login.post.ts — write the session
export default defineEventHandler(async (event) => {
const { email, password } = await readBody(event)
const customer = await loginCustomer(email, password) // server/utils/ct/auth.ts
await setUserSession(event, {
user: { id: customer.id, email, firstName: customer.firstName, lastName: customer.lastName },
cartId: customer.cart?.id,
secure: { ctAccessToken: customer.accessToken },
})
return { user: { id: customer.id, email } }
})
// server/api/auth/me.get.ts — read / require the session
export default defineEventHandler(async (event) => {
const { user } = await requireUserSession(event) // throws 401 if no user
return { user }
})
// server/api/auth/logout.post.ts
export default defineEventHandler(async (event) => {
await clearUserSession(event)
return { ok: true }
})
Stateless vs stateful. The default is fully stateless — everything lives encrypted in the cookie (4 KB limit), so it scales horizontally with no shared store. If session data outgrows the cookie or you need server-side revocation,nuxt-auth-utilssupports an optionalunstorage-backed server store keyed by a session id — same API surface, different storage. This is the generic skill's stateful-BFF option.
Locale is owned by@nuxtjs/i18n(its cookie +useI18n().locale), not the session. When a customer'scountry/currency/localemust drive pricing, persist them into the session at login/selection and read them in Nitro routes viagetUserSession(event).
BFF Nitro route shape
server/utils/ct/<namespace>.ts → return JSON). In Nuxt that is a Nitro route:// server/api/widgets/index.get.ts
export default defineEventHandler(async (event) => {
const { user } = await requireUserSession(event) // 401 if not logged in
try {
return { widgets: await getWidgets(user.id) } // getWidgets from server/utils/ct/widgets.ts
} catch (e: unknown) {
throw createError({
statusCode: 500,
statusMessage: e instanceof Error ? e.message : 'Failed to fetch widgets',
})
}
})
server/utils/ct/<namespace>.ts (generic rule). The data flow is: page/store → useFetch/$fetch('/api/...') → Nitro route → server/utils/ct/* → getApiRoot().[id] is a dynamic param):server/api/
auth/ login.post.ts register.post.ts logout.post.ts me.get.ts
account/ orders.get.ts addresses.*.ts payments.*.ts wishlist.*.ts
cart/ index.get.ts index.post.ts line-items.post.ts discount.post.ts
checkout/ order.post.ts order/[orderId].get.ts
shipping-methods/ index.get.ts # options by locale/currency
channels/ index.get.ts # store channels (BOPIS)
defineEventHandler shell.Server-side caching — defineCachedFunction
defineCachedFunction (wrap a function) or defineCachedEventHandler (wrap a whole route). maxAge is in seconds; swr: true serves a stale entry while revalidating in the background:// server/utils/ct/locale-validation.ts
export const getValidCountryConfig = defineCachedFunction(
async () => {
const { body } = await getApiRoot().get().execute()
const { countries = [], currencies = [], languages = [] } = body
return Object.fromEntries(
Object.entries(COUNTRY_CONFIG).filter(([country, config]) =>
countries.includes(country) &&
currencies.includes(config.currency) &&
languages.some((l: string) => l.toLowerCase() === config.locale.toLowerCase())
)
)
},
{ name: 'locale-validation', maxAge: 300, getKey: () => 'all', swr: true }
)
routeRules in nuxt.config.ts:// nuxt.config.ts
export default defineNuxtConfig({
routeRules: {
'/api/category-tree': { cache: { maxAge: 60, swr: true } },
'/api/shipping-methods': { cache: { maxAge: 60, swr: true } },
},
})
| Data | Cache TTL | Reason |
|---|---|---|
| commercetools project config (countries, currencies) | 300 s | Changes only on project reconfiguration |
| Category tree | 60 s | Rarely edited; high reuse across pages |
| Shipping methods | 60 s | Rarely edited; no per-user variation |
| Product prices | Do not cache | Change on promotion rules; per-currency |
| Cart / account data | Do not cache | Per-session, changes frequently |
PreferdefineCachedFunctionover module-level variables — a module cache resets on cold starts and isn't shared across instances. The Nitro cache is shared across all requests, so never cache per-user or per-session data with it; use a Pinia store (client) or a direct per-requestserver/utils/ct/*call for user-specific data. Cached entries persist in the Nitrocachestorage (memory in dev; the platform KV/driver in production).
Hydration from the server — the Nuxt payload
useAsyncData/useFetch and any Pinia store state is serialized into the payload and reused on hydration without refetching.useAsyncData/useFetch already hydrate. No extra wiring; the page renders with data on first paint.callOnce guarantees the init runs once on the server and isn't repeated on the client:// app/plugins/init-session.ts (or call inside app.vue setup)
export default defineNuxtPlugin(async () => {
const cart = useCartStore()
const account = useAccountStore()
await callOnce('init-session', async () => {
const { user } = await $fetch('/api/auth/me').catch(() => ({ user: null }))
if (user) account.setUser(user)
await cart.load() // store action; result hydrates via payload
})
})
user fields (id, email, first/last name), account.setUser needs no commercetools fetch — a full customer fetch is only required on the account profile page.Route params
- In a page, read params from
useRoute():const slug = useRoute().params.slug as string. It's reactive and identical on server and client. - In a Nitro route, use
getRouterParam(event, 'slug')andgetQuery(event); never reach foruseRoute()server-side. - Give
useAsyncDataan explicit key that includes the param (useAsyncData(\product:${slug}`, ...)`) so navigations between slugs don't collide in the payload cache.
Connection health check
// server/api/health.get.ts ← DELETE before deploying
export default defineEventHandler(async () => {
try {
const { body } = await getApiRoot().get().execute()
return { ok: true, projectKey: body.key }
} catch (e) {
throw createError({ statusCode: 500, statusMessage: String(e) })
}
})
curl http://localhost:3000/api/health
# → {"ok":true,"projectKey":"your-project-key"}
Nuxt Stack — commercetools Storefront
Nuxt stack adapter. This is the Nuxt 4 implementation layer for thecommercetools-storefrontskill. That skill states every commercetools fact and B2C/B2B rule as a framework-neutral decision; this stack maps each one to Nuxt 4 + Nitro +@nuxtjs/i18nv10 +nuxt-auth-utils+ Pinia + Tailwind v4 primitives. Use it together with the skill'score/,b2c/, andb2b/references when the storefront's frontend is Nuxt.
When you read a rule in the generic skill phrased as "server-rendered data load", "server endpoint", "the framework's locale-aware link", or "the framework's server-side cache-with-TTL primitive", come here for the concrete Nuxt mapping.
The one rule that shapes everything
app/, runs on both server and client) and the Nitro server (server/, server-only). commercetools credentials live in Nitro and are reachable only from server/. The app never imports server/ code — the bundler would otherwise leak secrets to the browser.server/api/, and pages reach it with useFetch / useAsyncData / $fetch. During SSR an internal useFetch('/api/...') is a direct in-process call (no real network hop), so this costs nothing for server-rendered pages. This is the BFF boundary from the generic skill, realized in Nitro.Reference Index
| Task | Reference |
|---|---|
| Generic concept → Nuxt primitive lookup table; routing, navigation, page & store shapes | concept-mapping.md |
Project layout (app/, server/, shared/), nuxt.config.ts, i18n wiring, Tailwind v4, version gates, deploy | project-layout.md |
The Nuxt side of data loading: nuxt-auth-utils sealed session, Nitro route shape, defineCachedFunction, SSR/Pinia hydration, route params, health-check route | data-loading.md |
<NuxtImg> config, provider: 'none', domains, sizes, LCP preload | best-practices/image.md |
useSeoMeta / useHead, dynamic meta, title template, nuxt-og-image v6 | best-practices/metadata.md |
SSR/client boundary, <ClientOnly>, hydration safety, server-only code, useState vs module refs | best-practices/rendering.md |
createError, app/error.vue, <NuxtErrorBoundary>, navigateTo / sendRedirect, route middleware | best-practices/error-handling.md |
Commands
| Task | Command |
|---|---|
| Scaffold a new Nuxt 4 + commercetools storefront | Run /nuxtjs-setup-project |
/nuxtjs-setup-project verifies Node, creates the Nuxt 4 app, installs pinned dependencies, writes nuxt.config.ts (modules, Tailwind v4, image: { provider: 'none' }, i18n strategy: 'prefix', server-only runtimeConfig), lays down the app//server//shared/ structure with shared types/utils, the commercetools client, a generated NUXT_SESSION_PASSWORD, and a health-check route, then verifies the full chain. See project-layout.md for the resulting layout.Priority Tiers
CRITICAL
- Nuxt version — use
nuxt@^4. The app source lives underapp/(the defaultsrcDir); server code underserver/; isomorphic code undershared/. - Secrets never leave Nitro — commercetools client id/secret live in
runtimeConfig(server-only, nopublickey) and are read withuseRuntimeConfig(event)insideserver/. Never reference them from a component or apublicconfig key. @nuxtjs/i18nstrategy — usestrategy: 'prefix'so every route carries a locale prefix.- Session password —
nuxt-auth-utilsrequiresNUXT_SESSION_PASSWORD≥ 32 chars; set it as a real platform env var in production, never commit it.
HIGH
- Locale-aware links — build hrefs with
useLocalePath()(<NuxtLink :to="localePath('/cart')">), never a bare string path; the prefix would be lost. - No
$fetchinsetup()— bare$fetchin a component'ssetupruns twice (SSR + hydration). UseuseFetch/useAsyncDatafor initial data; reserve$fetchfor event handlers and Nitro routes / store actions. createError/navigateToare control flow —createError({ ..., fatal: true })throws; in route middlewarenavigateTo/abortNavigationmust be returned. See best-practices/error-handling.md.@nuxt/imagemust not transform CT URLs — setimage: { provider: 'none' }. The commercetools CDN rejects optimizer query params. See best-practices/image.md.
Anti-Patterns Quick Reference
| Anti-pattern | Correct approach |
|---|---|
Importing server/utils/ct/* into a page/component | Call it through a Nitro route via useFetch('/api/...') — never import server code into the app |
commercetools secret under runtimeConfig.public | Top-level runtimeConfig only (server-only); read with useRuntimeConfig(event) |
<NuxtLink to="/cart"> in localized UI | <NuxtLink :to="localePath('/cart')"> via useLocalePath() |
Bare $fetch('/api/...') in setup() | useFetch/useAsyncData (deduped, SSR-payload-hydrated) |
createError/navigateTo inside a swallowing try/catch (middleware) | return navigateTo(...) / return abortNavigation(...) at the top level |
Keeping image.provider as ipx for CT images | image: { provider: 'none' } — CT CDN 403s on ?w=&q= |
Module-level ref() for shared state | useState(key, init) or a Pinia store (request-isolated on the server) |
@nuxtjs/tailwindcss for Tailwind v4 | @tailwindcss/vite plugin + @import "tailwindcss" |
How this maps to the generic skill
- "server endpoint (BFF)" → Nitro route
server/api/<resource>.ts(defineEventHandler) - "server-rendered data load" →
useFetch/useAsyncDataagainst a Nitro route (SSR, payload-hydrated) - "client-fetched mutable state" → a Pinia store whose actions call
$fetch('/api/...') - "the framework's server-side cache-with-TTL primitive" →
defineCachedFunction/routeRulescache - "the framework's locale-aware link / client navigation" →
useLocalePath()+<NuxtLink>/navigateTo - "server-managed session" →
nuxt-auth-utilssealed encrypted cookie (stateless BFF)
apiRoot.*, the as-associate chain), the mappers, and getLocalizedString/formatMoney — are identical to the generic skill; only their location (<server>/ → server/utils/, shared/) and the render/state primitives around them are Nuxt-specific.Nuxt Project Layout
commercetools-storefront patterns assume when the framework is Nuxt. The /nuxtjs-setup-project command scaffolds all of it; this file documents the resulting layout and the load-bearing config so you can reason about it or repair a partial setup.Version gates (CRITICAL)
| Package | Required | Why |
|---|---|---|
nuxt | ^4 | App source under app/; shared/ for isomorphic code; Nitro server layer |
@nuxtjs/i18n | ^10 | Targets Nuxt 4 / Vue Router 5 / Vue I18n 11; the "restructure" file layout |
nuxt-auth-utils | ^0.5 (pin exact — pre-1.0) | Sealed-cookie sessions; @nuxt/kit ^4 |
@pinia/nuxt + pinia | ^0.11 + ^3 | Official Nuxt 4 state management |
@nuxt/image | ^2 | <NuxtImg>; the none provider for CT CDN pass-through |
@commercetools/platform-sdk | ^8 | Storefront SDK |
@commercetools/ts-client | ^4 | Token + middleware client |
tailwindcss + @tailwindcss/vite | ^4 | Tailwind v4 via the Vite plugin (not the legacy Nuxt module) |
Directory structure
app/ is the default srcDir (so ~/@ → app/); server/ and shared/ sit beside it at the project root (~~/@@ → project root).storefront/ # <root-dir> — project root
├── nuxt.config.ts # modules, runtimeConfig, i18n, image, nitro, vite
├── app/ # srcDir — the Vue app (server + client)
│ ├── app.vue # root component
│ ├── error.vue # root error page (see best-practices/error-handling.md)
│ ├── layouts/ # default.vue, etc.
│ ├── pages/ # file-based routing ([slug].vue, checkout/, etc.)
│ ├── components/{ui,layout,product}/ # auto-imported; OgImage/ for og-image templates
│ ├── composables/ # auto-imported client-side composables
│ ├── stores/ # Pinia stores (auto-imported)
│ ├── middleware/ # route middleware (auth.ts, *.global.ts)
│ ├── plugins/ # Nuxt plugins (init-session.ts)
│ └── assets/css/main.css # @import "tailwindcss"
├── server/ # <server> — Nitro, server-only (never imported by app)
│ ├── api/ # BFF routes (auth, account, cart, checkout, shipping-methods, channels)
│ └── utils/
│ ├── ct/ # server-only commercetools helpers + client.ts (getApiRoot)
│ ├── mappers/ # commercetools → app mappers
│ └── session.ts # thin nuxt-auth-utils helpers (optional)
├── shared/ # isomorphic — auto-imported in app AND server
│ ├── types/ # boundary types (Product, Cart, Session augmentation)
│ └── utils/ # COUNTRY_CONFIG, formatMoney, getLocalizedString
├── i18n/
│ ├── i18n.config.ts # Vue I18n options (fallbackLocale, formats)
│ └── locales/ # <locale>.json message catalogs
└── public/ # static files served at /
The boundary:app/may never import fromserver/. Secrets live only inserver/utils/ct/. Isomorphic helpers (types,COUNTRY_CONFIG, formatters) live inshared/— importable from both, but they must not import Vue or Nitro APIs.
Co-located with a Connect connector? If<root-dir>/(e.g.storefront/) is a sibling of aconnect.yamland its connector apps in the same repo, the storefront still deploys exactly as above — keep the platform's project root scoped to<root-dir>/so it ignores the connector code. For the monorepo layout and why the connector apps must be root siblings, see the commercetools-connect skill's monorepo-with-storefront.md.
@nuxtjs/i18n locale routing
nuxt.config.ts with strategy: 'prefix' so every route carries a locale prefix. Derive locales from COUNTRY_CONFIG (the isomorphic source of truth in shared/utils/):// nuxt.config.ts (excerpt)
import { COUNTRY_CONFIG } from './shared/utils'
// single source of truth — add a locale in COUNTRY_CONFIG and it flows here
const locales = Object.keys(COUNTRY_CONFIG).map((code) => ({
code,
language: code,
file: `${code}.json`,
}))
export default defineNuxtConfig({
modules: ['@nuxtjs/i18n', '@pinia/nuxt', '@nuxt/image', 'nuxt-auth-utils'],
i18n: {
strategy: 'prefix', // locale prefix on every route
defaultLocale: 'en-US',
lazy: true, // lazy-load message files
locales, // derived from COUNTRY_CONFIG above
// restructureDir defaults to 'i18n'; langDir defaults to 'locales'
// → message files resolve under i18n/locales/
},
})
// i18n/i18n.config.ts — non-message Vue I18n options
export default defineI18nConfig(() => ({
legacy: false,
fallbackLocale: DEFAULT_LOCALE.locale,
}))
useLocalePath() — <NuxtLink :to="localePath('/cart')">. For switching languages use useSwitchLocalePath(); for programmatic navigation navigateTo(localePath('/path')). Dynamic-segment pages that need slug-per-locale resolution set params with useSetI18nParams() so switchLocalePath resolves correctly.Tailwind v4
@tailwindcss/vite plugin (the current recommended path for Tailwind v4) — not the legacy @nuxtjs/tailwindcss module. No tailwind.config.js; theme tokens are declared in CSS via @theme:// nuxt.config.ts (excerpt)
import tailwindcss from '@tailwindcss/vite'
export default defineNuxtConfig({
css: ['~/assets/css/main.css'],
vite: { plugins: [tailwindcss()] },
})
/* app/assets/css/main.css */
@import "tailwindcss";
@theme {
--color-brand: #0a7cff;
}
@tailwind base/components/utilities directives — v4 uses the single @import "tailwindcss".Image config
none provider so @nuxt/image passes commercetools CDN URLs through untouched (the CDN 403s on optimizer query params), and allow the remote host via domains:// nuxt.config.ts (excerpt)
export default defineNuxtConfig({
image: {
provider: 'none', // never append ?w=&q=
domains: ['storage.googleapis.com'], // allow the CT CDN host
},
})
Deploy
Nitro auto-detects Vercel and Netlify when building in their CI, so zero config is often enough. To pin a target explicitly:
// nuxt.config.ts (excerpt)
export default defineNuxtConfig({
nitro: { preset: 'vercel' }, // or 'netlify'
})
npm run build produces the platform-ready output; npm run preview runs it locally. Env vars prefixed NUXT_ map onto runtimeConfig at runtime — set these in the platform dashboard (never commit them):| Env var | Maps to |
|---|---|
NUXT_SESSION_PASSWORD (≥ 32 chars) | nuxt-auth-utils sealed-cookie key |
NUXT_CT_PROJECT_KEY / NUXT_CT_CLIENT_ID / NUXT_CT_CLIENT_SECRET | runtimeConfig.ct* (server-only) |
NUXT_CT_AUTH_URL / NUXT_CT_API_URL | runtimeConfig.ctAuthUrl / ctApiUrl |
server/api/health.get.ts before deploying.