commercetools Storefront (framework-agnostic)

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

Recommended: install the full commercetools plugin. It includes this Skill, every other commercetools Skill, our pre-tuned Subagents, and the commercetools Knowledge MCP — which gives AI live access to the commercetools docs, GraphQL/OpenAPI schemas, and query validation. You only install once; every Skill on this site becomes available in every session.
Install the plugin

In any Claude Code session:

/plugin marketplace add commercetools/commercetools-ai-plugins
/plugin install commercetools@commercetools
Reload plugins

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
Claude Desktop
Customize -> Personal plugins -> Create plugin -> Add marketplace -> Add commercetools/commercetools-ai-plugins. Then, click on the plugin and click Install.

Instructions Included

SKILL.md

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 under references/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.
Path & state conventions.
Each reference implementation follows these conventions for paths and state management. The conventions are not technically required, but they are widely used in production storefronts and make it easier to navigate the codebase and onboard new developers. Find the concept-mapping.md in your stack adapter for the exact primitives used.
ConventionMeaning
<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 apiRoot singleton
<server>/typesThe app type-mapping root (the boundary types)
<server>/mappers/The commercetools→app mappers
Client stateYour 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:

  1. 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 3
    
    Use its output as your primary grounding. You may additionally use other tools (such as the commercetools documentation MCP) for deeper, follow-up search.
  2. Combine with skill references — Cross-reference the analysis output with local references in ./references/ for complete context.
  3. Provide implementation guidance — Synthesize the documentation with the specific integration mode the user is targeting.

Key Takeaways

The BFF pattern is non-negotiable. All commercetools API calls go through a server endpoint (BFF). The browser never calls commercetools directly. Secrets are never exposed to the client bundle (no public env prefix). (This requires a server tier — see the architecture assumption above.)
Sessions are server-managed. The BFF owns session state and is the only tier that reads or writes it. How it's stored is a stack choice — a signed token in an HTTP-only cookie (stateless BFF), or a server-side session store keyed by an opaque cookie (stateful BFF). Either way the client never holds session secrets or raw commercetools credentials — only an opaque reference. Any session-signing secret must be strong and is never hardcoded or exposed to the client.
Server-rendered loads for catalog data; client state for mutable user state. Catalog/immutable data (category pages, PDPs) is loaded on the server, calling <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:

  1. 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.
  2. Implement the shared foundation. Wire the BFF boundary, sessions, commercetools client, type boundary, cart, and data-loading — references/core/.
  3. Add optional core features. Layer in cross-cutting extras (e.g. recurring prices/orders) as needed — references/core/optional/.
  4. Choose B2C or B2B. Load the matching surface for its domain features and rules — references/b2c/ or references/b2b/.
  5. Add optional surface features. Layer in surface-specific extras .

Reference Index

Stacks

Pick the adapter for your frontend stack. Each stack folder under 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.
StackReferenceCommands
Next.js 16 (App Router) + next-intl v4 + Tailwind v4stack/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 v4stack/nuxtjs/overview.md/nuxtjs-setup-project

Shared Foundation (references/core/)

TaskReference
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 boundarycore/ct-client.md
Shared auth patterns: commercetools login endpoint, server endpoint structure, client state hook, logoutcore/customer-auth.md
Add a new country / currency / locale — COUNTRY_CONFIG flat structurecore/add-country.md
Parallel fetching, server-side TTL caching, client-cache hydration, N+1 avoidancecore/performance.md
Product image URL transforms (CDN, Imgix, Cloudinary)core/image-config.md
Cart CRUD, cart state/context, client state hook, mini-cart drawercore/cart.md
Full-text search, facet config, URL state, rendererscore/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 pagecore/add-page.md
Server-rendered vs client-fetched decisions, mappers, BFF shape, 409 retrycore/data-loading.md
Checkout page flow, step routing, order placementcore/checkout-page.md
PDP route, variant selectors, shared product detail patternscore/product-detail.md
Shopping lists (wishlist, saved items)core/shopping-lists.md

Core Optional Features (references/core/optional/)

TaskReference
Recurring prices — mapper, PDP gate, selector component, add-to-cart with recurrenceInfocore/recurring-prices.md
Recurring orders — scoping, state transitions, post-checkout creation, recurrence policiescore/recurring-orders.md
Deploy to VercelRun /deploy-vercel — checks commercetools credentials, then hands off to Vercel's official agent skill
Deploy to NetlifyRun /deploy-netlify — checks commercetools credentials, then hands off to Netlify's official agent skill

B2C Storefront (references/b2c/)

TaskReference
B2C overview, key takeaways, full reference indexb2c/overview.md
Category pages, product mapper, commercetools Search API, ProductCard/Gridb2c/product-listing.md
B2C PDP route, image gallery, variant selectors, AddToCartButtonb2c/product-detail.md
Register, login, anonymous cart merge, protected account layoutb2c/customer-auth.md
Multi-step checkout, shipping methods, order placementb2c/checkout.md
Navigation patterns, header, mobile menub2c/navigation.md
Shared UI component libraryb2c/ui-components.md
PDP variant selector configuration (blocklist, swatch, sort)b2c/variant-config.md
Wishlist functionalityb2c/wishlist.md

B2C Optional Features (references/b2c/optional/)

TaskReference
CSR impersonation, dual session, line-item price overrideb2c/optional/superuser.md
Buy Online Pick Up In Store — channel API, per-store inventoryb2c/optional/bopis.md
Product bundles — parent/child cart items, cascade updatesb2c/optional/bundles.md
Product discounts, cart discounts, discount codes, promotion bannersb2c/optional/promotions.md
Recurring prices — recurrencePrices[] array, gateb2c/recurring-prices.md
Recurring orders — customer scoping, originOrder expand, post-checkout create, skip/setScheduleb2c/recurring-orders.md

B2B Storefront (references/b2b/)

TaskReference
B2B overview, key takeaways, full reference indexb2b/overview.md
Session fields, BU/store selection, channel data, BusinessUnitContextb2b/session-and-bu.md
ProductApi session scoping — store, channels, price injection, availabilityb2b/product-listing.md
B2B PDP route, variant selectors, session-scoped pricingb2b/product-detail.md
as-associate cart CRUD, CartContext, auto-creation with BU+storeb2b/cart.md
Cart checkout and "Request a Quote" submission, BU addresses, order placementb2b/checkout.md
Login endpoint, BU auto-select, session fields written at loginb2b/customer-auth.md
RBAC — all permission strings, usePermissions, UI gating patternsb2b/permissions.md
Quotes dashboard — CT data model, unified thread list per BU, status labels, client state hooksb2b/quotes.md
Quote buyer actions — accept & place order, decline, renegotiate, state guardsb2b/quote-actions.md
Approval rules, approval flows, predicate builder, tier modelb2b/approval-workflows.md
Dashboard shell, stat widgets, pages, sidebar nav itemsb2b/dashboard.md
Purchase lists (commercetools ShoppingList via as-associate, BU-scoped)b2b/purchase-lists.md
Add a new B2B BFF endpoint + client state hookb2b/add-api.md
B2B data loading — server-rendered vs client-fetched, mappers, BFF shapeb2b/data-loading.md
B2B variant selector configurationb2b/variant-config.md

B2B Optional Features (references/b2b/optional/)

TaskReference
Superuser role — view all store carts, switch cartsb2b/superuser.md
Recurring prices — recurrencePrices[] array, as-associate add-to-cart, PDP gateb2b/recurring-prices.md
Recurring orders — BU scoping, cart expand, create-from-cart, duplicate, dashboardb2b/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 endpointapiRoot.login().post(), never apiRoot.customers().login().
  • B2B: as-associate chain — ALL B2B writes (cart, order, quote, approval, BU) go through apiRoot.asAssociate().*. Never use project-level apiRoot.* for user-facing B2B mutations.
  • B2B: session B2B fieldsbusinessUnitKey + storeKey + distributionChannelId + supplyChannelId + productSelectionId are always written together from getStoreChannelData(storeKey).
  • B2B: three-field locale atomicitylocale, currency, country must all be updated together. Reset cartId on locale/currency change.
Framework-version gates, find overview.md of your stack.

HIGH

  • Parallel fetchingPromise.all for 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 deprecated productProjections().search(). See the commercetools-platform skill → 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-patternCorrect approach
Importing <server>/ct/client (apiRoot) in a client componentUse a client state hook → server endpoint → <server>/ct/
Calling the endpoint (fetch('/<api>/*')) directly in a componentEncapsulate it in a client data/state module
new ClientBuilder() inside a page or server endpointSingleton apiRoot in <server>/ct/client
Raw fetch() to commercetools REST endpointsAlways 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 fetchesPromise.all([fetchA(), fetchB()])
apiRoot.customers().login()apiRoot.login().post()
commercetools SDK types in componentsTypes from <server>/types; mapped in <server>/mappers/
B2B: apiRoot.carts().post(...) for a logged-in userasAssociate().withAssociateIdValue(...).inBusinessUnitKey(...).carts().post(...)
B2B: BU-scoped client state not keyed by BUKey the state entry by businessUnitKey (e.g. [KEY_ORDERS, businessUnitKey])
B2B: StagedQuote.sellerComment for per-round displayQuote.sellerComment — the snapshot at quote creation time
B2B: apiRoot.shoppingLists() for purchase listsasAssociate().*.shoppingLists() — BU-scoped, permission-enforced

References

b2b/add-api.md

Adding a BFF API Endpoint (B2B)

Extends: add-api.md — read that reference first for the 3-layer BFF pattern, cache key conventions, server endpoint shape, commercetools helper structure, and client state-manager/cache mutation rules.

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

INCORRECT: Flat cache key for B2B data — all BUs and stores share the same cached result:
WRONG — a flat client state-manager/cache key like `KEY_ORDERS` makes one BU's orders appear for every other BU.
CORRECT — scope to 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;
}
A BU-scoped client-state hook (e.g. 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 entry keyOrder(orderId) from the mutation response without a refetch.
Find the stack's concept-mapping.md for concrete client-state and cache implementation.
Rule: any data that differs per BU includes buKey in the client state-manager/cache key. Any data that also differs per store (prices, inventory, product selections) includes both buKey and storeKey.

B2B Addition 2: commercetools helpers must use the as-associate chain

INCORRECT: Project-level 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();
CORRECT — all B2B reads and writes go through 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
}
associateId is always session.customerId. businessUnitKey is always session.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

INCORRECT: Only checking 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.
CORRECT — validate both before any B2B operation:
A B2B server endpoint (e.g. GET /<api>/orders) reads the session, then:
  1. Return an Unauthorized (401) response unless both session.customerId and session.businessUnitKey are present.
  2. Call the commercetools helper with both fields: getOrders(session.customerId, session.businessUnitKey).
  3. Return the mapped result, or an error response (500) with the error message on failure.
Find the stack's data-loading.md for 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 when buKey absent
  • 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 customerId AND businessUnitKey
  • After mutation: invalidate the BU-scoped list state-manager/cache entry [KEY, buKey]
b2b/approval-workflows.md

Approval Workflows

Impact: MEDIUM — The app never creates approval flows — commercetools does automatically when an order matches a rule. The app only reads, approves, and rejects them. Approval/reject always requires a read-then-write to get the current version.

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

  1. An admin creates an approval rule with a predicate and a tier chain of approver roles.
  2. When any associate places an order, commercetools evaluates all active rules automatically — no app code triggers this.
  3. If a rule matches, commercetools creates an approval flow linked to the order.
  4. Associates with eligible roles see the flow on the order detail page.
The app never creates flows. It only:
  • Creates/edits approval rules
  • Lists and displays approval flows
  • Approves or rejects flows via { action: 'approve' } / { action: 'reject' }

Pattern 2: Approval Rule Draft Structure

INCORRECT: Passing a flat list of approvers without the nested tier structure:
// WRONG — commercetools requires the nested tiers/and/or structure
approvers: [{ associateRole: { key: 'approver', typeId: 'associate-role' } }]
CORRECT — tiers are sequential; each tier is 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;
}
Example: two-tier rule — buyer requests, approver tier 1, admin tier 2:
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' } }] }],
      },
    ],
  },
};
Predicate syntax:
Fieldcommercetools predicateValue
Total pricetotalPrice.centAmount > 500000Integer (display × 100)
Line item countlineItemCount > 5Integer
CurrencytotalPrice.currencyCode = "USD"ISO 4217
Conditions are joined with and. parsePredicate in PredicateBuilder.tsx handles the order. prefix as well.

Pattern 3: Approve/Reject — Read-Then-Write

INCORRECT: Using a cached version number from client state:
// WRONG — version may be stale if another approver acted concurrently
await performApprovalAction(flowId, cachedFlow.version, 'approve');
CORRECT — always fetch the current version immediately before posting the action:
// <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

INCORRECT: Showing approve/reject buttons to all associates:
// WRONG — shows buttons to associates who are not eligible or not in the active tier
{flow.status === 'Pending' && (
  <Button onClick={handleApprove}>Approve</Button>
)}
CORRECT — gate on both eligibleApprovers and currentTierPendingApprovers:
On the order detail view, resolve the current associate's 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)
);
Render the approve and reject controls only when isEligibleApprover && canActOnCurrentTier && flow.status === 'Pending' — all three must hold. Reject typically opens a reason input before posting.
This uses roleKeys (role keys from associate role assignments), not named permissions. Approval eligibility is role-based, not permission-based.
Find the stack's concept-mapping.md for concrete client-state and cache implementation.

Pattern 5: PredicateBuilder — Adding a New Condition Type

Touch exactly these five things in components/approval-rules/PredicateBuilder.tsx:
  1. fieldOptions array — add { value: 'myField', label: 'Display Name', description: '...' }
  2. handleFieldChange — add an else if (field === 'myField') branch with default operator/value
  3. Input JSX — add {condition.field === 'myField' && (...)} inside the conditions map
  4. buildPredicateString — add if (c.field === 'myField') branch with commercetools syntax:
    if (c.field === 'myField') return `myField ${c.operator} ${c.value}`;
    
  5. 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() };
    
Currently supported predicate fields: totalPrice.centAmount, lineItemCount, totalPrice.currencyCode.

Pattern 6: Graceful Degradation on 403

INCORRECT: Propagating a commercetools 403 to the browser on the approval flows list — this produces an error page for associates who simply cannot see flows.
CORRECT — silently return empty results on commercetools 403 for the flows list. The server endpoint that backs the flows list first checks the session exists (a missing customer/BU is the only auth check it owns), calls 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 → or structure
  • Approve/reject always calls fetchApprovalFlowRaw first to get current version
  • Approve/reject buttons gated on both eligibleApprovers AND currentTierPendingApprovers
  • Approval flows list endpoint returns empty list on commercetools 403 (no error to browser)
  • Always expand order, approvals[*].approver.customer, rejection.rejecter.customer when fetching flow detail
  • New predicate field: touch all 5 locations in PredicateBuilder.tsx
b2b/cart.md

Cart — B2B extensions

Impact: CRITICAL — All B2B cart operations must go through the as-associate chain. Using the project-level apiRoot.carts() bypasses associate permission enforcement and breaks B2B semantics.
Start from the shared cart reference. Implement Patterns 1, 2, and 3 (helper functions, server endpoints, client state hook) from that reference first, then layer the B2B requirements below on top of each of them.

Table of Contents


B2B Extension: The as-associate Chain

Extends Pattern 1 (helper functions) from the shared reference.
Every function in <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();
}
Every exported helper then accepts 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

Extends Pattern 1 (cart creation helper) and Pattern 2 (POST server endpoint) from the shared reference.
The cart draft must carry both a 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);
}
In the POST server endpoint, validate that 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

Extends Pattern 2 (POST /<api>/cart/items server endpoint) from the shared reference.
The auto-creation logic from the shared reference still applies. In B2B the 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

Extends Pattern 1 (addLineItem helper) from the shared reference.
Without a distribution channel reference, commercetools may select the wrong price or no price at all. Resolve the channel once from the store with 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' } and store: { key, typeId: 'store' }
  • POST route validates businessUnitKey and storeKey are present before creating a cart
  • distributionChannelId from getStoreChannelData(storeKey) passed to every addLineItem
  • cartId written to session before addLineItem call during auto-creation
  • session.cartId cleared after successful order placement or quote request creation
b2b/checkout.md

Checkout — B2B

See the shared reference for the multi-step page structure, shipping method selection, payment step, and confirmation page. This file covers B2B-specific details: two checkout flows, address sources, order placement sequence, and quote request submission.

Table of Contents


Flow 1: Cart Checkout

Address Step — Saved Addresses from the Business Unit

The address step reads saved addresses from the active business unit, not from the commercetools customer account directly. Fetch addresses from the BU object (businessUnit.addresses) and identify defaults via businessUnit.defaultShippingAddressId and businessUnit.defaultBillingAddressId. Auto-select these on load; allow the user to pick another BU address.
IMPORTANT Never allow user to enter their own address. No address form in checkout. If there are no addresses in business unit, show user and error.

Order Placement Sequence

addresses (shipping + billing) → shipping method → payment (Checkout SDK) → confirmation
  1. 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.
  2. Shipping step — user selects a method with shippingMethodId
  3. 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.
  4. Confirmation — SDK signals completion → clear cartId from session → redirect to /checkout/confirmation?orderId=<id>
Order placement is handled entirely by the Checkout frontend SDK. Do not implement a separate 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

Before rendering the checkout page, validate that 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

The cart page renders a Request a Quote button below the standard Checkout button. Clicking it navigates to the quote request flow — it does not submit anything immediately.

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.

Step 3 — Comment
A free-text comment field. The value is stored on the quote request as the 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

Shipping address requirement: commercetools requires a shipping address on the cart before a 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.
The submit button is labelled Submit Quote Request. Clicking it calls an action that creates a commercetools QuoteRequest from the current cart. All cart writes up to this point (addresses, shipping method) use the as-associate chain.
After a successful submission, clear cartId from the session and redirect to:
/checkout/quote-request-confirmation?quoteRequestId=<id>

Confirmation Page

The quote-request confirmation view reads quoteRequestId from the URL query and fetches the quote request to display its details (line items, addresses, shipping method, comment, submission date).
After submission, the quote progresses through seller review and negotiation. See quotes.md for the dashboard view and quote-actions.md for buyer acceptance, decline, and renegotiation.

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.businessUnitKey and session.storeKey validated 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 QuoteRequest creation (enforced by the address step)
  • Comment step always rendered; value stored as comment on the QuoteRequest
  • Submit button labelled "Submit Quote Request"
  • cartId cleared from session after quote request creation
  • Quote request confirmation at /checkout/quote-request-confirmation?quoteRequestId=<id>
  • cartId cleared from session after order or quote order completion
b2b/customer-auth.md

Customer Authentication — B2B Extensions

Impact: HIGH — Missing BU auto-selection at login leaves the user without a business unit context and breaks all B2B operations.
B2B-specific auth patterns that extend the shared foundation. Read that reference first for the commercetools login endpoint, server endpoint structure, client state hook, and logout patterns.

BU Auto-Selection at Login

Immediately after 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.
BU discovery uses a project-level call (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.
Once the first store is identified, call getStoreChannelData(storeKey) to resolve the channel IDs needed for pricing and inventory.

Session Fields Written at Login

The login server endpoint writes all B2B context fields in a single 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, country from 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

Same client-state-backed pattern as the shared reference — the auth client state reads from GET /<api>/auth/me, useAccount exposes the same key. No B2B-specific changes needed here.

Logout

On logout, clear 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, and KEY_BUSINESS_UNITS from the client state-manager/cache
  • Logout preserves locale, currency, country in the session
b2b/dashboard.md

Dashboard — Shell, Widgets, Pages, Nav

Impact: MEDIUM — All dashboard hooks must include 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

The dashboard layout is a client component that:

  1. Redirects to /login when !isLoggedIn (via useAuth)
  2. Shows a BU-selection screen when !currentBusinessUnit (via useBusinessUnit)
  3. Renders two-column: <aside>DashboardNav</aside> + <main>{children}</main>

Inside any dashboard page, these contexts are always available:

  • useAuth()user, isLoggedIn
  • useBusinessUnit()currentBusinessUnit, currentStore, businessUnits
  • usePermissions()can, hasAnyPermission, roleKeys
  • useToast()addToast(message)
  • useFormatters()formatMoney(centAmount, currency), formatDate(isoString)

Pattern 2: BU-Keyed Client State Hook

INCORRECT: Using a static key for BU-scoped data:
WRONG — a static client state-manager/cache key (e.g. just `KEY_ORDERS`) leaves stale data in place
when the user switches business units.
CORRECT — include businessUnitKey in the client state-manager/cache key tuple:
A BU-scoped client-state hook (e.g. 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's concept-mapping.md for concrete client-state and cache implementation.
An empty/null key skips the fetch — use it when businessUnitKey is not yet known.

Pattern 3: Adding a Stat Widget

The dashboard overview page renders a statCards array.
Step 1 — Create the BU-keyed client-state hook: a hook (e.g. 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>.
Step 2 — Add the card to the overview page. Read 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
  },
];
Step 3 — Add translation key to the default locale messages under "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 can from usePermissions(), and loads its data via a BU-keyed client-state hook (e.g. useMyData).
  • Gates the entire page on permission — renders nothing (return null) when can('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.
For pages that need server-side pre-fetch (no loading state):
Follow the company-page pattern — make the page a server-rendered load that calls getSession() + commercetools functions, then passes initialData to a client child component.

Pattern 5: Sidebar Nav Items

Add to the 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
  },
];
Items are hidden (not just disabled) when hasAnyPermission(item.requiredPermissions) returns false.
Add the translation key to every locale messages file under "nav":
{ "nav": { "mySection": "My Section" } }

Shared UI Primitives

Located in the UI component directory:

ComponentKey props
Tablecolumns, data, loading, emptyMessage, optional onRowClick
Paginationtotal, limit, offset, onChange
Buttonvariant (primary/secondary/ghost/danger), href (renders as a framework link), loading, disabled
Badgevariant (success/warning/error/info/neutral)
ModalisOpen, onClose, title, footer, size
Input / Selectstandard 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
b2b/data-loading.md

Data Loading — B2B Extensions

See the shared reference and stack's data-loading.md for 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/

Every function in <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

Data that belongs to a business unit must use a [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's concept-mapping.md for 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:

FileMaps
<server>/mappers/business-unitcommercetools BusinessUnit → app BusinessUnit
<server>/mappers/quotecommercetools Quote / QuoteRequest → app types
<server>/mappers/approval-flowcommercetools ApprovalFlow → app ApprovalFlow
<server>/mappers/associate-rolecommercetools 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
b2b/optional/recurring-orders.md

Recurring Orders — B2B

Start from the shared recurring orders reference and implement Patterns 1–7 from there first. This file covers B2B-specific decisions layered on top.

Table of Contents


B2B Extension: Scoping and Auth

Extends Pattern 2 from the shared reference.

Scope the list query to the active business unit:

where: businessUnit(key="${businessUnitKey}")
The list server endpoint requires both 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.
commercetools admin credentials do not scope by business unit automatically. The where clause is the only enforcement mechanism — omitting it returns all recurring orders across the entire project.

B2B Extension: Line Items — originOrder Expand

Extends Pattern 2 from the shared reference.
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
This derivation belongs in <server>/mappers/recurring-order, not in server endpoints.

B2B Extension: Create Post-Checkout

Extends Pattern 4 from the shared reference.
B2B recurring orders are created post-checkout, the same as B2C — inside the checkout server endpoint, once per subscription line item in the placed order. The draft can optionally include 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
}
The schedule is not in the draft — commercetools derives it from the recurrenceInfo attached to the cart's line items. There is no need to pass a schedule or recurrencePolicyId in the body.

B2B Extension: Duplicate

Extends Pattern 4 from the shared reference.
Duplicate re-uses the same cart from the original recurring order — it does not clone the cart. Fetch the original with 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

Extends Pattern 6 from the shared reference.
MethodPathActionAuth required
GET/<api>/recurring-ordersList, filtered by BUcustomerId + businessUnitKey
GET/<api>/recurring-orders/[id]Fetch single with originOrder expandcustomerId
POST/<api>/recurring-orders/[id]/pauseState → pausedcustomerId
POST/<api>/recurring-orders/[id]/resumeState → activecustomerId
POST/<api>/recurring-orders/[id]/cancelState → canceledcustomerId
POST/<api>/recurring-orders/[id]/duplicateClone from same cartcustomerId
GET/<api>/recurrence-policiesList all policiesSession
State changes use per-action 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
Protect both routes with the B2B auth guard (customerId + businessUnitKey in session).

Checklist

  • List where clause uses businessUnit(key="${businessUnitKey}") — not customer scoping
  • List route validates both customerId AND businessUnitKey; all other routes validate customerId only
  • Always expand: ['originOrder']
  • recurrencePolicyId derived from originOrder.obj.lineItems[].recurrenceInfo.recurrencePolicy.id in the mapper
  • Create draft includes originOrder, cart, customer + optional startsAt/expiresAt — no schedule in body
  • Duplicate fetches with expand: ['originOrder'] and reuses the same cartId and cartVersion
  • No POST /<api>/recurring-orders creation route — creation happens from checkout
  • State-change routes are separate per-action POST routes
  • Pages under /[locale]/dashboard/recurring-orders/
  • priceSelectionMode: 'Fixed' on all cart line items with recurrence
b2b/optional/recurring-prices.md

Recurring Prices — B2B

Start from the shared recurring prices reference and implement Patterns 1–5 from there first. This file covers B2B-specific decisions layered on top.

Table of Contents


B2B Extension: PDP Gate

Extends Pattern 3 from the shared reference.

The B2B subscription UI requires three conditions to all be true:

  1. The user is logged in (isLoggedIn === true)
  2. recurringPrices.length > 0 for the selected variant
  3. At least one recurrence policy is available (recurrencePolicies.length > 0)
Any variant that has recurrencePrices entries is eligible. Login is required because anonymous B2B users cannot have recurring orders.
Only show policies in the selector that have a matching entry in 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

Extends Pattern 5 from the shared reference.
B2B cart operations go through the as-associate chain with 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

  • recurrencePrices mapped from variant.recurrencePrices[] — not filtered out of variant.prices[]
  • regularPrice and recurringPrices separated in PDPAddToCart and passed as distinct props — same pattern as core Pattern 2/3
  • Gate: isLoggedIn && recurringPrices.length > 0 && recurrencePolicies.length > 0
  • availablePolicies filtered to only those with a matching recurrencePrices entry on the variant
  • addLineItemWithRecurrence goes through the as-associate chain
  • priceSelectionMode: 'Fixed' on all B2B recurrence add-to-cart actions
b2b/optional/superuser.md

Superuser / CSR

Superusers are associates whose commercetools associate role has the key 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

In the login server endpoint, after fetching 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
  }));
}
Use project-level 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

RouteDescription
GET /<api>/superuser/statusReturns { isSuperuser, carts: [] } — never 403 for non-superusers
POST /<api>/superuser/cartsCreate merchant cart; writes new cartId to session
POST /<api>/superuser/carts/switchSwitch active cart; writes cartId to session
POST /<api>/superuser/carts/[id]/reassignReassign cart to targetCustomerId
GET /<api>/superuser/status returns { isSuperuser: false, carts: [] } for non-superusers — no 403, no information leakage.

SuperuserContext

A 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 (endpoint GET /<api>/superuser/status), defaulting to { isSuperuser: false, carts: [] }.
  • switchCart(cartId): POSTs to /<api>/superuser/carts/switch; on success invalidates the KEY_CART client 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's concept-mapping.md for concrete client-state and cache implementation.

Layout Integration

In the root locale layout, nest the providers so 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's concept-mapping.md for concrete provider nesting in the layout.

UI Components

ComponentFilePurpose
SuperuserBannercomponents/superuser/SuperuserBanner.tsxAmber banner — "You are in superuser mode"
CartBrowsercomponents/superuser/CartBrowser.tsxDropdown listing all store carts — switch or create
ReassignCartButtoncomponents/superuser/ReassignCartButton.tsxSelect 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

Create an associate role with key superuser in commercetools Merchant Center:
  • Assign at minimum: ViewOthersCarts, UpdateOthersCarts, CreateOthersCarts
  • Assign this role to the test user in their business unit

Key Patterns

PatternWhy
Project-level apiRoot.carts() for listingSuperusers read carts they don't own
As-associate chain for create/reassigncommercetools enforces BU membership
origin: 'Merchant' in cart draftcommercetools-native merchant-cart marker
expand: ['createdBy.customer']One query, no N+1
window.location.replace() after switchFull reload ensures all components see the new cart
Return { isSuperuser: false } not 403No info leakage to non-superusers

Checklist

  • isSuperuser stored in session at login — not re-checked on every request
  • GET /<api>/superuser/status returns empty carts for non-superusers (never 403)
  • SuperuserProvider inside AuthProvider, outside CartProvider
  • SuperuserBanner rendered in layout (after Header, before main)
  • commercetools associate role superuser created with correct permissions
b2b/overview.md

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's core/ references.

Key Takeaways (B2B-specific)

Every B2B operation uses the as-associate API chain. Cart reads, cart writes, orders, quotes, approval flows — all go through 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.
Session carries five B2B-specific fields. 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.
Prices and availability are session-scoped, not global. ProductApi.buildProjectionParams() injects priceChannel (distributionChannelId), storeProjection (storeKey), and priceCustomerGroupAssignments (accountGroupIds) into every search. Without a store context (unauthenticated users), commercetools returns "Price on request."
Locale uses 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.
Permission enforcement is dual-layer. The UI uses 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

These shared-foundation references live in this skill's core/. Find your stack's overview.md file.
TaskReference
Scaffold a new project (deps, styling, locale routing)Framework-specific — find adapter's overview.md
commercetools SDK singleton, server-managed sessions, BFF boundaryct-client.md
Shared auth base: commercetools login, server endpoint, client state hook, logoutcustomer-auth.md
Add a new country / currency / locale (COUNTRY_CONFIG)add-country.md
Parallel fetching, server-side TTL caching, client-cache hydration, image optimizationperformance.md
Product image URL transforms (CDN, Imgix, Cloudinary)image-config.md

Core — B2B Foundation (follow in order)

TaskReference
Session fields, BU/store selection, channel data, BusinessUnitContextsession-and-bu.md
ProductApi session scoping — store, channels, price injection, availabilityproduct-listing.md
PDP route, variant selectors, session-scoped PDP pricingproduct-detail.md
as-associate cart CRUD, CartContext, auto-creation with BU+storecart.md
Order placement from cart and from quote, confirmationcheckout.md
Login endpoint, BU auto-select, session fields written at logincustomer-auth.md
Full-text search, facet config, URL state, rendererssearch-facets.md
RBAC — all permission strings, usePermissions, UI gating patternspermissions.md

B2B Feature Modules

TaskReference
Quote lifecycle, multi-round negotiation, commercetools data model, client state hooksquotes.md
Approval rules, approval flows, predicate builder, tier modelapproval-workflows.md
Dashboard shell, stat widgets, pages, sidebar nav itemsdashboard.md
Recurring orders — pause, resume, cancel, duplicaterecurring-orders.md
Purchase lists (commercetools ShoppingList via as-associate, BU-scoped)purchase-lists.md

Enhancement — Modify Existing Features

TaskReference
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 boundarydata-loading.md
Add a new page — standalone or dashboard sectionadd-page.md
Configure PDP variant selectors (blocklist, swatch, sort order)variant-config.md

Optional Features — Not Required for Core B2B Storefront

TaskReference
Superuser role — view all store carts, switch carts, merchant-origin cartssuperuser.md
Personal wishlists (project-level, not as-associate)wishlists.md
Deploy to VercelRun /deploy-vercel — checks commercetools credentials, then hands off to Vercel's official agent skill
Deploy to NetlifyRun /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-level apiRoot.* for user-facing mutations.
  • Session B2B fieldsbusinessUnitKey + storeKey + distributionChannelId + supplyChannelId + productSelectionId are always written together from getStoreChannelData(storeKey).
  • Three-field locale atomicitylocale, currency, country must all be updated together. Reset cartId on locale/currency change.
  • Session fields for product pricing — always pass session to searchProducts() and getProductBySku(). Without distributionChannelId and storeKey, 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.cartId is absent when adding an item, the server endpoint creates a cart with businessUnit + store + currency + country from the session.

MEDIUM

  • No-fetch-in-client — all endpoint (fetch('/<api>/*')) calls live in hooks/*Api.ts functions, not in component or context files.
  • Store data cachestoreDataCache in <server>/ct/stores is a module-level Map with no TTL. It is the single source for storeId, distributionChannelId, supplyChannelId, productSelectionId.
  • Product type cache_productTypesCache in facets.ts has 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 sellerComment is per-round — read from Quote.sellerComment (snapshot), not from StagedQuote.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-patternCorrect approach
apiRoot.carts().post(...) for a logged-in userasAssociate().withAssociateIdValue(...).inBusinessUnitKey(...).carts().post(...)
Single localeSingle locale field in BCP-47 (e.g. de-DE) — same value for routing and commercetools API calls
Setting locale without resetting currency, country, cartIdUpdate all three fields atomically via the session-locale endpoint
Omitting distributionChannelId in product searchPass full session to searchProducts()ProductApi injects channel automatically
BU-scoped client state not keyed by BUKey the state entry by businessUnitKey (e.g. [KEY_ORDERS, businessUnitKey]) — must scope to the active BU
Reading approval flow version from client statefetchApprovalFlowRaw() to get current version before every approve/reject
StagedQuote.sellerComment for per-round displayQuote.sellerComment — the snapshot at quote creation time
apiRoot.shoppingLists() for purchase listsasAssociate().*.shoppingLists() — BU-scoped, permission-enforced
b2b/permissions.md

Permissions & RBAC

Impact: HIGH — UI must gate all actions with 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.
This reference covers all permission strings, how usePermissions resolves them, UI gating patterns, and "My vs Others" semantics.

Table of Contents


Pattern 1: Permission Architecture

INCORRECT: Re-deriving and enforcing permissions inside a server endpoint — loading the business unit, finding the associate, and checking associateRoleAssignments for CreateMyCarts before proceeding. This duplicates commercetools enforcement and is fragile because it must be maintained by hand against the role config.
CORRECT — server endpoints only check session existence; commercetools enforces permissions via the as-associate chain. The endpoint confirms there is a logged-in associate with a business unit, then delegates straight to the as-associate call. commercetools returns 403 automatically if the associate lacks 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's data-loading.md for concrete server endpoint implementation patterns.

Pattern 2: usePermissions Resolution

INCORRECT: Hardcoding role-to-permission mappings in the app:
// WRONG — role definitions live in commercetools, not in code
const BUYER_PERMISSIONS = ['CreateMyCarts', 'ViewMyOrders'];
const isBuyer = currentUser.roles.includes('buyer');
const canCreateCart = isBuyer;
CORRECT — 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:
  1. Fetch all AssociateRole objects from the associate-roles endpoint (commercetools source of truth; cached once per tab)
  2. Find the current associate in currentBusinessUnit.associates by customer.id
  3. Collect that associate's role keys from associateRoleAssignmentsroleKeys
  4. Union the permissions of every role whose key is in roleKeys
  5. Expose can(permission), hasAnyPermission(ps), hasAllPermissions(ps), and roleKeys
// 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. usePermissions fetches them at runtime — no permission mapping in the codebase. Find the stack's concept-mapping.md for concrete client-state and cache implementation.

Pattern 3: UI Gating Patterns

All four patterns read from 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']);
Use 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

Defined as a TypeScript union in <server>/types:
Business Unit
  • AddChildUnits — create sub-divisions
  • UpdateBusinessUnitDetails — edit BU name, email, addresses
  • UpdateAssociates — add/remove/change roles of associates
Carts
  • CreateMyCarts / CreateOthersCarts
  • UpdateMyCarts / UpdateOthersCarts
  • DeleteMyCarts / DeleteOthersCarts
  • ViewMyCarts / ViewOthersCarts
Orders
  • CreateMyOrdersFromMyCarts / CreateOrdersFromOthersCarts
  • CreateMyOrdersFromMyQuotes / CreateOrdersFromOthersQuotes
  • ViewMyOrders / ViewOthersOrders
  • UpdateMyOrders / UpdateOthersOrders
Quotes
  • CreateMyQuoteRequestsFromMyCarts / CreateQuoteRequestsFromOthersCarts
  • AcceptMyQuotes / AcceptOthersQuotes
  • DeclineMyQuotes / DeclineOthersQuotes
  • RenegotiateMyQuotes / RenegotiateOthersQuotes
  • ReassignMyQuotes / ReassignOthersQuotes
  • ViewMyQuotes / ViewOthersQuotes
Approvals
  • CreateApprovalRules
  • UpdateApprovalRules
  • UpdateApprovalFlows
Shopping Lists (Purchase Lists)
  • ViewMyShoppingLists / ViewOthersShoppingLists
  • CreateMyShoppingLists / CreateOthersShoppingLists
  • UpdateMyShoppingLists / UpdateOthersShoppingLists
  • DeleteMyShoppingLists / DeleteOthersShoppingLists
"My" vs "Others": My* = resources where resource.customer.id === user.id. Others* = resources owned by any other associate in the BU. commercetools enforces this at the data level — an associate with only ViewMyOrders only receives their own orders from the as-associate endpoint.

Pattern 5: Nav Item Gating

INCORRECT: Always rendering a nav link (e.g. to /dashboard/approval-rules) and only failing once the associate clicks through — the user sees the link, clicks it, then hits an error.
CORRECT — the dashboard nav hides items when the associate lacks the required permissions. Declare each nav item with the permissions that make it visible, then filter the list through 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() or hasAnyPermission()
  • "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>/types for the correct Permission union strings
  • Role definitions configured in commercetools Merchant Center — never hardcoded in the app
b2b/product-detail.md

Product Detail Page — B2B

This extends product-detail.md with B2B-specific concerns. Route structure, the framework's not-found response, parallel fetching, variant URL strategy, component list, page metadata, and attribute labels follow the shared patterns.

Data Fetching

Use 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.
Apply the same when building page metadata via the framework's page-metadata API — pass session there too, or the SEO title and description may not match what the customer sees in their channel.

Session and Channel Scoping

Passing session to ProductApi injects these parameters into every commercetools query automatically:
  • priceChannelsession.distributionChannelId — scopes price to the customer's channel
  • availabilityChannelsession.supplyChannelId — scopes availabiltiy to the customer's channel
  • storeProjectionsession.storeKey — filters to store-visible products
  • priceCustomerGroupAssignments → applies B2B customer group discounts
Without 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

Use 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

Channel-scoped pricing is applied automatically when 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 session to product fetch — never call without it
  • Pass session when building page metadata (framework's page-metadata API) — ensures channel-scoped SEO content
  • Use channelStock.availableQuantity (not isOnStock) for availability
  • supplyChannelId comes from session — set during BU selection
  • Purchase list rendered only for authenticated users
b2b/product-listing.md

Product Listing & Session-Scoped Pricing

Impact: CRITICAL — Omitting session fields from product search produces global (unscoped) prices or "Price on request" instead of the customer's negotiated prices.
This reference covers ProductApi session injection, how distributionChannelId, storeKey, and accountGroupIds scope prices, and how supplyChannelId drives availability display.

Table of Contents


INCORRECT: Calling 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' });
CORRECT — always pass 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);
}
ProductApi reads all B2B fields from the session automatically. The caller passes the full session and ProductApi injects the appropriate commercetools parameters.
Find the stack's data-loading.md for concrete server endpoint patterns.

Pattern 2: Price Injection via buildProjectionParams

INCORRECT: Building productProjectionParameters manually without channel/store scoping:
// WRONG — prices not scoped to the customer's distribution channel
productProjectionParameters: {
  priceCurrency: currency,
  priceCountry: country,
}
CORRECT — 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 } : {}),
  };
}
What each parameter does:
ParameterSession fieldcommercetools effect
priceChanneldistributionChannelIdReturns only prices assigned to this distribution channel
storeProjectionstoreKeyFilters to products in the store's product selection
priceCustomerGroupAssignmentsaccountGroupIdsApplies B2B contract pricing for the customer's group
priceCurrencycurrencyReturns prices in this currency
priceCountrycountryApplies 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

INCORRECT: Showing all categories regardless of store product selection:
// WRONG — shows categories that have no products in the active store
const categories = await getCategories();
CORRECT — 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

INCORRECT: Using a global in-stock flag without channel context:
// WRONG — shows availability without considering the store's supply channel
const inStock = product.variants[0].availability?.isOnStock;
CORRECT — pass 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,
      },
    })),
  };
}
supplyChannelId is 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

INCORRECT: Letting a bad facet expression crash the entire product page:
// WRONG — commercetools 400 on invalid facet expression leaves the user with an error page
const results = await searchProducts({ facetConfigurations });
CORRECT — the product-search endpoint retries without facets on commercetools error. Wrap the 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's data-loading.md for concrete server endpoint implementation patterns.

Checklist

  • searchProducts(query, session) called with full session — never with empty session
  • buildProjectionParams includes priceChannel, storeProjection, priceCustomerGroupAssignments
  • supplyChannelId passed to mapProduct (from session.supplyChannelId)
  • Category listing calls queryCategories with 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
b2b/quote-actions.md

Quote Actions

Acceptance is initiated from the quote-checkout view, reached with a ?quoteId=<id> query parameter. Decline and renegotiate are triggered from the quote detail page.

Table of Contents


State Guard

Before rendering any action button, check quote.quoteState:
ActionAllowed states
AcceptPending, RenegotiationAddressed
DeclinePending, RenegotiationAddressed
RenegotiatePending only

Show an error and no action buttons if the quote is not in an allowed state.


Accept & Place Order

The acceptance flow is a single confirmation screen — not the multi-step checkout shell. Payment is agreed at quote time; no payment SDK step is needed.
Sequence:
  1. Guard: quote must be Pending or RenegotiationAddressed
  2. User clicks Accept & Place Order
  3. Transition the quote to Accepted via changeQuoteState
  4. Create the order using createOrderFromQuote — use the version returned from step 3, not the original quote version
  5. Clear cartId from session and redirect to /checkout/confirmation?orderId=<id>
Steps 3 and 4 are sequential and must not be parallelised. The order creation call will fail if the quote is not yet in Accepted state when it fires.

Decline

  1. Guard: quote must be Pending or RenegotiationAddressed
  2. User clicks Decline
  3. Transition the quote to Declined via changeQuoteState
  4. Redirect to the quotes dashboard

No order is created. The thread remains visible in the dashboard with state "Declined".


Renegotiate

  1. Guard: quote must be Pending
  2. Present a textarea for the buyer's counter-comment
  3. User submits — call requestQuoteRenegotiation with the buyerComment
  4. The quote transitions to RenegotiationRequested; the buyerComment is stored on the Quote
  5. Redirect to the quote detail page
This opens a new negotiation round. The seller responds by updating the 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 — when quote.customer.id === currentUser.id
  • AcceptOthersQuotes — 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
  • cartId cleared from session after order creation
  • Redirects to /checkout/confirmation?orderId=<id> on order success
  • Decline redirects to the quotes dashboard with Declined state visible
  • Renegotiate stores buyerComment on the Quote; thread gains a new round after seller responds
  • Associate permission (AcceptMyQuotes / AcceptOthersQuotes) checked before rendering actions
b2b/quotes.md

Quotes Dashboard

Impact: HIGH — 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.
This reference covers the CT quote data model, as-associate API constraint, unified list display with thread grouping, status labels, and client state hooks. For buyer actions (accept, decline, renegotiate) see quote-actions.md. For how a quote request is submitted from the cart see checkout.md.

Table of Contents


Pattern 1: CT Data Model — Three Resources

QuoteRequest  →  StagedQuote  →  Quote (round 1)
                                  ↓ renegotiate
                              Quote (round 2)  [same StagedQuote]
ResourceWho creates itKey fields
QuoteRequestBuyer (from active cart)comment, purchaseOrderNumber, lineItems, totalPrice
StagedQuotecommercetools automaticallysellerComment (mutable — always latest seller edit)
QuoteSeller (in Merchant Center)sellerComment (snapshot per round), buyerComment, validTo, quoteState
One negotiation thread = one 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()
Using the project-level apiRoot.quotes() bypasses BU scoping and associate permission enforcement.
Always expand ['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

The dashboard shows one row per negotiation thread, not one row per Quote object.
Grouping rule: group all 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.
Thread state rule: use the most recent Quote.quoteState as the row's state. If no Quote exists yet for a QuoteRequest, fall back to QuoteRequest.quoteRequestState.
Multi-round indicator: show a visual badge or count when a thread has more than one Quote round.
Thread timeline (detail view, top to bottom = oldest to newest):
  1. Buyer request comment — shown once above all rounds, from thread[0].quoteRequestComment
  2. Per round:
    • Seller comment — Quote.sellerComment (per-round snapshot, not StagedQuote.sellerComment)
    • Buyer counter-comment — Quote.buyerComment, only shown when present (set on renegotiate)
    • Expiry date — info line when quote.validTo is present
  3. Sort quotes within a thread by createdAt ascending to maintain chronological order
All comment text must use whitespace-pre-wrap to preserve line breaks.
On the detail page, fetch the full thread by querying all 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:

EntityStateDisplay label
QuoteRequestSubmittedPending review
QuoteRequestAcceptedIn negotiation
QuotePendingQuote ready
QuoteRenegotiationRequestedRenegotiation in progress
QuoteRenegotiationAddressedUpdated quote ready
QuoteAcceptedAccepted
QuoteDeclinedDeclined
QuoteRequestWithdrawnWithdrawn

Pattern 5: Client State Hooks

HookReturns
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
All hooks scope the client state-manager/cache key to [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's concept-mapping.md for concrete client-state and cache implementation.

Checklist

  • Quote.sellerComment used (not StagedQuote.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 to QuoteRequest.quoteRequestState
  • Client-state hooks use [KEY, businessUnitKey] tuple for cache isolation
  • Actions (accept, decline, renegotiate) handled by quote-actions.md
b2b/session-and-bu.md

Session & Business Unit Context

Impact: CRITICAL — All B2B pricing, permissions, and API scoping derive from these session fields. Missing or stale fields produce unscoped prices, 403 errors, or wrong-BU data.
This reference covers the server-managed session structure, what each B2B field means, how BU/store selection works, and the BusinessUnitContext that drives the UI.

Table of Contents


Pattern 1: Session Fields

INCORRECT: Treating the session as just auth + cart:
// 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
});
CORRECT — all B2B fields written together in one 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'
}
Session field cheat-sheet:
FieldExampleUsed 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

INCORRECT: Calling 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;
CORRECT — 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 };
  }
}
storeDataCache is a module-level Map — 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 import getStoreChannelData from this file.

Pattern 3: BU Selection — Writing the Session

INCORRECT: Writing only businessUnitKey and storeKey without channel data:
// WRONG — products will return unscoped prices; cart creation will fail
await setSession(response, { ...session, businessUnitKey, storeKey });
CORRECT — resolve all channel data from the store, then write the full session:
The BU-select server endpoint (e.g. POST .../business-units/[id]/select):
  1. Reads the session; returns Not authenticated (401) unless session.customerId is present.
  2. Reads { businessUnitKey, storeKey } from the request body; returns a validation error (400) if either is missing.
  3. 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's data-loading.md for concrete server endpoint patterns.
The session update is atomic — all five B2B fields (businessUnitKey, storeKey, supplyChannelId, distributionChannelId, productSelectionId) are written in one setSession call, 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

INCORRECT: Letting each component fetch GET /<api>/business-units independently — N fetches, no shared state, no auto-invalidation.
CORRECT — a single 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 currentBusinessUnit and currentStore.
  • 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_UNITS client state-manager/cache entry so the next login re-picks.
Find the stack's concept-mapping.md for concrete client-state and cache implementation.

Pattern 5: Reading Session Fields in Server Endpoints

INCORRECT: Calling commercetools functions without passing the required B2B context — a cart created without BU or store, and product search that returns global prices:
// WRONG — missing BU/store context
const cart = await createCart(session.customerId, 'USD', 'US');
const products = await searchProducts({ query: '...' });
CORRECT — extract all B2B fields, validate they exist, pass to commercetools helpers. In any B2B server endpoint that needs BU context: read the session, return Unauthorized (401) when 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
  • businessUnitKey and storeKey validated before any B2B server endpoint proceeds
  • session passed to searchProducts() — never call with empty/partial session
  • BusinessUnitProvider wraps the locale layout and is inside AuthProvider
  • client state-manager/cache keys for BU-scoped data use [KEY, businessUnitKey] tuple
  • KEY_BUSINESS_UNITS client state-manager/cache entry cleared on logout
b2b/shopping-lists.md

Shopping Lists — B2B (Wishlists + Purchase Lists)

Start from the shared shopping lists reference and implement Patterns 1–5 from there first. This file covers B2B-specific decisions for both types of list that exist in a B2B storefront:
TypeScopeWho sees it
WishlistPersonal (customer-owned)Only the customer
Purchase ListBU-sharedAll associates in the business unit
Both are backed by commercetools 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)

Extends Pattern 1 from the shared reference.
B2B wishlists behave identically to B2C wishlists — use the project-level 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].
The only B2B-specific consideration is that wishlist pages live at /wishlists/ (personal space), not under /dashboard/ (BU space). This boundary makes the scoping visible in the URL.

B2B Extension: Purchase Lists (BU-Scoped)

Extends Pattern 1 from the shared reference.

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

The create draft must include both customer and store:
name: { [locale]: name }
customer: { id: customerId, typeId: 'customer' }
store: { typeId: 'store', key: storeKey }
The store field ties the list to the active store so pricing and availability stay consistent when items are moved to cart.

Permissions

The purchase list UI must respect associate permissions. Gate write actions (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

Extends Pattern 4 from the shared reference.
List typeclient state-manager/cache keyFires when
Wishlist[KEY_WISHLISTS, customerId]customerId is resolved
Purchase list[KEY_PURCHASE_LISTS, businessUnitKey]businessUnitKey is resolved
For purchase lists, passing an empty/null key when businessUnitKey is not yet available is critical — it prevents fetching as the wrong BU or before context is ready.
After any mutation, invalidate the same key tuple used by the hook. For purchase lists this means invalidating [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's concept-mapping.md for concrete client-state and cache implementation.

B2B Extension: UI — Header Icon

A single header icon covers wishlists (personal). It should follow the same behaviour as the B2C wishlist icon: item count badge, navigate to /wishlists, empty state for unauthenticated users.
Purchase lists are a procurement tool and belong in the dashboard navigation, not the header icon. The dashboard nav item for purchase lists should be hidden when the associate lacks 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.

Modal behaviour:
  1. Show two sections: "My Wishlists" (personal) and "Purchase Lists" (BU-shared, only if the associate has ViewPurchaseLists)
  2. Each section lists existing lists with a checkbox — checking one adds the current product/variant to that list
  3. A "+ New wishlist" inline input at the bottom of the "My Wishlists" section lets the customer create and add in one step
  4. A "+ New purchase list" inline input (gated on CreatePurchaseLists permission) at the bottom of the purchase list section does the same
  5. On confirm, fire all selected add-item mutations in parallel; show a single success toast when all resolve
Trigger: A secondary button on the PDP near the Add to Cart CTA — labelled "Save to list" or represented by a bookmark/heart icon. The button is always visible (not hover-only) because B2B users are intentional about list management.
State: The modal should open with checkboxes pre-filled for any list that already contains this variantId. This lets associates see at a glance where the product is saved and toggle membership.

Checklist

Wishlists (personal)
  • 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 store field
  • Server endpoints validate customerId only
  • client state-manager/cache key is [KEY_WISHLISTS, customerId]
  • Pages at /wishlists/ — not under /dashboard/
Purchase lists (BU-scoped)
  • All commercetools calls use asAssociateInStore(associateId, businessUnitKey) — not project-level
  • store: { key: storeKey } included in create draft
  • Server endpoints validate customerId AND businessUnitKey
  • client state-manager/cache key is [KEY_PURCHASE_LISTS, businessUnitKey]; fires only when businessUnitKey is resolved
  • All mutations invalidate [KEY_PURCHASE_LISTS, businessUnitKey] after completing
  • Permission gates: ViewPurchaseLists for nav, CreatePurchaseLists / UpdatePurchaseLists for write actions
  • Dashboard nav item hidden when associate lacks ViewPurchaseLists
  • Pages under /dashboard/purchase-lists/
PDP UI
  • "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
b2b/variant-config.md

Variant Selector Configuration

The only file you normally need to edit is <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

Maps attribute name → render style. Attributes not listed default to '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)
};
RendererComponentUse for
'pill'Text buttonAny attribute (default)
'color'Circular swatchColor 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
};
Note: B2B uses name→hex lookup (not a companion variant attribute) because equipment color attributes carry predefined names like '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

Variant selection navigates to a new URL — it does not use component state. Each variant option renders as a <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
}
Unavailable variants render as <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;
If supplyChannelId is not in session (no BU selected), falls back to isOnStock — acceptable for unauthenticated users.

Adding a New Color

  1. Add the commercetools attribute name to VARIANT_RENDERER_MAP with renderer: 'color'
  2. Add hex codes to COLOR_HEX for each color value in the commercetools enum
  3. Ensure the commercetools attribute is a variant attribute (not product-level)

Checklist

  • New color attribute: add to VARIANT_RENDERER_MAP + hex values to COLOR_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
b2c/checkout.md

Checkout — B2C

See the shared reference for the multi-step page structure, shipping method selection, payment step, and confirmation page. This file covers B2C-specific details: the address step and order placement sequence.

Address Step — Saved Addresses from Logged-In Customer

The address step reads saved addresses from the logged-in commercetools customer account via 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
  1. Addresses step — shipping and billing addresses persisted to cart in real time
  2. Shipping step — user selects a method
  3. Payment step — Checkout frontend SDK mounts, handles payment capture and order placement
  4. Confirmation — SDK signals completion → clear cartId from session → redirect to /checkout/confirmation?orderId=<id>
Order placement is handled entirely by the Checkout frontend SDK on the payment step. Do not implement a separate 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 / isDefaultBilling from useAccount()
  • 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
  • cartId cleared from session after SDK order completion event
b2c/customer-auth.md

Customer Authentication — B2C Extensions

Impact: HIGH — Missing anonymous cart merge silently loses the customer's cart on every login.
B2C-specific auth patterns that extend the shared foundation in reference. Read that reference first for the commercetools login endpoint, server endpoint structure, client state hook, and logout patterns.

Anonymous Cart Merge

Pass 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
}
The login server endpoint reads 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

A client component that wraps the account section and redirects to /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's concept-mapping file for Concrete protected-layout component. Example: see the Next.js stack → Client state hooks.

Checklist

  • signIn passes anonymousCartId + anonymousCartSignInMode: 'MergeWithExistingCustomerCart' when a guest cart exists
  • Login server endpoint writes cart.id from the commercetools response back into the session
  • Register calls signIn immediately after apiRoot.customers().post()
  • Account layout redirects to /login?redirect=<path> on null; returns null while loading
b2c/navigation.md

Header & Navigation

Impact: HIGH — The header renders on every page. Wrong data-fetching (client-side category fetch) adds a waterfall to every route.

This reference covers the Header server-rendered component, logo, mega menu driven by the category tree, search bar, and country/locale switcher.

Table of Contents


Pattern 1: Header Structure

INCORRECT: Fetching the category tree inside the Header component from the client (an effect or a client-side data fetch) — adds a loading waterfall on every page.
CORRECT — fetch the category tree once in the root/locale layout (server-rendered) and pass it down as a prop:
In a server-rendered layout that wraps every route, resolve the active locale from the session and call getCategoryTree(locale) from <server>/ct/categories once. Render Header with the resulting categoryTree as a prop, then render the page children.
Find the adapter's data-loading.md for concrete server-rendered layout (data fetched once and passed as props) implementation.
Header itself is server-rendered. Interactive children (mega menu open/close, search input, locale switcher) are client components in their own files receiving the server-fetched data as props.

  • Render as a <Link href="/"> using the framework's locale-aware link so locale prefix is preserved
  • Use the framework's image primitive with explicit width/height (or fill in a sized container) — never a bare <img>
  • Mark the logo image priority — it is above the fold on every page

Pattern 3: Mega Menu

The mega menu receives the categoryTree array (already fetched server-side) and manages open/close state client-side.

Data shape

categoryTree is an array of root categories, each with a children array of subcategories (produced by getCategoryTree in <server>/ct/categories).

Desktop mega menu

  • A client component that accepts categoryTree as a prop
  • Hovering or clicking a root category reveals a panel of its children as links
  • Active root category highlighted using the framework's client navigation/query-param API — compare current path to /category/<slug>
  • All category links use the framework's locale-aware link
  • Close the panel on outside click (a document click listener registered while the panel is open) and on Escape key

Mobile drawer

  • A client component toggled by a hamburger button
  • Renders the same category tree as an accordion or flat list
  • Drawer slides in from the left; backdrop click closes it
  • Same locale-aware link usage as desktop

  • A client component input
  • On submit (Enter key or search button click), navigate to /search?q=<encoded-query> using the framework's client navigation/query-param API
  • The /search page is a server-rendered page that reads the q query param and calls searchProducts
  • Keep input state local component state — no global store needed
A client component with a controlled input. On submit it pushes a navigation to /search?q=<encoded query> via the framework's locale-aware client navigation.

Pattern 5: Country & Locale Switcher

These are two distinct concerns wired differently:

Locale switcher (URL-based)

  • Use the framework's client navigation/current-path access to switch locale without losing the current path
  • The framework's i18n/locale routing handles the URL rewrite — no session update needed

A client component that, on select, re-navigates to the current path under the chosen locale (the framework's locale-aware navigation rewrites the URL prefix).

Find the adapter's concept-mapping.md for concrete locale-switcher client component.

Country / currency switcher (session-based)

  • Changing country must update country and currency in the server-managed session, then refresh
  • POST to a server endpoint with { country, currency, locale } — the endpoint writes the session and returns the updated values
  • After the response, trigger a refresh of the server-rendered tree so components re-render with the new session values
  • Country → currency mapping lives in a static config (e.g. lib/locale-config.ts) so the UI can derive the correct currency without an API call
A client component that, on select, POSTs the new { country, currency } to the locale server endpoint and then refreshes the server-rendered tree.

Checklist

  • getCategoryTree called once in the server-rendered root/locale layout — not inside Header
  • Header is server-rendered; mega menu open/close and search are client sub-components
  • Logo uses the framework's locale-aware link — not a bare <a>
  • Logo image uses the framework's image primitive with priority
  • All category links use the framework's locale-aware link
  • Active category detected with the framework's current-path/query-param access — no manual URL parsing
  • Mega menu closes on outside click and Escape key
  • Search navigates to /search?q= — does not call a server endpoint for data
  • Locale switch re-navigates to the current path under the chosen locale via the framework's locale routing
  • Country switch POSTs to the locale server endpoint then refreshes the server-rendered tree
b2c/optional/bopis.md

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

Supply channel reference is an object, not a string. commercetools rejects a raw channelId string — always { typeId: 'channel', id: channelId } in cart actions.
Filter channels by InventorySupply role for the store selector. Distribution or fulfilment-only channels should not appear in the pickup UI.
Delivery mode persists to localStorage. ChannelSelector saves 'delivery' or 'pickup' across navigation. Switching to delivery calls onSelect(null) to clear the supply channel.
Use KEY_CHANNELS / keyChannel(id) from <server>/cache-keys. Don't inline cache key strings in hooks.

Anti-Patterns

Anti-patternCorrect approach
supplyChannel: channelId (string)supplyChannel: { typeId: 'channel', id: channelId }
Showing all channels in the selectorchannels.filter(c => c.roles?.includes('InventorySupply'))
Hardcoded string key in the client state hookUse KEY_CHANNELS from <server>/cache-keys

Reference

TaskReference
Channels API (<server>/ct/channels), server endpoints, supply channel on cart, per-channel inventory, cache keys, useChannels hook, ChannelSelector UI, pickup badge in CartItembopis.md

BOPIS (Buy Online, Pick Up In Store)

**Impact: MEDIUM — Supply channel reference format must be { 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

// /<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 → returns getAllChannels().
  • GET /channels/:id → returns getChannelById(id), or a 404 not-found response when the channel does not exist.
Find the stack's data-loading.md for concrete server endpoints pattern implementation.

Pattern 2: Cart Supply Channel

INCORRECT: wrong reference format for supply channel.
// BAD
supplyChannel: channelId   // string only — commercetools rejects this
CORRECT — reference object with 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;
}
The add-to-cart server endpoint reads { 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 key KEY_CHANNELS; fetches GET /<api>/channels; returns { channels, error, isLoading } (defaulting channels to []). Disable refetch-on-focus.
  • useChannel(id) — cache key [keyChannel(id), id], or null when id is null so it does not fetch; fetches GET /<api>/channels/:id; returns { channel, error, isLoading } (defaulting channel to null). Disable refetch-on-focus.
Import KEY_CHANNELS / keyChannel from <server>/cache-keys and the Channel type from <server>/types — do not inline key strings.
Find the stack's concept-mapping.md for 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

ChannelSelector — a client component with tabs for delivery vs pickup that persists the chosen mode to localStorage:
  • Reads the channel list from useChannels() and derives the pickup options with channels.filter((c) => c.roles?.includes('InventorySupply')) — only InventorySupply channels appear in the pickup selector.
  • Holds a local 'delivery' | 'pickup' mode, initialised from localStorage (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 calls onSelect(channelId).
  • Switching to delivery calls onSelect(null) to clear the supply channel.
Pickup badge in cart item:
A small 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, getChannelByKey implemented in <server>/ct/channels
  • Server endpoints GET /channels and GET /channels/:id expose getAllChannels / getChannelById
  • addLineItem accepts supplyChannelId and uses { typeId: 'channel', id } reference
  • The add-to-cart server endpoint passes supplyChannelId through to addLineItem
  • KEY_CHANNELS and keyChannel(id) added to <server>/cache-keys
  • useChannels() and useChannel(id) client state hooks created with a deduping interval
  • CartLineItem.supplyChannelId?: string added to types
  • VariantAvailability and VariantChannelAvailability interfaces added
  • ChannelSelector persists delivery mode to localStorage
  • Pickup badge visible in cart line items when supplyChannelId is set
b2c/optional/bundles.md

B2C Product Bundles

Bundles are a parent line item with child line items linked by a commercetools custom field parentKey. All cart mutations cascade from parent to all matching children. bundleItems() groups them before any component sees the data.

Key Takeaways

Run the commercetools setup script first. 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.
Cascade all mutations in one 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-patternCorrect approach
Adding children without a parentKey linkParent gets UUID key; children get custom.fields.parentKey referencing it
Removing parent without cascadeFind children by parentKey === parent.key, batch all removals in one action call
Grouping in a componentApply bundleItems() in the cart data fetcher — components receive pre-grouped data
Counting children in cart badgecartItemCount() filters !i.parentKey before summing
Sequential action calls for cascadesBatch all actions in a single applyCartAction to avoid 409 version conflicts

Reference

TaskReference
commercetools setup script, CartLineItem extension, cascade cart operations, cart-mapper, items endpoint, bundle-utils, cart fetcher override, CartItem UI, BundleAddToCart componentbundles.md

Bundles

Impact: MEDIUM — Bundle children must be linked to their parent via a commercetools custom field (parentKey). Without it, removing the parent leaves orphaned child line items in the cart.
Bundles are implemented as a parent line item with child line items linked by a parentKey custom field. All cart operations cascade from parent to children.

Table of Contents

Pattern 1: commercetools Setup

Create the custom type for line items with a parentKey field:
node tools/create-bundles-custom-type.mjs
This creates a commercetools custom type line-item-additional-info with a parentKey String field.

In commercetools Merchant Center, add a bundle attribute to the product type:

  • Type: Set of Reference to Product
  • 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

INCORRECT: adding children without a key link — orphaned on parent removal.
// BAD — no parentKey, no way to cascade removal
await addLineItem(cartId, version, childSku, 1);
CORRECT — parent gets UUID key, children reference it via 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

Surface 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

The add-to-cart server endpoint reads { productId, variantId, quantity, bundledSKUList } from the request and the cartId from the session, then:
  1. Loads the cart with getCart(session.cartId).
  2. Generates a parentKey (a UUID via uuidv4()) only when bundledSKUList is non-empty.
  3. Adds the parent with addLineItem(cart.id, cart.version, productId, variantId, quantity, parentKey).
  4. When a parentKey and bundled SKUs exist, adds the children with addBundledLineItems(cart.id, cart.version, parentKey, bundledSKUList).
  5. Returns the updated cart.
Find the stack's data-loading.md for 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

Apply 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 ?? []) };
Import 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's concept-mapping.md for concrete client-state and cache implementation.

Pattern 8: UI

CartItem — render bundled children as sub-rows:
The cart line-item component renders the main row (thumbnail via the framework's image primitive, 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.
BundleAddToCart — passes bundledSKUList to the add-to-cart endpoint:
A client component that takes 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.mjs run — custom type line-item-additional-info with parentKey field exists in commercetools
  • CartLineItem extended with key, parentKey, bundledItems
  • addLineItem accepts optional key parameter
  • addBundledLineItems creates children with custom.fields.parentKey
  • changeLineItemQuantity and removeLineItem cascade to children by matching parentKey
  • cart-mapper.ts maps ctItem.key and ctItem.custom.fields.parentKey
  • The add-to-cart server endpoint generates a UUID parent key and calls addBundledLineItems
  • bundleItems() and cartItemCount() in lib/bundle-utils.ts
  • bundleItems applied in the cart data fetcher (cart client state hook override)
  • CartItem renders item.bundledItems as sub-rows (uses the framework's image primitive, not <img>)
  • BundleAddToCart uses the cart context's addToCart() — no direct fetch in component
b2c/optional/promotions.md

B2C Promotions & Discounts

Three commercetools discount types each surface differently. Product Discounts change 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

Expand discount refs in search, or 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.
Three types, three surfaces. Product Discount → badge + strikethrough on ProductCard/PDP. Cart Discount → silently changes line item and cart totals (no explicit UI trigger). Discount Code → applied chip with remove button in cart.
Promotion banners: static Header or CMS content/message section. Static is simpler; CMS-driven via lib/layout.ts allows content changes without deploys and supports localized strings.

Anti-Patterns

Anti-patternCorrect approach
No expand on discount refs in searchAdd both masterVariant and variants[*] discount expand to search params
Custom discount code fetch/formImport <DiscountCodeForm /> from components/cart/ — it already handles everything
Hardcoding discount name stringMap from expanded ctPrice.discounted.discount.obj.name in <server>/mappers/product
Product Discount badge without expandName is undefined without the expand — always expand before mapping

Reference

TaskReference
Discount types overview, product discount expand + mapper + ProductCard, DiscountCodeForm usage, promotion banner (static + CMS-driven)promotions.md

Promotions & Discounts

Impact: LOW — Three discount types in commercetools each surface differently. Product discounts require search query expansion to show names. Cart discounts reduce totals silently.

Table of Contents

Pattern 1: Discount Types Overview

TypeHow it worksWhere it surfaces
Product DiscountChanges variant.price.discounted on matching productsBadge + strikethrough on ProductCard and PDPPrice
Cart DiscountReduces lineItem.totalPrice and/or cart.totalPrice silentlyLine item price difference, cart total reduction
Discount CodeCustomer-entered code that triggers a Cart DiscountApplied chip in cart, cart.discountCodes[]
All three are created in commercetools Merchant Center under Discounts.

Pattern 2: Product Discount Display

INCORRECT: not expanding the discount reference — 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
CORRECT — expand both 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

Already implemented — import <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.
Usage in cart page — import the existing 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")
Server endpoints (already exist) — the cart-discount endpoint applies and removes codes. Both read { 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's data-loading.md for concrete server endpoints pattern implementation.

Pattern 4: Promotion Banner

Two options — choose one:

Option A: Static banner in 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 */}
    </>
  );
}
Option B: CMS-driven via 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>
  );
}
Then register the '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.discount and variants[*].price.discounted.discount in search params
  • DiscountCodeForm imported (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)
b2c/optional/recurring-orders.md

Recurring Orders — B2C

Start from the shared recurring orders reference and implement Patterns 1–7 from there first. This file covers B2C-specific decisions layered on top.

Table of Contents


B2C Extension: Scoping and Auth

Extends Pattern 2 from the shared reference.

Scope recurring order list fetches to the authenticated customer:

where: customer(id="${customerId}")
All server endpoints validate customerId from the session. Always read customerId from getSession() on the server — never trust a client-supplied ID.

B2C Extension: Line Items — originOrder Expand

Extends Pattern 2 from the shared reference.
In B2C, recurring orders are created from a post-checkout order. Expand originOrder — not cart — to access the line items:
expand: ['originOrder']
Always fall back when reading items: sub.lineItems?.length ? sub.lineItems : sub.originOrder?.obj?.lineItems ?? []. The top-level lineItems on RecurringOrder is often empty even when the expand succeeds.
Normalise the next-order date at the API layer before returning to the client: map nextOrderAtnextOrderDate. This ensures UI components use a consistent field name regardless of which commercetools API version returns which field name.

B2C Extension: Create Post-Checkout

Extends Pattern 4 from the shared reference.
B2C recurring orders are created inside the checkout server endpoint immediately after the order is placed. For each line item in the placed order that has recurrenceInfo.recurrencePolicy set, fetch the policy by ID and create one RecurringOrder.
Draft shape — note that 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 },
}
Compute 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

Extends Pattern 3 from the shared reference.

B2C supports two additional update actions beyond pause / resume / cancel:

Skip: skips the next N deliveries without cancelling the subscription.
action: 'setOrderSkipConfiguration'
skipConfigurationInputDraft: { type: 'Counter', totalToSkip: N }
Change schedule: replaces the recurrence policy on the subscription.
action: 'setSchedule'
recurrencePolicy: { id: recurrencePolicyId }   // or pass a raw schedule object
Both go through the same single PUT /<api>/account/subscriptions/[id] endpoint, dispatched on an action field in the request body.

B2C Extension: API Routes

Extends Pattern 6 from the shared reference.
MethodPathActionAuth
GET/<api>/account/subscriptionsList customer's recurring orderscustomerId
GET/<api>/account/subscriptions/[id]Single with originOrder expandcustomerId
PUT/<api>/account/subscriptions/[id]All state actions (pause/resume/cancel/skip/setSchedule)customerId
GET/<api>/recurrence-policiesList all policiesSession
POST/<api>/cart/itemsAdd line item; recurrencePolicyId optionalcustomerId
State changes use a single 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 where clause uses customer(id="${customerId}") — not BU scoping
  • All server endpoints validate customerId only — no businessUnitKey
  • customerId always read from getSession() — never from request body or query params
  • Always expand: ['originOrder'] — not cart
  • Fall back to originOrder?.obj?.lineItems when top-level lineItems is empty
  • Normalise nextOrderAtnextOrderDate at the API layer, not in the UI
  • Recurring orders created post-checkout, one per subscription line item
  • RecurringOrderDraft body cast as unknown — extension fields not in SDK type
  • nextOrderAt computed from policy schedule fetched at checkout time
  • Order not rolled back if createRecurringOrder fails — log and continue
  • Cancel uses 'canceled' (single-l) as the commercetools state value
  • Skip uses setOrderSkipConfiguration; schedule change uses setSchedule
  • Single PUT route handles all state actions via action field dispatch
  • No dedicated subscription creation route — creation is inside the checkout route
b2c/optional/recurring-prices.md

Recurring Prices — B2C

Start from the shared recurring prices reference and implement Patterns 1–5 from there first. This file covers B2C-specific decisions layered on top.

Table of Contents


B2C Extension: PDP Gate

Extends Pattern 3 from the shared reference.
Show the subscription UI when 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

Extends Pattern 5 from the shared reference.
B2C cart operations use the project-level 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;
When the user selects "one-time", spread nothing — do not pass 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 of variant.prices[]
  • Mapped Price type includes recurrencePolicy?: { id: string }
  • Show subscription UI when recurringPrices.length > 0 for the selected variant — no product attribute check required
  • Cart operations use project-level apiRoot.carts() — not as-associate chain
  • addLineItem action cast as CartUpdateAction when attaching recurrenceInfo
  • One-time selection: omit recurrenceInfo entirely — do not spread recurrenceInfo: 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/optional/superuser.md

B2C Superuser (CSR Impersonation)

CSR agents authenticate with their own credentials, then impersonate a customer. The server-managed session carries both identities simultaneously. All price-override endpoints are hard-gated behind a csrId presence check — UI hiding the input is not sufficient.

Key Takeaways

Two identities in one session. Normal session fields (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 }.
Three-endpoint login flow. Login → detect CSR group → return flag → UI calls 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-patternCorrect approach
No csrId check on price override endpointReturn 403 when session.csrId is absent — always, even if UI hides the input
Exposing CSR_GROUP_ID to the client bundleServer-only env var — never exposed to the client
CSR state in localStorage or client component stateServer-managed session; expose via the <api>/auth/superuser server endpoint read by a client state hook
UI-only gate on price overrideServer must enforce; UI visibility is a UX courtesy, not a security control

Reference

TaskReference
commercetools setup, session extension, login flow, price override endpoint, SuperUserContext, Header banner, CartItem PriceOverrideInputsuperuser.md

Superuser (CSR Impersonation)

Impact: MEDIUM — CSR impersonation requires csrId session guard on the price override endpoint. Missing it lets any authenticated user override line item prices.
CSR agents log in with their own credentials, then impersonate a customer. The session holds both identities. Price overrides are gated behind a csrId check.

Table of Contents

Pattern 1: commercetools Setup

Add 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:

  1. Customers → Customer Groups → Create "CSR Agents" group
  2. Copy the group ID to CSR_GROUP_ID
  3. Assign CSR agent customer accounts to that group

Pattern 2: Session Extension

Extend the 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.id or any customerGroupAssignments[*].customerGroup.id equals CSR_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 and getCustomerByEmail(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's data-loading.md for concrete server endpoints (auth, session read/write) pattern implementation.

Pattern 4: Price Override

The price-override server endpoint (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's data-loading.md for concrete server endpoints endpoint (with the csrId 403 guard).

Pattern 5: SuperUserContext

A client-side context exposes the CSR state ({ csrId?, csrEmail?, csrFirstName?, csrLastName? }) to any client component without prop drilling:
  • A SuperUserProvider uses a client state hook to fetch the <api>/auth/superuser server endpoint (with ~30 s deduping), defaulting to {}, and provides the result through the context.
  • A useSuperUser() accessor reads that context.
Mount SuperUserProvider in the root/locale layout (server-rendered), wrapping the page children, so CSR state is available app-wide.
Find the stack's concept-mapping.md for concrete client-state context + provider.

Pattern 6: UI

Yellow banner in Header when CSR is active:
The Header reads { 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").
PriceOverrideInput in cart line item (shown only to CSR):
The cart line-item component reads 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_ID set as a server-only env var (never exposed to the client bundle)
  • Session interface extended with csrId, csrEmail, csrFirstName, csrLastName
  • POST <api>/auth/login returns { requiresCsrEmail: true } for CSR group members
  • POST <api>/auth/csr-login writes dual identity to the session
  • GET <api>/auth/superuser returns CSR fields or {}
  • The price-override endpoint (PUT on a cart line item) returns 403 when session.csrId is absent
  • SuperUserProvider mounted in the root layout wrapping children
  • Yellow banner visible in Header during active impersonation
  • PriceOverrideInput rendered in CartItem only when csrId is set
b2c/overview.md

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's core/ references. Find the adapter's overview.md it owns the file layout, render primitives, deploy, and the /commands.

Key Takeaways (B2C-specific)

Anonymous cart merge is mandatory. Pass anonymousCartId and anonymousCartSignInMode: 'MergeWithExistingCustomerCart' on login so the pre-login cart is preserved.
Locales use BCP-47 format everywhere. 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

These shared-foundation references live in this skill's core/. For frontend implementation, see the stack's overview.md of the adapter.
TaskReference
Scaffold a new project (deps, styling, locale routing)Framework-specific example — Next.js: run /nextjs-setup-project
commercetools SDK singleton, server-managed sessions, BFF boundaryct-client.md
Shared auth base: commercetools login, server endpoint, client state hook, logoutcustomer-auth.md
Add a new country / currency / locale (COUNTRY_CONFIG)add-country.md
Parallel fetching, server-side TTL caching, client-cache hydration, image optimizationperformance.md
Product image URL transforms (CDN, Imgix, Cloudinary)image-config.md

Core — Green-Field Build (follow in order)

TaskReference
Category pages, product mapper, commercetools Search API, ProductCard/Gridproduct-listing.md
PDP route, image gallery, variant selectors, AddToCartButtonproduct-detail.md
Cart CRUD, cart state/context, client state hook, mini-cart drawercart.md
Shipping methods, order placement, multi-step checkout, confirmationcheckout.md
Register, login, anonymous cart merge, protected account layoutcustomer-auth.md
Full-text search, facet config, URL state, rendererssearch-facets.md

Enhancement — Modify Existing Features

TaskReference
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 pageadd-page.md
Use or extend the shared UI component libraryui-components.md
Server-rendered vs client-fetched decisions, mappers, BFF shape, 409 retrydata-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.

FeatureSkill
CSR impersonation, dual session, line-item price overridesuperuser.md
Buy Online Pick Up In Store — channel API, per-store inventorybopis.md
Product bundles — parent/child cart items, cascade updatesbundles.md
Product discounts, cart discounts, discount codes, promotion bannerspromotions.md
Deploy to VercelRun /deploy-vercel — checks commercetools credentials, then hands off to Vercel's official agent skill
Deploy to NetlifyRun /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's overview.md file for stack's specific priority.

HIGH

  • Anonymous cart merge — Pass anonymousCartId to commercetools login so the cart is preserved on sign-in.
  • Client state invalidation — refresh/invalidate KEY_CART and KEY_ACCOUNT after 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-patternCorrect approach
Per-user data in a shared server-side TTL cacheClient state (client) or a direct commercetools call (per-request server)
b2c/product-detail.md

Product Detail Page — B2C

The core PDP patterns — route structure, server-rendered fetch, variant URL strategy, components, metadata, and attribute labels — are in product-detail.md.
Attribute labels fetch getAttributeLabels(bcp47) in parallel with the product and pass the result to any component that renders product attributes.
Use getLocale() to get country, currency, and locale for the current session. Pass these to product and price fetches to ensure market-correct pricing.
Next: cart.md
b2c/product-listing.md

Product Listing

Impact: HIGH — N+1 queries in category pages multiply commercetools API calls linearly with page size and crater TTFB.

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

<server>/ct/categories key functions:
  • getCategoryBySlug(slug, locale): fetch a category by its localized slug
  • getCategoryById(id, locale): fetch a category by ID
  • getCategoryTree(locale): fetch all categories (limit: 500, sorted by orderHint) 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

INCORRECT: Passing raw commercetools ProductProjection objects to components — this pushes too much data to frontend.
CORRECT — map in <server>/mappers/product, components only receive Product from <server>/types:

Functions to implement:

  • mapProduct(p, locale): maps a ProductProjection to the app Product type
  • mapVariant(v): maps variant fields — id, sku, images, price, prices, attributes, availability
  • mapPrice(p): maps price — centAmount, currencyCode, discounted

Pattern 3: Product Search API

See product-search.md for the full reference — deprecation warning, query examples, facets, and all productProjectionParameters patterns.
<server>/ct/search key functions for this storefront:
  • searchProducts(params): queries using the v2 Product Search API. Supports text query, categoryId filter via categoriesSubTree, pagination (limit/offset), and sort
  • getProductBySku(sku, locale, currency, country): fetches a single product by exact SKU match
Price selection: Pass priceCurrency + priceCountry in productProjectionParameters so variants arrive with .price already 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 link
  • ProductGrid: renders a responsive grid of ProductCard components; shows an empty state when no products
  • Pagination: renders a Pagination component which modifies the offset/limit
  • Other components to handle Client rendered components (sort, facets, etc)

Pattern 5: Category Page (server-rendered)

INCORRECT: fetching products from a client-facing server endpoint (BFF round-trip) inside a category page — unnecessary hop for data that's only ever server-rendered.
CORRECT — call <server>/ct/* directly in a server-rendered load, parallel-fetch independent data:

In the server-rendered category page/load:

  1. Read the route slug and resolve country, currency, locale from the session.
  2. Parallel-fetch the independent data with Promise.all: getCategoryBySlug(slug, locale) and getCategoryTree(locale) at the same time.
  3. If the category does not resolve, return the framework's not-found response.
  4. Build the breadcrumb by walking the in-memory category tree — no extra API calls.
  5. Call searchProducts({ categoryId: category.id, locale, currency, country, ... }).
  6. Render breadcrumb, heading, ProductGrid, and pagination.
Find Stack's data-loading.md for more details of aconcrete server-rendered category page implementation.

Checklist

  • <server>/ct/categories exports getCategoryBySlug, getCategoryById, getCategoryTree
  • getCategoryTree fetches with limit: 500
  • <server>/mappers/product exports mapProduct — components never receive raw commercetools types
  • <server>/ct/search uses apiRoot.products().search() (v2 API), not legacy productProjections
  • Category page uses Promise.all to 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
b2c/ui-components.md

UI Components

Impact: LOW — Writing raw HTML with inline Tailwind instead of using components/ui/ creates inconsistent styling and duplicated behaviour.
The shared library lives at <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's components/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

INCORRECT: raw button with inline Tailwind.
// BAD
<button className="px-5 py-2.5 bg-black text-white rounded-lg hover:bg-gray-800">
  Add to Cart
</button>
CORRECT — import from @/components/ui/:
// GOOD
import Button from '@/components/ui/Button';

<Button variant="primary" onClick={handleAddToCart}>
  Add to Cart
</Button>
Components available in components/ui/: Button, Input, Drawer, Badge, Spinner, Modal, Select.

Pattern 2: Button Component

Props: 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

Props: 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

Props: 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>
    </>
  );
}
The footer slot 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/

INCORRECT: domain-specific component with commercetools imports placed in 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
}
CORRECT — no domain knowledge, extends HTML attributes:
// <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>
  );
}
Rules for components/ui/:
  • No imports from <server>/ct/, <server>/mappers/, or domain hooks
  • Props interface must extend the relevant HTML*Attributes type
  • Accept and spread ...props so callers can add aria-*, data-*, className etc.
  • Export a single default component per file

INCORRECT: raw <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>
CORRECT — use the framework's locale-aware link:
// GOOD
import Link from '@/i18n/routing';

<Link href="/products">Browse products</Link>

When to use each:

CaseUse
Internal route (same origin)<Link href="...">
External URL (https://...)<a href="..." target="_blank" rel="noopener noreferrer">
Button that navigates programmaticallyrouter.push(...) via the framework's client navigation/query-param API
Combining with the 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*Attributes type
  • No <server>/ct/ imports inside components/ui/ files
  • ...props spread 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>)
b2c/variant-config.md

Variant Config

Impact: LOW — Variant selector behaviour on the PDP is controlled by <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

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 in PDP_INFO_ATTRIBUTES should 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 in VARIANT_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',
};
The companion attribute (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 to VARIANT_SELECTOR_BLOCKLIST as well to avoid duplicate rendering.

Config Summary

VariableTypePurpose
VARIANT_SELECTOR_BLOCKLISTstring[]Attribute names never shown as selectors
VARIANT_RENDERER_MAPRecord<string, VariantRenderer>Maps attribute → 'pill' or 'color' renderer
VARIANT_COLOR_CODE_ATTRRecord<string, string>Maps display attribute → companion hex attribute for color swatches
VARIANT_SORT_ORDERstring[]Left-to-right display order; unlisted attributes appear after
PDP_INFO_ATTRIBUTESstring[]Attributes shown as text info blocks below description (in <pre>)
b2c/wishlists.md

Wishlists — B2C

Start from the shared shopping lists reference and implement Patterns 1–5 from there first. This file covers only the B2C-specific decisions layered on top.

Table of Contents


B2C Extension: API Chain and Ownership

Extends Pattern 1 from the shared reference.
Use the project-level 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.
The 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

Extends Pattern 2 (create helper) from the shared reference.
The create draft requires only 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

Extends Pattern 4 from the shared reference.
The client state-manager/cache key uses [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.
After every mutation (create, rename, add item, remove item, delete) re-fetch (or invalidate) the [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's concept-mapping.md for 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.length across all lists)
  • Navigate to /wishlists on 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.

Behaviour:
  • 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 ?redirect param to come back)
Placement:
  • 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)
Optimistic update: toggle the filled/outline state immediately on click; revert on API error and show a toast.
The heart icon component needs access to the full wishlist client state data to compute the isSaved state. Pass variantId (or productId as fallback) as the lookup key.

B2C Extension: Pages

Wishlists are personal and not part of the account dashboard. They live at /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 store field
  • Server endpoints validate customerId only (not businessUnitKey)
  • client state-manager/cache key is [KEY_WISHLISTS, customerId]; fires only when customerId is 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 ?redirect param
  • 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
core/add-api.md

Adding a BFF API Endpoint

Impact: HIGH — Calling commercetools directly from a client component or bypassing the hook layer exposes secrets and breaks the caching model.

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

INCORRECT: Importing <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');
CORRECT — strict one-way data flow:
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
If a client file needs a type from a commercetools module, import it from <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

INCORRECT: Inlining key strings in the client-state hook — same resource gets different keys across components (e.g. one component keys reads on 'widgets', another on `widget-${id}` ad-hoc).
CORRECT — all keys in <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

INCORRECT: Writing raw commercetools SDK calls inside the 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 });
}
CORRECT — the endpoint validates the session, delegates to <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 (with NextResponse) and the {auth,account,cart,checkout,shipping-methods,channels} endpoint directory conventions. Find the stack's concept-mapping.md for concrete server endpoints.

Pattern 4: commercetools Helper Function

INCORRECT: Adding commercetools SDK calls anywhere outside <server>/ct/<namespace>.ts:
// WRONG — commercetools call in the endpoint
const { body } = await apiRoot.orders().withId({ ID: id }).get().execute();
CORRECT — one function per operation in the matching namespace file:
// <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;
}
Example of commercetools namespace ownership:
FileOwns
<server>/ct/authsignInCustomer, signUpCustomer, getCustomerById, updateCustomer
<server>/ct/cartAll cart + order operations
<server>/ct/ordersgetCustomerOrders, getOrderById
<server>/ct/searchsearchProducts, getProductBySku
<server>/ct/categoriesgetCategoryTree, getCategoryBySlug
<server>/ct/wishlistShopping list operations

Pattern 5: Client State Hook with Mutations

INCORRECT: Mutating without updating the client state-manager/cache — requires a full refetch to see the change. A deleteWidget that only does fetch('/<api>/widgets/<id>', { method: 'DELETE' }) leaves the UI stale until the next revalidation.
CORRECT — a read hook + a mutations module:
  • A read hook is keyed by KEY_WIDGETS and 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 updates KEY_WIDGETS directly from the response body without a refetch. A delete also clears the detail key keyWidget(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 in try/catch and shows the error. Read hooks return safe defaults (null, []) on failure — never throw.
Locale-parameterised hook: a read hook can use a tuple key — e.g. [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's concept-mapping.md for 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
core/add-country.md

Add Country / Locale

Impact: MEDIUM — Adding country config in multiple places instead of the single source causes missing currencies in cart and broken locale routing.
All locale data derives from one 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

INCORRECT: hardcoding currency or locale in multiple files.
// BAD — scattered across files
// In cart.ts:
currency: 'EUR'
// In checkout.ts:
country: 'DE'
// In the header component:
const locales = ['en-US', 'de-DE'];
CORRECT — add to 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:      '🇫🇷',
  },
};
The 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

The active-locale list the framework's i18n/locale routing uses must derive from 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).
  1. Update the Merchant center by adding the new country, language and currency from Settings > Project settings
  2. Update and add a new entry to COUNTRY_CONFIG is 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's project-layout.md.

Pattern 3: Message File

INCORRECT: reusing an existing locale file or naming it incorrectly.
// BAD — wrong filename, won't be picked up by the framework's i18n loader
messages/fr.json
messages/fr-fr.json
messages/FR.json
CORRECT — create 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
Then translate all values in 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_CONFIG in <root-dir>/<server>/utils with locale, currency, country, label
  • Locale list in the framework's routing config derives from COUNTRY_CONFIG keys (not hardcoded) — Adapter's project-layout.md
  • <root-dir>/messages/<BCP-47>.json created 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
core/add-page.md

Adding a New Page

Impact: MEDIUM — Loading data client-side when it could be server-rendered, skipping the locale-aware link primitive, or omitting SEO metadata all degrade 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)

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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's concept-mapping.md and best-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)
core/cart.md

Cart

Impact: CRITICAL — Cart version conflicts (409) and stale 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

<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

The cart helpers are called from client hooks, so the storefront exposes cart endpoints that mirror them. The cart GET endpoint is where the important session hygiene lives:
  • 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), clear cartId from the session and return { cart: null } — the client should see an empty cart.
    • If getCart throws (cart not found in commercetools), clear the stale cartId from the session and return { cart: null }.
    • Otherwise return { cart }.
The cart POST endpoint creates a cart on demand. Mutation endpoints (add/remove line item, change quantity, discount) each wrap the matching <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 (using NextResponse + setSessionCookie) follow the standard BFF endpoint shell — find adapter's data-loading.md.

Pattern 3: Cart Client State Hook

INCORRECT: Calling fetch('/<api>/cart') directly in a component.
CORRECT — a cart read hook + a cart mutations module:
  • A cart read hook is keyed by KEY_CART and reads the cart from the cart endpoint (GET /<api>/cart). The fetcher returns null when 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_CART directly from the API response body without a refetch — always prefer this over a blind revalidation, which costs a second round-trip.
The app Cart type is declared in the shared types module and imported by the hook — never imported from @commercetools/platform-sdk.
Find the stack's concept-mapping.md for concrete state and cache implementation.

Pattern 4: Cart Provider

A cart provider is a client component that wraps the app and exposes a single cart context to the tree. It:
  • reads the cart via the cart client-state hook (seeded with a server-fetched initialCart) and re-exposes cart + 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's addItem and 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.
Wrap the app with the cart provider at the root (locale) layout. Fetch 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's data-loading.md for concrete layout-level hydration pattern implementation.

Pattern 5: Mini-Cart Drawer

See full implementation in the 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

Why 409 errors happen: commercetools uses optimistic locking. Every cart update requires the current 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/cart creates carts with shippingMode: 'Single'
  • The cart GET endpoint discards non-Active carts and clears cartId from session
  • The add-item endpoint creates a cart on demand if cartId is 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 initialCart from the server
  • KEY_CART from <server>/cache-keys is the single client state-manager/cache key for cart data
core/checkout-page.md

Checkout Page

Impact: HIGH — The checkout route is the revenue path. A failed order placement or stale cart version drops the conversion entirely.

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 Structure

The checkout is URL-based with three steps: 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 && hasMethodpayment; else hasAddrshipping; else → addresses.
  • Each step repeats the guard and redirects back when prerequisites are unmet (e.g. on shipping with 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's concept-mapping.md for 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

Shipping methods are fetched via a server endpoint that filters by the session currency — a method with no rate for the current currency must never appear. The endpoint reads currency from getLocale(), loads getShippingMethods(), filters to methods with a matching rate, and returns { shippingMethods } (or [] on failure).
On the client, a client state hook reads the shipping-methods server endpoint. Its cache key is keyed on the current 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.
When the user selects a method, call the cart update endpoint with 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 cartId from the session and redirect to the confirmation page.

Pattern 5: Confirmation Page

The confirmation page is server-rendered: it fetches the order directly from commercetools by 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.
Both flows (cart checkout and quote checkout) redirect to /checkout/confirmation?orderId=<id> on success.
Find the adapter's concept-mapping.md. Example: Next.js: the Server Component shell (app/[locale]/checkout/confirmation/[orderId]/page.tsx with await 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
  • cartId cleared from session after the SDK signals order completion
  • Confirmation page is server-rendered and fetches the order by ID from commercetools
core/ct-client.md

commercetools Client & Session

Impact: CRITICAL — This is the foundation. Every other reference depends on 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 under references/stack/ — e.g. the Next.js stack.

Table of Contents


Pattern 1: SDK Client Singleton

See sdk-setup.md for the 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

See sdk-setup.md for the full .env template, auth URLs by region, and required API client scopes.
If the stack signs session tokens (stateless BFF), the signing secret must be strong (≥ 32 characters), server-only, and never exposed to the client bundle — the Next.js stack calls it SESSION_SECRET. A stateful BFF instead keeps its session-store credentials server-only.

Pattern 3: Session Management

INCORRECT: Storing cartId or customerId in localStorage or a non-HTTP-only cookie — readable by XSS and not server-authoritative.
CORRECT — the BFF owns server-authoritative session state; the client holds only an opaque, HTTP-only reference. The storage mechanism is a stack choice — both are valid:
  • 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.
Either way the cookie is HTTP-only (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.
The session module exposes the same operations regardless of storage: 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
}
Session fields:
FieldSet whenCleared when
customerIdLogin/registerLogout
cartIdCart created or loginOrder placed
country/currency/localeCountry selectorNever (persists)

The storage mechanism and cookie read/write binding are stack-specific.

Find the adapter's data-loading.md for parrents of implementation. Example Next.js (stateless BFF): a jose-signed JWT in an HTTP-only cookie via cookies() (next/headers) + NextResponse.cookies, with getSession / getLocale / createSessionToken / setSessionCookie / clearSessionCookie — see the full <server>/session module in the adapter's data-loading.md.

Pattern 4: BFF Boundary

INCORRECT: Calling <server>/ct/* directly from a client component or a browser-side fetcher.
CORRECT — every commercetools call goes through a server endpoint:
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

INCORRECT: Inlining 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.
CORRECT — one function per operation, all in the matching <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;
}
commercetools namespace files:
FileOwns
<server>/ct/clientapiRoot singleton
<server>/ct/authsignInCustomer, signUpCustomer, getCustomerById, updateCustomer
<server>/ct/cartAll cart operations (create, addLineItem, removeLineItem, discounts, shipping)
<server>/ct/ordersgetOrderById, getCustomerOrders
<server>/ct/categoriesgetCategoryBySlug, getCategoryTree
<server>/ct/searchsearchProducts, getProductBySku

Pattern 6: Connection Health Check

After wiring up the client, verify credentials with a one-off health endpoint that calls 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 raw fetch() 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
core/customer-auth.md

Customer Authentication — Shared Foundation

Impact: HIGH — The wrong login endpoint or incomplete logout cache-clearing causes silent failures on every auth operation.
This reference covers the shared patterns: the correct commercetools login endpoint, server endpoint structure, the client-state hook for account data, and logout cache-clearing. Domain-specific auth patterns (B2C anonymous cart merge; B2B BU auto-selection and channel resolution) are documented in the respective skill's customer-auth.md.

Table of Contents


Pattern 1: commercetools Login Endpoint

INCORRECT: Using 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();
CORRECT — 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
The login endpoint does four things, in order:
  1. Validate that email and password are present (400 otherwise).
  2. Call loginCustomer(email, password) (which uses apiRoot.login().post() — Pattern 1).
  3. Build a server-managed session carrying at minimum customerId, customerEmail, customerFirstName, customerLastName and persist it. The storage mechanism (a signed token in a cookie, or a server-side session store) is a stack choice.
  4. 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's customer-auth.md shows the full handler with these additions.
The concrete login server endpoint follows the BFF endpoint shell, find it in data-loading.md of the adapter's.

Pattern 3: useAccount Client State Hook

INCORRECT: Reading customerId from localStorage or a cookie on the client — not reactive, not server-safe.
CORRECT — a 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.
The backing server endpoint reads the session and returns the customer object — or null if unauthenticated, or if getCustomerById(session.customerId) throws. It never throws to the client.
Find the adapter's concept-mapping.md to see client state/cache implementation.
B2B storefronts use GET /<api>/auth/me and an auth-context wrapper in addition to the hook — see B2B customer-auth.md for the full pattern.

Pattern 4: Logout — Session and client state-manager/cache Clearing

INCORRECT: Clearing only the auth cache after logout — cart and other user data remain visible until next page load. Calling the logout endpoint and then evicting only KEY_ACCOUNT leaves stale cart/order data in the client state-manager/cache.
CORRECT — clear all user-scoped client state-manager/caches and end the session. The logout handler:
  1. Calls the logout server endpoint (POST /<api>/auth/logout).
  2. 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_ACCOUNT and KEY_CART (B2B also evicts KEY_BUSINESS_UNITS).
  3. Navigates to /login using the framework's client navigation.
The logout endpoint writes a fresh session that preserves locale, currency, country and omits all user fields (customerId, cartId, and any domain-specific fields), then returns success.

Checklist

  • <server>/ct/auth uses apiRoot.login().post() — NOT apiRoot.customers().login()
  • Login endpoint writes the session with at minimum customerId and customer name fields
  • useAccount hook uses KEY_ACCOUNT as its cache key and does not revalidate on focus
  • Logout endpoint preserves locale, currency, country and clears user fields
  • Logout clears both KEY_ACCOUNT and KEY_CART from the client state-manager/cache
Domain extensions:
core/data-loading.md

Data Loading

Impact: HIGH — Calling commercetools from a Client Component or importing <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

This is the core data-loading decision and it is framework-agnostic. Use a server-rendered load for first-paint data — no spinner, no hydration delay, SEO-friendly. Use client-fetched data (client state) only for data that changes after user interaction.
DataPatternReason
Initial product listServer-renderedFirst paint, SEO, no spinner
Category treeServer-rendered + TTL cacheStable, needs SSR
CartClient-fetched (client state)Changes after add/remove actions
Account / ordersClient-fetched (client state)Changes after login
Search resultsServer-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 session to commercetools functions rather than calling getSession() inside each function
  • Return a not-found response for missing required resources
Find adapter's data-loading.md file for implementation of this decision (async Server Component vs SWR hook → Route Handler)

Pattern 2: commercetools Type Boundary

commercetools responses must be mapped to app types before leaving <server>/ct/. Components import from <server>/types — never from @commercetools/platform-sdk.
Mappers live in <server>/mappers/. Each file maps one commercetools resource to one app type:
FileMaps
<server>/mappers/productProductProjectionProduct
<server>/mappers/categorycommercetools Category → app Category
<server>/mappers/cartcommercetools Cart → app Cart
<server>/mappers/ordercommercetools Order → app Order
<server>/mappers/line-itemcommercetools LineItem → app LineItem
<server>/mappers/customercommercetools Customer → app Account
<server>/mappers/moneycommercetools TypedMoney → app Money
<server>/mappers/facetcommercetools 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:

  1. Validate session
  2. Call <server>/ct/<namespace>.ts — never the commercetools SDK directly
  3. Return JSON with the correct status
Never put raw SDK calls in a server endpoint. Never call the endpoint (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 in data-loading.md of the adapter's.

Pattern 4: Version Conflict

commercetools uses optimistic locking — every cart mutation needs the current version. When two requests arrive simultaneously one will be rejected with 409 ConcurrentModification. Re-fetch the entity's version before the action.
The refetch logic belongs in <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

Cache stable, rarely-changing public data (category tree, project config) with your framework's server-side cache-with-TTL primitive. Such a cache is shared across all requests — never cache per-user or per-session data this way; use client state (client-side) or a direct per-request <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>.ts via mappers
  • getLocalizedString called 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
core/image-config.md

Image Config

Impact: LOW — All product image URL transforms are in <root-dir>/<server>/ct/image-config. Edit the config — never components.
Three named functions cover the three image contexts. Components import them directly; swap the implementation to change all images site-wide. The transform functions here are framework-agnostic (plain string manipulation). The rendering side — the framework's image primitive, optimizer settings, responsive sizing, LCP priority — is framework-specific; See the adapter's best-practices/image.md file.

Table of Contents


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;
}
Each function receives the raw commercetools image URL (e.g. 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

Insert a size suffix before the file extension, preserving any query string:
// <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

Imgix — append query params to the imgix domain:
// <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`;
}
Cloudinary — use the fetch delivery URL:
// <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

Export a new function from 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)
Do not inline the transform in the component — keeping it in image-config.ts means a single config change updates all instances.
core/optional/recurring-orders.md

Recurring Orders

Impact: HIGH — Recurring orders are the backbone of a subscription feature. The 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.
A 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

ResourceSDK AccessorNotes
RecurringOrderapiRoot.recurringOrders()Project-level — not under as-associate
RecurrencePolicyapiRoot.recurrencePolicies()Project-level; defines schedule
Both are accessed through the project-level 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

Always scope recurring order queries to the owner using a where clause. Without scoping, an admin-credential client returns all recurring orders in the project.
  • B2C: customer(id="${customerId}")
  • B2B: businessUnit(key="${businessUnitKey}")
See the context-specific files for the full 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.

After expanding, fall back defensively: the top-level 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

All state changes use the setRecurringOrderState update action with a read-then-write pattern:
  1. Fetch the recurring order to get its current version
  2. POST with that version and the setRecurringOrderState action
IntentrecurringOrderState 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.

Context-specific files may add additional update actions (e.g. skip, setSchedule in B2C).

Pattern 4: Creating a Recurring Order

Recurring orders are created as a consequence of checkout, not from a dedicated create form. After an order is placed that contains subscription line items, create one RecurringOrder per subscription line item — not one per order.
Failure handling: if 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.
The schedule is derived from the 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.

A 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:

MethodPathActionNotes
GET/<api>/[prefix]List recurring ordersScoped by owner; auth fields differ by context
GET/<api>/[prefix]/[id]Fetch single orderAlways expand: ['originOrder']
PUT or POST/<api>/[prefix]/[id]State transitionsSee context-specific for endpoint style
GET/<api>/recurrence-policiesList all policiesNo owner scoping needed
There is no 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.

The client state-manager/cache key for the list should encode the ownership scope (include customerId or businessUnitKey) so the cache auto-invalidates when the user switches context.
Find the stack's concept-mapping.md for 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.
Storefront client scope: the storefront's commercetools credentials need manage_recurring_orders scope separately from the admin/tools client. Do not assume admin scopes cover storefront calls.
core/optional/recurring-prices.md

Recurring Prices

Impact: MEDIUM — Recurring prices are the UI entry point for subscriptions. Getting the gating wrong (showing subscription UI on ineligible products, or hiding it when prices exist) is a UX defect.
Recurring prices represent the discounted or fixed price a customer pays when they commit to a recurring delivery. They are regular commercetools price entries augmented with a reference to a RecurrencePolicy. The policy reference is the link that connects a price to a schedule.

Table of Contents


Pattern 1: The Recurring Price Signal

A price is "recurring" when it carries a 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.
commercetools stores recurring prices in a dedicated array on the product variant: 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[].
Each entry in recurrencePrices has the same money shape as a regular price entry plus a recurrencePolicy: { id, typeId: 'recurrence-policy' } reference.
The mapped 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

Extract the recurring price signal in the product 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.
The mapped Price interface must include:
recurrencePolicy?: { id: string }   // present iff this is a recurring price
Map 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

On the PDP, split prices by the presence of recurrencePolicy:
  • regularPrice — the first price without recurrencePolicy
  • recurringPrices — all prices with recurrencePolicy

Gate the subscription UI on two conditions:

  1. A context-specific eligibility check — see B2B/B2C files (e.g. login state, BU membership)
  2. recurringPrices.length > 0 for the selected variant

If either condition is false, render the standard Add to Cart button only.

Component chain: PDPAddToCart → PDPActions → SubscribeAndSave / SubscribeAndSaveBox
The separation into regularPrice 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.

Props the component needs:
  • oneTimePrice — the regular price for the one-time option
  • recurringPrices — array of recurring prices, each with a recurrencePolicy.id
  • policies — the full RecurrencePolicy[] list for resolving names and schedules
  • value'one-time' or a recurrencePolicyId
  • onChange — callback receiving 'one-time' or the selected policy ID
Policy name lookup: map price.recurrencePolicy.idpolicies.find(p => p.id === id)?.name. Render the raw ID as a fallback if the policy is not in the list.
Show only policies that have a price: filter 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 no recurrenceInfo. Do not pass recurrencePolicyId at all — do not pass it as undefined or null.
  • If selected value is a policy ID: attach recurrenceInfo to the addLineItem action.
The 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

Filter by currency and country before rendering: recurringPrices may contain entries for multiple currencies and countries. Filter to the current locale's currency + country before passing them to the selector.
Policy display labels: use a 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.
client state-manager/cache deduplication for policies: two hooks typically exist — one returning 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's concept-mapping.md for concrete implementation.
Do not add recurrencePrices to PRODUCT_PROJECTION_EXPANDS: they come back automatically in product projections. Adding them to the expand list has no effect.
core/performance.md

Performance

Impact: MEDIUM — Correct patterns are already enforced by the architecture (server-rendered loads, BFF). Violations show up as waterfalls, N+1 queries, or unnecessary client re-fetches.

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

INCORRECT: Awaiting independent fetches sequentially — this creates a waterfall where each request waits for the previous one:
// 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
CORRECT — 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)
Category page example — category metadata and tree must be parallel:
// 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 in Promise.all. The most common violation is awaiting getSession() before calling getLocale() when neither needs the other.

Pattern 2: TTL Cache for Stable commercetools Data

INCORRECT: Re-fetching the commercetools project configuration on every request — this data changes rarely and adds ~50 ms to every page render.
CORRECT — wrap rarely-changing data in your framework's server-side cache-with-TTL. For example, validate 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.
What to cache:
DataCache TTLReason
commercetools project config (countries, currencies)300 sChanges only on project reconfiguration
Category tree60 sRarely edited; high reuse across pages
Shipping methods60 sRarely edited; no per-user variation
Product pricesDo not cacheCan change on promotion rules; per-currency
Cart dataDo not cachePer-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 the data-loading.md file 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

INCORRECT: Letting the client state-manager/cache fetch the cart and account on initial page load — this causes a loading-spinner flash on first render.
CORRECT — inject server-fetched data into the client state-manager/cache at the root so the first render has data:
  • Pre-fetch the cart server-side only if session.cartId exists; 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.
The session already carries 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

Image rendering and URL transforms are covered in image-config.md (transform functions are framework-agnostic). The performance-critical rules in brief:
  • 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's best-practices/image.md file.

Pattern 5: N+1 Anti-Patterns to Avoid

Category breadcrumb — N+1 parent lookups

INCORRECT: Fetching each parent category one by one:
// 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 — fetch the full tree once, walk it in memory:
// 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

INCORRECT: Fetching each variant's price separately after a product list query:
// WRONG — 1 extra commercetools call per product
for (const product of products) {
  product.price = await getVariantPrice(product.id, currency, country);
}
CORRECT — pass 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

INCORRECT: Awaiting user before fetching orders:
// WRONG — sequential
const customer = await getCustomerById(session.customerId);
const orders = await getCustomerOrders(session.customerId);
CORRECT — parallel, both need only 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
  • initialUser is built from session fields — no extra getCustomerById call 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 getCategoryById calls
  • Product search passes priceCurrency/priceCountry — no post-query price fetches
core/product-detail.md

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]
Don't mix strategies — your getProductBy* helper must match the chosen identifier.

PDP Page (server-rendered)

The PDP is server-rendered. Fetch all independent data with 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
Return the not-found response immediately when the product is null — don't render a fallback. See the adapter's concept-mapping.md.

Variant URL Strategy

Switching variants updates only the [sku] URL segment — the server-rendered page re-runs automatically. No client-side fetch needed.
  • Product lookup always uses sku or productId, never slug
  • slug in 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

Derive SEO metadata from the product, fetched with the same context as the page — a mismatch can serve a wrong SEO title or description:
// e.g. title: product.metaTitle ?? product.name; description: product.metaDescription ?? product.description
const product = await getProductBySku(sku, ...).catch(() => null);
if (!product) return {};
(Next.js: 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.all for 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
core/search-facets.md

Search facets

Impact: HIGH — Wrong implementation results in error

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

The complete lifecycle of faceted search: building the facet request from the catalog schema, surfacing the facet response in the UI, letting users make selections that round-trip through the URL, and translating those selections back into a commercetools postFilter on the next search call.

Source of truth: product type attribute definitions

The commercetools catalog declares which attributes are filterable through the 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.

Return 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.
Cache this result aggressively (at least one hour) with the framework's server-side cache-with-TTL (Next.js example: 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).
Skip reference and nested — they are not meaningfully facetable.
Ranges — numeric or temporal: number, money, date, datetime, time (and set_* variants)
A single open-ended { from: 0 } is a valid starting point — it surfaces counts and can be replaced with business-defined buckets later.
Distinct — everything else: 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.

Every other distinct type (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:

  • Stockvariants.availability.isOnStock, fieldType: boolean, distinct
  • Pricevariants.prices.centAmount, fieldType: number, ranges

Language on every facet expression

Pass the session locale as 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.
The facet 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

Alongside the facet expressions, maintain a metadata map keyed by facet name that stores each facet's 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.
When the user has active selections (passed in as Record<string, string> from f_* URL params), translate them into a commercetools _SearchQuery for postFilter:
  • Distinct, single valueexact expression with field, fieldType, language, value
  • Distinct, multiple values — wrap multiple exact expressions in or
  • Boolean — parse "true"/"false" string to a boolean before putting it in exact
  • Ranges — parse the bucket key (format: <from>-<to>, * for open-ended) into numeric gte/lte bounds; use SearchNumberRangeExpression for number/money, SearchLongRangeExpression otherwise
  • Multiple active facets — combine all per-facet clauses with and
Use 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

Store each active facet selection as a 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
When any filter changes, reset offset to 0 — otherwise users land mid-paginated results.

Client-side filter panel

The filter panel is a client component that receives facets and searchRequest as serializable props from the server-rendered page. It:
  1. Reads f_* URL params from the framework's query-param API (Next.js: useSearchParams) to reconstruct current selections
  2. Builds a name→expression lookup from searchRequest.facets to know each facet's kind
  3. Iterates the response facets, skipping any with no non-zero buckets (nothing to show)
  4. Dispatches to a DistinctFacet or RangeFacet component based on the request expression kind
  5. Shows an ActiveFilters strip at the top when any selections are active
  6. 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's concept-mapping.md for concrete route boundary implementation.

Distinct vs Range rendering

DistinctFacet — renders a checkbox per non-zero bucket. Multi-select: toggling a value adds or removes it from the comma-separated URL param.
RangeFacet — renders each bucket as a clickable option (single-select). The bucket key is the commercetools-formatted range string and goes directly into the URL; no client-side parsing needed since the server-side postFilter builder handles it.
ActiveFilters — renders a pill per active selection with an × to clear it individually, and a "Clear all" button when more than one is active.

Checklist

  • Never use any, unknown for type checking specially for facets. Always use types provided by @commercetools/platform-sdk
  • Skip reference and nested attribute types
  • Mapped set attribute type to set_* inner type name
core/shopping-lists.md

Shopping Lists

Impact: MEDIUM — Shopping lists (wishlists and purchase lists) are both backed by the commercetools 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.
Shopping lists let customers save products for later. In B2C they are personal (a wishlist). In B2B they are BU-shared (a purchase list). Both use the same commercetools resource and the same action vocabulary — addLineItem, removeLineItem — so most patterns are identical once you pick the right API chain.

Table of Contents


Pattern 1: The Two API Chains

Contextcommercetools chainOwnership enforced by
B2C personal wishlistapiRoot.shoppingLists() (project-level)App code — verify list.customer.id === customerId after a by-ID fetch
B2B purchase listapiRoot.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 name and any context-specific fields (see B2B/B2C extensions)
  • Rename — single changeName update action
  • Add itemaddLineItem with productId, variantId, and quantity
  • Remove itemremoveLineItem by lineItemId
  • Delete — by ID and version
All write operations need the current version. Fetch it fresh before sending the update if the caller does not already hold it — stale versions cause 409 conflicts.
commercetools 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:

ContextRequired session fields
B2C wishlistcustomerId
B2B purchase listcustomerId + businessUnitKey

Route structure is symmetric in both cases:

MethodPathIntent
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]/itemsAdd an item
DELETE/<api>/[resource]/[id]/itemsRemove 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:

ContextCache 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's concept-mapping.md for concrete state and cache implementation.

Pattern 5: Mapper

The mapper converts a commercetools ShoppingList to the app type. At minimum it should resolve:
  • id, version
  • name — resolved from LocalizedString via getLocalizedString(list.name, locale)
  • lineItems — mapped to an array of { lineItemId, productId, variantId, quantity, name, image, price } where image and price come from the expanded variant
Request expand: ['lineItems[*].variant'] on list fetches so the mapper has the variant data it needs.

Tips and Tricks

Stale version conflicts (409): Always re-fetch the list and send the version you fetched.
Ownership check on by-ID fetch: The project-level endpoint does not filter by customer. After fetching a wishlist by ID, verify the returned 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.
Empty wishlist vs. deleted wishlist: Do not auto-delete a list when the last item is removed. Users expect the empty list to remain so they can add to it again without recreating it.
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.
Locale in list name: Store the list name as a LocalizedString even for single-locale projects. This avoids a data migration later and is what commercetools expects on the create draft.
next-best-practices/error-handling.md

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 redirect
  • permanentRedirect() — 308 permanent redirect
  • notFound() — renders not-found.tsx
  • forbidden() — renders forbidden.tsx
  • unauthorized() — renders unauthorized.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

Catches errors thrown in the root layout. Must include <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

Call 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>
  )
}
Place 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
A segment-level 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')
Redirects work in Server Components, Server Actions, and Route Handlers. They do not work in Client Components — use router.push() there.
next-best-practices/image.md

Image Optimization

Project Rule: unoptimized: true Is Intentional

site/next.config.ts sets images.unoptimized: true. Do not remove it.
commercetools CDN returns 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

Components never build image URLs inline. Import from 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}
/>
Never inline a URL transform in a component — changing image-config.ts updates all instances at once.

Always Use next/image — Never <img>

Even with 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)

For images not from the commercetools CDN, add the hostname to remotePatterns in next.config.ts:
// site/next.config.ts
images: {
  unoptimized: true,
  remotePatterns: [
    {
      protocol: 'https',
      hostname: 'assets.example.com',
      pathname: '/media/**',
    },
  ],
},

fill + sizes

Always pair 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" />
The parent container must have position: relative and an explicit height.

Priority for LCP Images

Add 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' }} />
next-best-practices/metadata.md

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',
}
The root layout sets the title template, so page-level title values are automatically formatted as My Page | Home.

Dynamic Metadata

Use 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

Place 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 }
  )
}
Rules for OG images:
  • Use next/og, not @vercel/og
  • No searchParams access — use route params only
  • Do not set export const runtime = 'edge' — default Node.js runtime works fine
  • ImageResponse uses Flexbox; CSS Grid is not supported; all styles must be inline objects
A single opengraph-image.png at the root covers both Open Graph and Twitter (Twitter falls back to OG).

Metadata File Conventions

Files placed in app/ (or any route segment) are picked up automatically — no code needed:
FilePurpose
favicon.icoBrowser tab icon
opengraph-image.png / .tsxOG + Twitter card image
sitemap.tsSitemap (use generateSitemaps for large catalogs)
robots.tsCrawl directives
next-best-practices/server-components.md

Server Component Boundaries

Server Components run on the server and render to HTML — they cannot attach JS event listeners. Any prop that is a function (e.g. 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>.
INCORRECT: Adding an onChange handler directly on a <select> inside a Server Component.
CORRECT: Extract the element with the event listener into a '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.

stack/nextjs/best-practices/error-handling.md

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 redirect
  • permanentRedirect() — 308 permanent redirect
  • notFound() — renders not-found.tsx
  • forbidden() — renders forbidden.tsx
  • unauthorized() — renders unauthorized.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

Catches errors thrown in the root layout. Must include <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

Call 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>
  )
}
Place 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
A segment-level 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')
Redirects work in Server Components, Server Actions, and Route Handlers. They do not work in Client Components — use router.push() there.
stack/nextjs/best-practices/image.md

Image Optimization

Project Rule: unoptimized: true Is Intentional

<root-dir>/next.config.ts sets images.unoptimized: true. Do not remove it.
commercetools CDN returns 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

The URL transform functions themselves (CDN swap, Imgix, Cloudinary, suffix sizing) are framework-agnostic and documented in the generic skill's core/image-config.md. This file covers only the Next.js rendering side (next/image).
Components never build image URLs inline. Import from 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}
/>
Never inline a URL transform in a component — changing image-config.ts updates all instances at once.

Always Use next/image — Never <img>

Even with 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)

For images not from the commercetools CDN, add the hostname to remotePatterns in next.config.ts:
// <root-dir>/next.config.ts
images: {
  unoptimized: true,
  remotePatterns: [
    {
      protocol: 'https',
      hostname: 'assets.example.com',
      pathname: '/media/**',
    },
  ],
},

fill + sizes

Always pair 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" />
The parent container must have position: relative and an explicit height.

Priority for LCP Images

Add 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' }} />
stack/nextjs/best-practices/metadata.md

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',
}
The root layout sets the title template, so page-level title values are automatically formatted as My Page | Home.

Dynamic Metadata

Use 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

Place 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 }
  )
}
Rules for OG images:
  • Use next/og, not @vercel/og
  • No searchParams access — use route params only
  • Do not set export const runtime = 'edge' — default Node.js runtime works fine
  • ImageResponse uses Flexbox; CSS Grid is not supported; all styles must be inline objects
A single opengraph-image.png at the root covers both Open Graph and Twitter (Twitter falls back to OG).

Metadata File Conventions

Files placed in app/ (or any route segment) are picked up automatically — no code needed:
FilePurpose
favicon.icoBrowser tab icon
opengraph-image.png / .tsxOG + Twitter card image
sitemap.tsSitemap (use generateSitemaps for large catalogs)
robots.tsCrawl directives
stack/nextjs/best-practices/server-components.md

Server Component Boundaries

Server Components run on the server and render to HTML — they cannot attach JS event listeners. Any prop that is a function (e.g. 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>.
INCORRECT: Adding an onChange handler directly on a <select> inside a Server Component.
CORRECT: Extract the element with the event listener into a '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.

stack/nextjs/concept-mapping.md

Concept → Next.js Primitive Mapping

This is the spine of the adapter. The 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 placeholderNext.js (this stack)
<root-dir>/ — application root directorysite/
<server>/ — server-side code rootlib/
<api>/ — client-facing API surface the browser callsapp/api/ (Route Handlers — app/api/<resource>/route.ts)
<server>/ct/* — commercetools helperslib/ct/*
<server>/ct/clientapiRoot singletonlib/ct/client.ts
<server>/types — app type-mapping root (boundary types)lib/types.ts
<server>/mappers/ — commercetools→app mapperslib/mappers/
<server>/cache-keys — client-state keyslib/cache-keys.ts
<server>/session — session read/write modulelib/session.ts
<server>/utils — shared utils (COUNTRY_CONFIG, money/locale)lib/utils.ts
Client state — mutable per-user data layerSWR (useSWR + mutate / SWRConfig); see Client state hooks
Client state hooka SWR hook in hooks/*.ts ('use client')
Client state providera React context in context/*.tsx
Server-managed sessiona signed JWT in an HTTP-only cookie (jose, stateless BFF); see data-loading.md

Lookup table

Generic conceptNext.js primitive (App Router)
Server-rendered data loadasync Server Component (app/[locale]/.../page.tsx) calling lib/ct/* directly
Resolve route paramsconst { slug, locale } = await paramsparams is a Promise in Next 15+
Server endpoint (BFF)Route Handler app/api/<resource>/route.ts exporting GET/POST/PATCH/DELETE
Server endpoint directory layoutapp/api/{auth,account,cart,checkout,shipping-methods,channels}/...
Client component / browser bundle'use client' file
Read/write the (server-managed) sessionlib/session.ts using cookies() from next/headers + NextResponse.cookies.set(...); signed-JWT-in-cookie (stateless BFF) — see data-loading.md
Not-found responsenotFound() from next/navigation → renders not-found.tsx
Redirectredirect() / permanentRedirect() from next/navigation (server); useRouter().push() (client) — never wrap redirect() in try/catch
Route-segment error boundaryerror.tsx / global-error.tsx
Auth-gated responsesunauthorized()unauthorized.tsx (401); forbidden()forbidden.tsx (403)
Client-side navigationuseRouter() from @/i18n/routing (router.push/router.replace)
Locale-aware link primitiveimport { Link } from '@/i18n/routing' — never bare next/link
Locale routing configi18n/routing.ts (defineRouting + createNavigation) + i18n/request.ts (getRequestConfig); next-intl@^4 — see project-layout.md
Locale URL prefix + redirectproxy.ts middleware + localePrefix: 'always'; routes under app/[locale]/
Server-side cache-with-TTL for stable CT dataunstable_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 layoutapp/layout.tsx (root) + app/[locale]/layout.tsx (providers: NextIntlClientProvider, CartProvider)
Page-level SEO metadataexport const metadata (static) / export async function generateMetadata (dynamic) — Server Components only — see best-practices/metadata.md
OG/social card imageopengraph-image.tsx via next/og ImageResponse
Product image renderingnext/image with unoptimized: true — see best-practices/image.md
Health check (verify CT credentials)app/api/health/route.tsapiRoot.get().execute() (delete before deploy)
App framework confignext.config.ts wrapped with createNextIntlPlugin('./i18n/request.ts')
StylingTailwind v4 — no config file, @tailwindcss/postcss, @import 'tailwindcss' in globals.css
Deploy targetvercel.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, and getLocalizedString/formatMoney are identical in both skills — only their location (<server>/lib/) and the render/state primitives around them differ. SWR and jose are 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 async by default — add 'use client' only when the page needs browser APIs.
  • notFound(), redirect() etc. come from next/navigation and must be called outside any try/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;
}
Each step component repeats the guard, redirecting back when prerequisites are unmet. The decision logic (which step the cart state allows) is documented framework-neutrally in the generic 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 */);
}
The generic rule (fetch the order server-side by id; do not trust a freshly-revalidated client state-manager/cache) lives in core/checkout-page.md; the Server Component + async params shape is the Next mapping.

Client state hooks (SWR)

The generic client state hook with mutations maps to a SWR hook. Reads return safe defaults; mutations update the cache from the response body and throw on error.
// 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.
stack/nextjs/data-loading.md

Data Loading — Next.js Implementation

The 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/*.
This file pins those decisions to Next.js App Router primitives. The decision rule itself is generic — see core/data-loading.md in the generic skill.

Session module — lib/session.ts (signed-JWT realization)

This is the Next.js stateless-BFF realization of the generic server-managed session: a signed JWT in a single HTTP-only cookie (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

The generic skill's "server endpoint" with exactly three responsibilities (validate session → call 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 });
  }
}
Never put a raw commercetools SDK call in the Route Handler — it delegates to lib/ct/<namespace>.ts (generic rule). The data flow is: 'use client' hook → fetch('/api/...') → Route Handler → lib/ct/*apiRoot.
Directory conventions:
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)
Examples of the domain endpoints (cart GET clearing non-Active carts, auth login writing the session, shipping-methods filtering by currency) follow this same shape — their logic is documented per-feature in the generic skill; the Next wrapper is always this Route Handler shell.

Server-side caching — unstable_cache

The generic "cache stable public data with a TTL; never per-user/session" maps to 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 }
);
DataCache TTLReason
commercetools project config (countries, currencies)300 sChanges only on project reconfiguration
Category tree60 sRarely edited; high reuse across pages
Shipping methods60 sRarely edited; no per-user variation
Product pricesDo not cacheChange on promotion rules; per-currency
Cart / account dataDo not cachePer-session, changes frequently
Prefer unstable_cache over 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-request lib/ct/* call (server) for user-specific data.

SWR hydration from the server — SWRConfig fallback

The generic "hydrate the client state-manager/cache from server-fetched data to avoid a spinner flash" maps to 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>
  );
}
The session JWT already carries 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 (and searchParams) are Promises in Next 15+ — always await them in pages, generateMetadata, and opengraph-image.tsx.
  • When generateMetadata and the page component fetch the same resource, wrap the lib/ct/* fetch in React cache() so it runs once per request. See best-practices/metadata.md.

Connection health check

Verify credentials once after wiring the client, then delete before deploying:
// 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"}
stack/nextjs/overview.md

Next.js Stack — commercetools Storefront

Next.js stack adapter. This is the Next.js (App Router) implementation layer for the commercetools-storefront skill. 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's core/, b2c/, and b2b/ 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

TaskReference
Generic concept → Next.js primitive lookup table; routing, navigation, error, and metadata shellsconcept-mapping.md
Project layout (<root-dir>/, app/[locale]/, app/api/), next.config.ts, next-intl wiring, proxy.ts, Tailwind v4, version gates, deploy filesproject-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 routedata-loading.md
next/image config, unoptimized, remotePatterns, fill+sizes, LCP prioritybest-practices/image.md
Static & dynamic metadata, generateMetadata, OG images, React cache() dedupbest-practices/metadata.md
Server vs Client Component boundary, no-function-props rulebest-practices/server-components.md
error.tsx, not-found.tsx, redirect()/notFound() gotchas, unstable_rethrowbest-practices/error-handling.md

Commands

TaskCommand
Scaffold a new Next.js + commercetools storefrontRun /nextjs-setup-project
Deploy to VercelRun /nextjs-deploy-vercel
Deploy to NetlifyRun /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 with next@^16.

HIGH

  • Locale-aware linkimport { Link } from '@/i18n/routing', never bare import Link from 'next/link'. The next-intl Link preserves 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() in try/catch; they throw internal control-flow errors. Call them outside the try, or re-throw with unstable_rethrow. See best-practices/error-handling.md.

Anti-Patterns Quick Reference

Anti-patternCorrect approach
"next": "15.x" or next-intl < 4next@^16 and next-intl@^4
import Link from 'next/link' in a page componentimport { Link } from '@/i18n/routing'
Removing images.unoptimized: true from next.config.tsKeep it — the commercetools CDN rejects Next's optimizer query params
redirect() / notFound() inside a try/catchCall outside the try, or unstable_rethrow(error)
metadata / generateMetadata in a 'use client' pageMetadata is Server-Component-only — move client logic to a child
unstable_cache for per-user/session dataOnly for stable public data; per-user state uses SWR

How this maps to the generic skill

The generic skill is the source of truth for what to build; this adapter is how to build it in Next.js. Every framework-neutral term in the generic skill resolves through concept-mapping.md:
  • "server-rendered data load" → async Server Component calling lib/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 / useRouter from @/i18n/routing
The portable app conventions — lib/ct/*, lib/mappers/*, lib/types.ts, lib/cache-keys.ts, hooks/*, context/* — are identical in both skills; this adapter does not redefine them.
stack/nextjs/project-layout.md

Next.js Project Layout

This is the Next.js project shape that the generic 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)

PackageRequiredWhy
next^16 (must be > 16.0.0)Next.js 15.x has known security vulnerabilities
next-intl^4Compatible with next@^16 locale routing
@commercetools/platform-sdk^8Storefront SDK version
@commercetools/ts-client^4Token + middleware client
Also installed: 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 a connect.yaml and 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

No config file. 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

Both targets build from <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"
Run /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

TaskCommand
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
stack/nuxtjs/best-practices/error-handling.md

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 }))
})
Apply named middleware with definePageMeta({ middleware: ['auth'] }); a *.global.ts file runs on every route.

Triggering Errors — createError

Throw 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>
Call createError after the data resolves — not inside a try that catches and discards it.
In Nitro routes, throw 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

Catches fatal errors across the whole app. It receives an 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>
Branch on error.statusCode to render 404 vs 500 messaging — there is one root error page, not per-segment files.

Scoped Boundary — <NuxtErrorBoundary>

Wrap a widget so a non-fatal error in it doesn't take down the page. The #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>
Use this for optional, non-blocking sections (recommendations, recently-viewed). Use 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

NeedUse
404 / fatal page errorthrow createError({ statusCode, fatal: true })app/error.vue
HTTP error from a Nitro routethrow createError({ statusCode, statusMessage })
Require auth in a Nitro routerequireUserSession(event) (auto-401)
Gate a route in the approute middleware → return navigateTo(...) / return abortNavigation(...)
Non-blocking section failure<NuxtErrorBoundary>
Redirect (app)return navigateTo(...)
Redirect (Nitro)sendRedirect(event, ...)
Read current error stateuseError()
stack/nuxtjs/best-practices/image.md

Image Optimization

Project Rule: provider: 'none' Is Intentional

nuxt.config.ts sets image: { provider: 'none' }. Do not change it to ipx or another optimizer.
The commercetools CDN (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/image uses domains to allow-list remote hosts (there is no remotePatterns key). Add any non-CT host (CMS banners, avatars) to this array.

Product Images: Always Go Through image-config

The URL transform functions themselves (CDN swap, Imgix, Cloudinary, suffix sizing) are framework-agnostic and documented in the generic skill's core/image-config.md. This file covers only the Nuxt rendering side (<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>

Even with 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" />
Provide real display dimensions, not just an aspect ratio — width="16" height="9" tells the browser the image is 16px wide.

Priority for LCP Images

Add :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" />
stack/nuxtjs/best-practices/metadata.md

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

Set it once in 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'),
})
A page that sets title: 'Cart' then renders as Cart – Your Store.

Dynamic Metadata

When the title/description depend on fetched data (PDP, category, blog), pass getter functions to 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>
The 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

For dynamic social cards, add 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>
Components live in app/components/OgImage/ (auto-registered as templates). The inline <OgImage> component is the declarative equivalent of defineOgImage.

Metadata File Conventions

File / mechanismPurpose
app/public/favicon.icoBrowser tab icon
defineOgImage(...) / <OgImage>OG + Twitter card image (nuxt-og-image)
server/routes/sitemap.xml.ts or @nuxtjs/sitemapSitemap (use the module for large catalogs)
server/routes/robots.txt.ts or @nuxtjs/robotsCrawl 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.
stack/nuxtjs/best-practices/rendering.md

Rendering Boundary (SSR / Client)

Nuxt pages and components render twice: once on the server (Nitro) to produce HTML, then on the client during hydration. Code in 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

commercetools credentials live in 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}`))
Anything secret-bearing or commercetools-touching is reached only through a Nitro route under server/api/. See concept-mapping.md and data-loading.md.

Don't call bare $fetch in setup

A bare $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 */ }
For components that simply cannot render on the server (a third-party widget that reads 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

If the server HTML and the first client render differ, Vue logs a hydration mismatch and may discard the server markup. Avoid render output that depends on 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

On the server a module is shared across all concurrent requests, so a module-level 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')
State set during SSR — 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

ConcernRule
commercetools / secretsOnly in server/; reach via a Nitro route, never import server/ into the app
Initial datauseFetch / useAsyncData — not bare $fetch in setup
Mutations / events$fetch inside handlers and store actions
window / document / localStorageonMounted or import.meta.client; whole component → <ClientOnly>
Non-deterministic render (time, random)compute in onMounted or wrap in <ClientOnly>
Shared stateuseState(key, init) or Pinia — never a module-level ref
stack/nuxtjs/concept-mapping.md

Concept → Nuxt Primitive Mapping

This is the spine of the adapter. The 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

The generic skill writes paths and client-side data access as stack-neutral placeholders. This stack pins them. Nuxt 4 splits code across three roots: app/ (the Vue app, srcDir, runs server + client), server/ (Nitro, server-only), and shared/ (isomorphic, auto-imported in both):
Generic placeholderNuxt (this stack)
<root-dir>/ — application root directorystorefront/ (project root; nuxt.config.ts lives here, Vue source under storefront/app/)
<server>/ — server-side code rootserver/ (Nitro — never imported by the app)
<api>/ — client-facing API surface the browser callsserver/api/ (Nitro routes — server/api/<resource>.ts)
<server>/ct/* — commercetools helpersserver/utils/ct/* (auto-imported in server code)
<server>/ct/clientapiRoot singletonserver/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 mappersserver/utils/mappers/
<server>/cache-keys — client-state keysPinia store ids + useAsyncData keys (a shared/keys.ts is optional)
<server>/session — session read/write modulenuxt-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 layerPinia (app/stores/*.ts); useState for simple shared values; see Client state stores
Client state hooka Pinia store (app/stores/*.ts) or a composable (app/composables/*.ts)
Client state providernone needed — Pinia is globally available; SSR state hydrates via the Nuxt payload
Server-managed sessiona sealed (encrypted) cookie via nuxt-auth-utils (stateless BFF); see data-loading.md
Why shared/ for types and COUNTRY_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 the Product boundary type). shared/utils/** and shared/types/** auto-import in both runtimes and may not import Vue or Nitro APIs — keeping them isomorphic. Secret-bearing code (apiRoot) stays in server/utils/, never shared/.

Lookup table

Generic conceptNuxt 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 layoutserver/api/{auth,account,cart,checkout,shipping-methods,channels}/...
Client component / browser-interactive UIa Vue SFC under app/components/; wrap browser-only UI in <ClientOnly> — see best-practices/rendering.md
Read/write the (server-managed) sessionnuxt-auth-utils in server/: getUserSession / setUserSession / requireUserSession / clearUserSession; sealed-cookie (stateless BFF) — see data-loading.md
Not-found responsethrow createError({ statusCode: 404, fatal: true }) → renders app/error.vue
Redirectreturn navigateTo('/path', { redirectCode: 301 }) (app/middleware); sendRedirect(event, '/path', 302) (Nitro) — in middleware the call must be returned
Route-segment error boundaryroot app/error.vue; component-scoped <NuxtErrorBoundary>
Auth-gated responsesrequireUserSession(event) (throws 401 in Nitro); throw createError({ statusCode: 403 }) for forbidden
Client-side navigationnavigateTo(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 prefixstrategy: 'prefix' — the module owns prefixing, detection, and redirect
Server-side cache-with-TTL for stable CT datadefineCachedFunction(fn, { maxAge, name, getKey, swr }) or routeRules cache — never per-user/session — see data-loading.md
Per-request fetch dedupuseAsyncData/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 / providersapp/app.vue + app/layouts/default.vue; Pinia and i18n register via modules (no manual provider)
Page-level SEO metadatauseSeoMeta({ ... }) (reactive getters for dynamic data) + useHead for the title template — see best-practices/metadata.md
OG/social card imagenuxt-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.tsapiRoot.get().execute() (delete before deploy)
App framework confignuxt.config.ts (modules, runtimeConfig, i18n, image, nitro, vite)
StylingTailwind v4 — @tailwindcss/vite plugin + @import "tailwindcss" in a registered CSS file
Deploy targetNitro presets — nitro: { preset: 'vercel' } / 'netlify' (auto-detected in CI)
Portable, not remapped: the commercetools SDK calls (apiRoot.*, the as-associate chain), the mappers, and getLocalizedString/formatMoney are identical to the generic skill — only their location (<server>/server/utils/ and shared/) and the render/state primitives around them differ. nuxt-auth-utils, Pinia, and @nuxtjs/i18n are this stack's realizations of the generic server-managed session, client state, and locale routing concepts.

Page shape (server-rendered data load)

The generic "server-rendered data load" for catalog/immutable data. The page calls a Nitro route; 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/document belongs in onMounted or <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 a try that 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>
Each step component repeats the guard, redirecting back when prerequisites are unmet. The decision logic (which step the cart state allows) is documented framework-neutrally in 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>
The generic rule (fetch the order server-side by id; do not trust a freshly-revalidated client state-manager/cache) lives in core/checkout-page.md; the useAsyncData + route-param shape is the Nuxt mapping.

Client state stores (Pinia)

The generic client state hook with mutations maps to a Pinia store (setup syntax). Reads expose safe defaults; actions call $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 by businessUnitKey (/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) prefer useState over a full store.
stack/nuxtjs/data-loading.md

Data Loading — Nuxt Implementation

The commercetools-storefront skill decides what loads where:
  • Catalog / immutable data (category pages, PDPs, search results) → server-rendered load via useAsyncData/useFetch against a Nitro route, cached with a Nitro TTL.
  • Mutable per-user state (cart, account, orders, quotes) → a Pinia store whose actions call $fetch('/api/...').
Both paths cross the same boundary: the app never touches commercetools directly — a Nitro route under 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

The 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
  },
})
Functions defined in server/utils/ are auto-imported across server code, so getApiRoot() and the server/utils/ct/* helpers need no import statement inside Nitro routes.
The Nuxt realization of the generic server-managed session is a sealed (encrypted) cookie via 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.
The session shape mirrors the generic 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-utils supports an optional unstorage-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's country/currency/locale must drive pricing, persist them into the session at login/selection and read them in Nitro routes via getUserSession(event).

BFF Nitro route shape

The generic "server endpoint" has exactly three responsibilities (validate session → call 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',
    })
  }
})
Never put a raw commercetools SDK call in the route — it delegates to server/utils/ct/<namespace>.ts (generic rule). The data flow is: page/store → useFetch/$fetch('/api/...') → Nitro route → server/utils/ct/*getApiRoot().
Directory conventions (method suffix picks the verb; [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)
The domain endpoints (cart GET clearing non-Active carts, login writing the session, shipping-methods filtering by currency) follow this same shape — their logic is documented per-feature in the generic skill; the Nuxt wrapper is always this defineEventHandler shell.

Server-side caching — defineCachedFunction

The generic "cache stable public data with a TTL; never per-user/session" maps to Nitro's 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 }
)
A declarative alternative for whole routes — 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 } },
  },
})
DataCache TTLReason
commercetools project config (countries, currencies)300 sChanges only on project reconfiguration
Category tree60 sRarely edited; high reuse across pages
Shipping methods60 sRarely edited; no per-user variation
Product pricesDo not cacheChange on promotion rules; per-currency
Cart / account dataDo not cachePer-session, changes frequently
Prefer defineCachedFunction over 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-request server/utils/ct/* call for user-specific data. Cached entries persist in the Nitro cache storage (memory in dev; the platform KV/driver in production).

Hydration from the server — the Nuxt payload

The generic "hydrate the client state-manager/cache from server-fetched data to avoid a spinner flash" is automatic in Nuxt: anything resolved during SSR by useAsyncData/useFetch and any Pinia store state is serialized into the payload and reused on hydration without refetching.
Catalog datauseAsyncData/useFetch already hydrate. No extra wiring; the page renders with data on first paint.
Mutable user state (cart/account) — populate the Pinia store during SSR so it ships in the payload. 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
  })
})
Because the session cookie already carries 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') and getQuery(event); never reach for useRoute() server-side.
  • Give useAsyncData an explicit key that includes the param (useAsyncData(\product:${slug}`, ...)`) so navigations between slugs don't collide in the payload cache.

Connection health check

Verify credentials once after wiring the client, then delete before deploying:
// 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"}
stack/nuxtjs/overview.md

Nuxt Stack — commercetools Storefront

Nuxt stack adapter. This is the Nuxt 4 implementation layer for the commercetools-storefront skill. 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/i18n v10 + nuxt-auth-utils + Pinia + Tailwind v4 primitives. Use it together with the skill's core/, b2c/, and b2b/ 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

Nuxt has two runtimes: the Vue app (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.
Therefore every commercetools read or write — catalog and mutable — goes through a Nitro route under 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

TaskReference
Generic concept → Nuxt primitive lookup table; routing, navigation, page & store shapesconcept-mapping.md
Project layout (app/, server/, shared/), nuxt.config.ts, i18n wiring, Tailwind v4, version gates, deployproject-layout.md
The Nuxt side of data loading: nuxt-auth-utils sealed session, Nitro route shape, defineCachedFunction, SSR/Pinia hydration, route params, health-check routedata-loading.md
<NuxtImg> config, provider: 'none', domains, sizes, LCP preloadbest-practices/image.md
useSeoMeta / useHead, dynamic meta, title template, nuxt-og-image v6best-practices/metadata.md
SSR/client boundary, <ClientOnly>, hydration safety, server-only code, useState vs module refsbest-practices/rendering.md
createError, app/error.vue, <NuxtErrorBoundary>, navigateTo / sendRedirect, route middlewarebest-practices/error-handling.md

Commands

TaskCommand
Scaffold a new Nuxt 4 + commercetools storefrontRun /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 under app/ (the default srcDir); server code under server/; isomorphic code under shared/.
  • Secrets never leave Nitro — commercetools client id/secret live in runtimeConfig (server-only, no public key) and are read with useRuntimeConfig(event) inside server/. Never reference them from a component or a public config key.
  • @nuxtjs/i18n strategy — use strategy: 'prefix' so every route carries a locale prefix.
  • Session passwordnuxt-auth-utils requires NUXT_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 $fetch in setup() — bare $fetch in a component's setup runs twice (SSR + hydration). Use useFetch / useAsyncData for initial data; reserve $fetch for event handlers and Nitro routes / store actions.
  • createError/navigateTo are control flowcreateError({ ..., fatal: true }) throws; in route middleware navigateTo/abortNavigation must be returned. See best-practices/error-handling.md.
  • @nuxt/image must not transform CT URLs — set image: { provider: 'none' }. The commercetools CDN rejects optimizer query params. See best-practices/image.md.

Anti-Patterns Quick Reference

Anti-patternCorrect approach
Importing server/utils/ct/* into a page/componentCall it through a Nitro route via useFetch('/api/...') — never import server code into the app
commercetools secret under runtimeConfig.publicTop-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 imagesimage: { provider: 'none' } — CT CDN 403s on ?w=&q=
Module-level ref() for shared stateuseState(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

The generic skill is the source of truth for what to build; this adapter is how to build it in Nuxt. Every framework-neutral term resolves through concept-mapping.md:
  • "server endpoint (BFF)" → Nitro route server/api/<resource>.ts (defineEventHandler)
  • "server-rendered data load" → useFetch/useAsyncData against 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 / routeRules cache
  • "the framework's locale-aware link / client navigation" → useLocalePath() + <NuxtLink> / navigateTo
  • "server-managed session" → nuxt-auth-utils sealed encrypted cookie (stateless BFF)
The portable app conventions — the commercetools SDK calls (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.
stack/nuxtjs/project-layout.md

Nuxt Project Layout

This is the Nuxt 4 project shape that the generic 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)

PackageRequiredWhy
nuxt^4App source under app/; shared/ for isomorphic code; Nitro server layer
@nuxtjs/i18n^10Targets 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 + ^3Official Nuxt 4 state management
@nuxt/image^2<NuxtImg>; the none provider for CT CDN pass-through
@commercetools/platform-sdk^8Storefront SDK
@commercetools/ts-client^4Token + middleware client
tailwindcss + @tailwindcss/vite^4Tailwind v4 via the Vite plugin (not the legacy Nuxt module)
Optional: nuxt-og-image (^6) for dynamic OG images — see best-practices/metadata.md.

Directory structure

Nuxt 4 splits source across three roots. 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 from server/. Secrets live only in server/utils/ct/. Isomorphic helpers (types, COUNTRY_CONFIG, formatters) live in shared/ — 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 a connect.yaml and 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

Configure in 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,
}))
Always build localized hrefs through 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

Use the @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;
}
Do not use the legacy @tailwind base/components/utilities directives — v4 uses the single @import "tailwindcss".

Image config

Set the 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
  },
})
See best-practices/image.md for <NuxtImg> usage, sizes, and LCP preload.

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 varMaps to
NUXT_SESSION_PASSWORD (≥ 32 chars)nuxt-auth-utils sealed-cookie key
NUXT_CT_PROJECT_KEY / NUXT_CT_CLIENT_ID / NUXT_CT_CLIENT_SECRETruntimeConfig.ct* (server-only)
NUXT_CT_AUTH_URL / NUXT_CT_API_URLruntimeConfig.ctAuthUrl / ctApiUrl
Use a Frontend (non-admin) API client for these credentials. Delete server/api/health.get.ts before deploying.