Next.js + commercetools Storefront

Description

Production patterns for commercetools storefronts on Next.js 16, NextIntl v4, TypeScript, Tailwind v4, and JWT sessions — covering the full range from shared BFF foundation to B2C and B2B surface-specific features.

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-skills
/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

Next.js + commercetools Storefront

Production patterns for commercetools storefronts on Next.js 16, NextIntl v4, TypeScript, Tailwind v4, and JWT sessions — covering the full range from shared BFF foundation to B2C and B2B surface-specific features.

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 Next.js Route Handlers (app/api/). The browser never calls commercetools directly. Secrets never get a NEXT_PUBLIC_ prefix.
Sessions are JWT HTTP-only cookies. Session data is signed with jose (HS256), stored in a single your-store-session cookie, and read/written only in server-side code. SESSION_SECRET must be at least 32 characters and is never hardcoded.
Server Components for catalog data, SWR hooks for mutable user state. Category pages and PDPs are async Server Components that call lib/ct/* directly. Cart, account, and user-specific data use SWR hooks → Route Handlers → commercetools SDK.
commercetools login endpoint. Always use apiRoot.login().post()apiRoot.customers().login() does not exist in commercetools SDK v2.
lib/ct/* is server-only. Never import from any 'use client' file. Import types from lib/types.ts.
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 }).*. commercetools enforces associate permissions server-side.

Reference Index

Shared Foundation (references/core/)

TaskReference
Scaffold the app, Tailwind v4, next-intl routing, locale proxy, folder structureRun /commercetools-nextjs-setup-project
commercetools SDK singleton, JWT sessions, BFF Route Handler shapecore/ct-client.md
Shared auth patterns: commercetools login endpoint, Route Handler structure, SWR hook, logoutcore/customer-auth.md
Add a new country / currency / locale — COUNTRY_CONFIG flat structurecore/add-country.md
Parallel fetching, unstable_cache, SWR prefetch, N+1 avoidancecore/performance.md
Product image URL transforms (CDN, Imgix, Cloudinary)core/image-config.md
Cart CRUD, CartContext, SWR hook, mini-cart drawercore/cart.md
Full-text search, facet config, URL state, rendererscore/search-facets.md
Add a new BFF endpoint + SWR hook (the 3-layer pattern)core/add-api.md
Add a new standalone or CMS-driven pagecore/add-page.md
Server vs SWR 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, SWR 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 + SWR hookb2b/add-api.md
B2B data loading — server vs SWR, 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

Next.js Framework Patterns (references/next-best-practices/)

TaskReference
next/image usage, unoptimized: true, image-config transforms, LCP prioritynext-best-practices/image.md
Static & dynamic metadata, generateMetadata, OG images, cache() deduplicationnext-best-practices/metadata.md
Server vs Client Component boundary, event handler rulesnext-best-practices/server-components.md
error.tsx, not-found.tsx, redirect() and notFound() gotchas, unstable_rethrownext-best-practices/error-handling.md

Priority Tiers

CRITICAL

  • Next.js version — Always use next@^16. Never write "next": "15.x". Next.js 15.x has known security vulnerabilities.
  • NextIntl version — Always use next-intl@^4 compatible with next@^16.
  • BFF architecturelib/ct/* is server-only. Zero commercetools SDK imports in any 'use client' file.
  • Session secretsSESSION_SECRET and CTP_CLIENT_SECRET are server-only env vars, never hardcoded or NEXT_PUBLIC_.
  • 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.

HIGH

  • Parallel fetchingPromise.all for independent fetches in Server Components. No request waterfalls.
  • Type safety — Frontend components import types from lib/types.ts, never from lib/ct/*.
  • commercetools type boundary — Map commercetools SDK responses to app types in lib/mappers/ before they leave lib/ct/.
  • SWR cache invalidation — Mutate relevant cache keys after login, logout, and order placement.
  • B2B: BU key in SWR cache keys — all dashboard hooks use [KEY, businessUnitKey] tuple keys.

MEDIUM

  • Product Search API — Use apiRoot.products().search(), never the deprecated productProjections().search(). See the commercetools-platform skill → product-search.md.
  • unstable_cache — Wrap rarely-changing commercetools data with a TTL. Never use it for per-user or per-session data.

Anti-Patterns Quick Reference

Anti-patternCorrect approach
import { apiRoot } from '@/lib/ct/client' in a 'use client' fileUse a SWR hook → Route Handler → lib/ct/
fetch('/api/*') directly in a componentEncapsulate in a hook in hooks/
new ClientBuilder() inside a page or Route HandlerSingleton apiRoot in lib/ct/client.ts
Raw fetch() to commercetools REST endpointsAlways use apiRoot — the SDK manages OAuth tokens and refresh
NEXT_PUBLIC_CTP_CLIENT_SECRET=...Server-only env var, no NEXT_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 lib/types.ts; mapped in lib/mappers/
next-intl < 4 or next ≤ 16Use next@>16 and next-intl@^4
import Link from 'next/link' in a page componentimport { Link } from '@/i18n/routing'
B2B: apiRoot.carts().post(...) for a logged-in userasAssociate().withAssociateIdValue(...).inBusinessUnitKey(...).carts().post(...)
B2B: useSWR(KEY_ORDERS, ...) without BU keyuseSWR([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, Route Handler shape, commercetools helper structure, and SWR mutation rules.

This reference covers B2B-specific additions only: scoping SWR cache keys to BU and store, using the as-associate API chain in commercetools helpers, and validating both session fields in Route Handlers.


B2B Addition 1: SWR 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 — one BU's orders appear for every other BU
return useSWR(KEY_ORDERS, fetcher);
CORRECT — scope to businessUnitKey; add storeKey when data varies by store:
// lib/cache-keys.ts
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;
}
// hooks/useOrders.ts
export function useOrders() {
  const { currentBusinessUnit } = useBusinessUnit();
  const buKey = currentBusinessUnit?.key ?? null;

  return useSWR<Order[]>(
    buKey ? [KEY_ORDERS, buKey] : null,   // null = skip fetch when no BU selected
    ([, bk]) => ordersFetcher(bk),
    { revalidateOnFocus: false }
  );
}

export function useOrderMutations() {
  const { mutate } = useSWRConfig();
  const { currentBusinessUnit } = useBusinessUnit();

  async function cancelOrder(orderId: string) {
    const updated = await cancelOrderRequest(orderId); // throws on error
    const buKey = currentBusinessUnit?.key;
    if (buKey) mutate([KEY_ORDERS, buKey]);                          // invalidate list
    mutate(keyOrder(orderId), updated.order, { revalidate: false }); // update detail
  }

  return { cancelOrder };
}
Rule: any data that differs per BU includes buKey in the SWR 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():
// lib/ct/orders.ts
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 Route Handler.

B2B Addition 3: Route Handlers 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
if (!session.customerId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
CORRECT — validate both before any B2B operation:
// app/api/orders/route.ts
export async function GET() {
  const session = await getSession();
  if (!session.customerId || !session.businessUnitKey) {
    return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
  }
  try {
    const orders = await getOrders(session.customerId, session.businessUnitKey);
    return NextResponse.json({ orders });
  } catch (e: unknown) {
    const msg = e instanceof Error ? e.message : 'Failed to fetch orders';
    return NextResponse.json({ error: msg }, { status: 500 });
  }
}

Checklist (B2B additions to the shared checklist)

  • SWR keys for BU-scoped data use [KEY, businessUnitKey] tuple — null when buKey absent
  • SWR keys for store-scoped data (prices, inventory) use [KEY, businessUnitKey, storeKey] tuple
  • commercetools helper uses asAssociate().withAssociateIdValue(...).inBusinessUnitKeyWithBusinessUnitKeyValue(...)
  • Route Handler validates both customerId AND businessUnitKey
  • After mutation: mutate([KEY, buKey]) to invalidate BU-scoped list
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] }]:
// lib/ct/approval-rules.ts
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 SWR 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:
// lib/ct/approval-flows.ts
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:
// app/[locale]/dashboard/orders/[id]/page.tsx
const { roleKeys } = usePermissions();

// 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)
);

// Only show buttons when BOTH conditions are true
{isEligibleApprover && canActOnCurrentTier && flow.status === 'Pending' && (
  <>
    <Button onClick={() => handleAction('approve')}>Approve</Button>
    <Button variant="danger" onClick={() => setShowRejectModal(true)}>Reject</Button>
  </>
)}
This uses roleKeys (role keys from associate role assignments), not named permissions. Approval eligibility is role-based, not permission-based.

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: Returning 403 to the browser when commercetools returns 403 on the approval flows list:
// WRONG — causes an error page for associates who just can't see flows
if (response.status === 403) {
  return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
CORRECT — silently return empty results on commercetools 403 for the flows list:
// app/api/approval-flows/route.ts
export async function GET() {
  const session = await getSession();
  if (!session.customerId || !session.businessUnitKey) {
    return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
  }

  try {
    const flows = await getApprovalFlows(session.customerId, session.businessUnitKey);
    return NextResponse.json({ results: flows, total: flows.length });
  } catch (error: unknown) {
    const statusCode = (error as { statusCode?: number }).statusCode;
    // commercetools 403 = associate lacks UpdateApprovalFlows — return empty list, not an error
    if (statusCode === 403) {
      return NextResponse.json({ results: [], total: 0 });
    }
    return NextResponse.json({ error: 'Failed to fetch approval flows' }, { status: 500 });
  }
}

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
  • GET /api/approval-flows 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, route handlers, SWR 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 lib/ct/cart.ts that reaches for apiRoot.carts() must instead go through an as-associate helper. The project-level carts() endpoint does not evaluate associate permissions.
// lib/ct/cart.ts
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 route handler) 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.
// lib/ct/cart.ts
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 route handler, 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 route handler) 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.
// lib/ct/cart.ts
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 (commercetools 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 — commercetools checkout frontend SDK mounts and handles payment capture and order placement. If a PO Number payment method is configured in the commercetools 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 commercetools checkout frontend SDK. Do not implement a separate POST /api/checkout route for order creation.
Reference: See the commercetools 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

app/[locale]/checkout/quote-request-confirmation/page.tsx reads quoteRequestId from the URL 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 commercetools checkout frontend SDK — no custom order route for cart checkout
  • PO Number not added manually — relies on commercetools 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, Route Handler structure, SWR 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 Route Handler — 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 Route Handler 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.


AuthContext and useAccount

Same SWR-backed pattern as the shared reference — AuthContext 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 SWR cache. The logout Route Handler 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 SWR 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 SWR 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

app/[locale]/dashboard/layout.tsx is a 'use 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 SWR Hook

INCORRECT: Using a static key for BU-scoped data:
// WRONG — stale data persists when user switches business units
return useSWR(KEY_ORDERS, ordersFetcher, { revalidateOnFocus: false });
CORRECT — include businessUnitKey in the SWR key tuple:
// hooks/useOrders.ts
export function useOrders() {
  const { currentBusinessUnit } = useBusinessUnit();
  const buKey = currentBusinessUnit?.key ?? null;

  return useSWR(
    buKey ? [KEY_ORDERS, buKey] : null,  // null = skip fetch until BU is selected
    ([, bk]) => fetchOrders(bk),
    { revalidateOnFocus: false }
  );
}
null key skips the SWR fetch — use it when businessUnitKey is not yet known. SWR automatically re-fetches when the key changes (BU switch).

Pattern 3: Adding a Stat Widget

The overview page (app/[locale]/dashboard/page.tsx) renders a statCards array.
Step 1 — Create the hook (BU-keyed):
// hooks/useMyStats.ts
const KEY_MY_STATS = 'my-stats';

export function useMyStats() {
  const { currentBusinessUnit } = useBusinessUnit();
  const buKey = currentBusinessUnit?.key ?? null;
  return useSWR(
    buKey ? [KEY_MY_STATS, buKey] : null,
    ([, bk]) => fetch(`/api/my-stats?buKey=${bk}`).then(r => r.json()),
    { revalidateOnFocus: false }
  );
}
Step 2 — Add the card to dashboard/page.tsx:
const { data: myStats } = useMyStats();
const { can } = usePermissions();

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 messages/en.json under "dashboard".

Pattern 4: Adding a Dashboard Page

// app/[locale]/dashboard/my-section/page.tsx
'use client';

import { Suspense } from 'react';
import { useTranslations } from 'next-intl';
import { usePermissions } from '@/hooks/usePermissions';
import { useMyData } from '@/hooks/useMyData';

function MySectionContent() {
  const t = useTranslations('mySection');
  const { can } = usePermissions();
  const { data, isLoading } = useMyData();

  // Gate the entire page — shows nothing if permission is missing
  if (!can('SomePermission')) return null;
  if (isLoading) return <div>{t('loading')}</div>;

  return (
    <div>
      <h1 className="mb-6 text-2xl font-bold">{t('title')}</h1>
      <div className="rounded-xl border border-gray-100 bg-white p-6">
        {/* content */}
      </div>
    </div>
  );
}

// Always wrap in Suspense — required when useSearchParams is used inside
export default function MySectionPage() {
  return (
    <Suspense>
      <MySectionContent />
    </Suspense>
  );
}
For pages that need server-side pre-fetch (no loading state):
Follow the company/page.tsx pattern — make page.tsx an async Server Component that calls getSession() + commercetools functions, then passes initialData to a *Client.tsx sibling component.

Pattern 5: Sidebar Nav Items

Add to the NAV_ITEMS array in components/layout/DashboardNav.tsx:
const NAV_ITEMS = [
  // existing items...
  {
    label: t('mySection'),              // from 'nav' translation namespace
    href: '/dashboard/my-section',      // locale prefix added by Link automatically
    requiredPermissions: ['SomePermission', 'AnotherPermission'],
    // omit requiredPermissions to show always
  },
];
Items are hidden (not just disabled) when hasAnyPermission(item.requiredPermissions) returns false.
Add translation key to every messages/*.json under "nav":
{ "nav": { "mySection": "My Section" } }

Shared UI Primitives

Located in components/ui/:
ComponentKey props
Tablecolumns, data, loading, emptyMessage, optional onRowClick
Paginationtotal, limit, offset, onChange
Buttonvariant (primary/secondary/ghost/danger), href (renders as <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 — null when BU not yet selected
  • Stat card has enabled: can('SomePermission') — disabled cards render with lock icon automatically
  • Dashboard page wrapped in <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 messages/*.json files
b2b/data-loading.md

Data Loading — B2B Extensions

See the shared reference for the Server vs SWR decision, commercetools type boundary, BFF route shape, version conflict retry, and caching patterns. This file covers B2B-specific additions only. See the shared reference for the Server vs SWR decision, commercetools type boundary, BFF route shape, version conflict retry, and caching patterns. This file covers B2B-specific additions only.

as-associate Chain in lib/ct/

Every function in lib/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 SWR Keys

Data that belongs to a business unit must use a [KEY, buKey] tuple as the SWR key. Passing null as the key suspends the fetch until businessUnitKey is available in the session.
return useSWR<Order[]>(
  buKey ? [KEY_ORDERS, buKey] : null,
  ordersFetcher,
  { revalidateOnFocus: false }
);

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
lib/mappers/business-unit.tscommercetools BusinessUnit → app BusinessUnit
lib/mappers/quote.tscommercetools Quote / QuoteRequest → app types
lib/mappers/approval-flow.tscommercetools ApprovalFlow → app ApprovalFlow
lib/mappers/associate-role.tscommercetools AssociateRole → app AssociateRole

Checklist

  • Extends shared data-loading patterns
  • All lib/ct/ functions use the as-associate chain — including inside version conflict logic
  • BU-scoped SWR hooks use [KEY, buKey] tuple; null key when BU not yet resolved
  • B2B mapper files present for business-unit, quote, approval-flow, associate-role
  • 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 route handler 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 lib/mappers/recurring-order.ts, not in route handlers.

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 route handler, 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 lib/ct/cart.ts 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 JWT session. It is detected at login and re-evaluated on BU selection.

Detect at login

// app/api/auth/login/route.ts  (after fetching businessUnits)
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(response, {
  customerId: customer.id,
  isSuperuser,
  // ... other session fields
});

commercetools Cart Functions (lib/ct/cart.ts)

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

// context/SuperuserContext.tsx
export function SuperuserProvider({ children }) {
  const { data } = useSWR(KEY_SUPERUSER_STATUS, superuserStatusFetcher, { revalidateOnFocus: false });
  const superuserStatus = data ?? { isSuperuser: false, carts: [] };

  const switchCart = useCallback(async (cartId: string) => {
    const res = await fetch('/api/superuser/carts/switch', { method: 'POST', body: JSON.stringify({ cartId }) });
    if (!res.ok) throw new Error('Failed to switch cart');
    mutateGlobal(KEY_CART);         // force CartContext to refetch
    window.location.replace(window.location.pathname);  // full reload
  }, [...]);

  const createMerchantCart = useCallback(async () => {
    await fetch('/api/superuser/carts', { method: 'POST' });
    await mutate();    // refresh cart list
    invalidateCart();  // refresh CartContext
  }, [...]);
}

export function useSuperuser() { ... }

Layout Integration

Add SuperuserProvider inside AuthProvider, outside CartProvider:
// app/[locale]/layout.tsx
<AuthProvider>
  <SuperuserProvider>
    <BusinessUnitProvider>
      <CartProvider>
        <Header />
        <SuperuserBanner />   {/* amber banner shown only to superusers */}
        <main>{children}</main>
      </CartProvider>
    </BusinessUnitProvider>
  </SuperuserProvider>
</AuthProvider>

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

Next.js + commercetools B2B Storefront

Production-tested patterns for the b2b-site — a B2B ecommerce storefront built on commercetools with Next.js 16 App Router, TypeScript, Tailwind v4, and JWT 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).

Shared foundation: BFF architecture, JWT session setup, commercetools SDK singleton, project scaffold, COUNTRY_CONFIG, performance patterns, image config, Vercel deployment, and the shared auth base are in the commercetools-storefront skill. Load that skill alongside this one when starting a new project.

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 Route Handlers.
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 JWT cookie. 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 Route Handlers.

Reference Index

Shared Foundation

Load the commercetools-storefront skill for these references:
TaskReference
Scaffold the app, Tailwind v4, next-intl routing, locale proxyRun /commercetools-nextjs-setup-project
commercetools SDK singleton, JWT sessions, BFF architecturect-client.md
Shared auth base: commercetools login, Route Handler, SWR hook, logoutcustomer-auth.md
Add a new country / currency / locale (COUNTRY_CONFIG)add-country.md
Parallel fetching, unstable_cache, SWR prefetch, 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, SWR 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 + SWR hook (no-fetch-in-client, 3-layer pattern)add-api.md
Server vs SWR 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, unstable_cache) are in the commercetools-storefront skill.

CRITICAL

  • Next.js version — Always use next@^16. Never write "next": "15.x" in package.json. Next.js 15.x has known security vulnerabilities and is unsupported. For new projects, run /commercetools-nextjs-setup-project which pins the correct version automatically.
  • NextIntl version — Always use next-intl@^4 compatible with next@^16. Never write "next-intl": "3.x" in package.json.
  • 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 SWR cache keys — all dashboard hooks use [KEY, businessUnitKey] tuple keys so the cache auto-invalidates 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 Route Handler creates a cart with businessUnit + store + currency + country from the session.

MEDIUM

  • No-fetch-in-client — all fetch('/api/*') calls live in hooks/*Api.ts functions, not in component or context files.
  • Store data cachestoreDataCache in lib/ct/stores.ts 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 degradationGET /api/approval-flows 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 client, NEXT_PUBLIC_ secrets, sequential awaits, etc.) are in the commercetools-storefront skill.
Anti-patternCorrect approach
apiRoot.carts().post(...) for a logged-in userasAssociate().withAssociateIdValue(...).inBusinessUnitKey(...).carts().post(...)
Separate urlLocale / locale fields in sessionSingle 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 POST /api/session/locale
Omitting distributionChannelId in product searchPass full session to searchProducts()ProductApi injects channel automatically
useSWR(KEY_ORDERS, ...) without BU keyuseSWR([KEY_ORDERS, businessUnitKey], ...) — cache 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(). No app-level enforcement in Route Handlers — 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: Adding permission checks inside Route Handlers:
// WRONG — duplicates commercetools enforcement; also fragile since it must be maintained manually
export async function POST() {
  const session = await getSession();
  const bu = await getBusinessUnitByKey(session.businessUnitKey!);
  const associate = bu.associates.find(a => a.customer.id === session.customerId);
  const hasPermission = associate?.associateRoleAssignments.some(r => r.key === 'CreateMyCarts');
  if (!hasPermission) return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
  // ...
}
CORRECT — Route Handlers only check session existence; commercetools enforces permissions via the as-associate chain:
// app/api/cart/route.ts
export async function POST() {
  const session = await getSession();
  // Only check: is the user logged in with a valid BU?
  if (!session.customerId || !session.businessUnitKey) {
    return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
  }
  // 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
  );
  return NextResponse.json({ cart });
}
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.

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:
// hooks/usePermissions.ts
// Resolution logic (simplified):
// 1. Fetch all AssociateRole objects from /api/associate-roles (commercetools source of truth)
// 2. Find current associate in currentBusinessUnit.associates by customerId
// 3. Collect their role keys from associateRoleAssignments
// 4. Collect all permissions from those roles
// 5. Expose can(), hasAnyPermission(), roleKeys

export function usePermissions() {
  const { user } = useAuth();
  const { currentBusinessUnit } = useBusinessUnit();
  const [permissions, setPermissions] = useState<Set<string>>(new Set());
  const [roleKeys, setRoleKeys] = useState<Set<string>>(new Set());

  useEffect(() => {
    if (!user || !currentBusinessUnit) return;

    // Find the current user's associate record in the BU
    const associate = currentBusinessUnit.associates.find(
      (a) => a.customer.id === user.id
    );
    if (!associate) return;

    // Collect the associate's role keys
    const keys = new Set(associate.associateRoleAssignments.map((r) => r.associateRole.key));
    setRoleKeys(keys);

    // Fetch all roles from commercetools (module-level cache, fetched once per tab)
    fetchAssociateRoles().then((roles) => {
      const perms = new Set<string>();
      for (const role of roles) {
        if (keys.has(role.key)) {
          for (const p of role.permissions) perms.add(p);
        }
      }
      setPermissions(perms);
    });
  }, [user, currentBusinessUnit]);

  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));

  return { can, hasAnyPermission, hasAllPermissions, roleKeys, permissions };
}
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.

Pattern 3: UI Gating Patterns

Pattern A — single permission

const { can } = usePermissions();
if (!can('CreateApprovalRules')) return null;

Pattern B — "either My or Others grants access" (feature visibility)

const { hasAnyPermission } = usePermissions();
const canViewOrders = hasAnyPermission(['ViewMyOrders', 'ViewOthersOrders']);
if (!canViewOrders) return null;
Use hasAnyPermission for deciding whether to show a feature at all.

Pattern C — dynamic My/Others dispatch (per-resource actions)

const { can } = usePermissions();
const { user } = useAuth();

const isOwnQuote = quote.customer.id === user?.id;
const canAccept = isOwnQuote ? can('AcceptMyQuotes') : can('AcceptOthersQuotes');

{canAccept && <Button onClick={handleAccept}>Accept Quote</Button>}

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)
);

{isEligibleApprover && canActOnCurrentTier && (
  <>
    <Button onClick={handleApprove}>Approve</Button>
    <Button onClick={handleReject}>Reject</Button>
  </>
)}

Pattern 4: All Permission Strings

Defined as a TypeScript union in lib/types.ts:
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: Rendering nav items and redirecting on access:
// WRONG — user sees the link, clicks it, then gets an error
<Link href="/dashboard/approval-rules">Approval Rules</Link>
CORRECT — DashboardNav hides items when the associate lacks the required permissions:
// components/layout/DashboardNav.tsx
const NAV_ITEMS = [
  { label: t('orders'), href: '/dashboard/orders',
    requiredPermissions: ['ViewMyOrders', 'ViewOthersOrders'] },
  { label: t('quotes'), href: '/dashboard/quotes',
    requiredPermissions: ['ViewMyQuotes', 'ViewOthersQuotes'] },
  { label: t('approvalRules'), href: '/dashboard/approval-rules',
    requiredPermissions: ['CreateApprovalRules', 'UpdateApprovalRules'] },
  { label: t('company'), href: '/dashboard/company',
    requiredPermissions: ['UpdateBusinessUnitDetails', 'UpdateAssociates'] },
];

// In the component:
{NAV_ITEMS
  .filter(item =>
    !item.requiredPermissions ||
    hasAnyPermission(item.requiredPermissions)
  )
  .map(item => <NavLink key={item.href} {...item} />)
}

Checklist

  • No permission checks in Route Handlers — 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 lib/types.ts 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, notFound(), parallel fetching, variant URL strategy, component list, 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 to generateMetadata — 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 in generateMetadata — 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


Pattern 1: Session-Scoped Product Search

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():
// app/api/products/route.ts
export async function POST(request: NextRequest) {
  const session = await getSession();
  const body = await request.json();

  // searchProducts reads businessUnitKey, storeKey, distributionChannelId,
  // supplyChannelId, accountGroupIds from session internally
  const results = await searchProducts(body, session);
  return NextResponse.json(results);
}
// lib/ct/products.ts — 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.

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:
// lib/ct/product-api.ts (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:
// lib/ct/product-api.ts (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:
// lib/ct/product-api.ts (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
  )
);

// lib/mappers/product.ts
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 — POST /api/products retries without facets on commercetools error:
// app/api/products/route.ts
export async function POST(request: NextRequest) {
  const session = await getSession();
  const body = await request.json();

  try {
    const results = await searchProducts(body, session);
    return NextResponse.json(results);
  } catch (error) {
    // Retry without facets — products always render even if facets fail
    console.warn('Product search failed with facets, retrying without:', error);
    try {
      const results = await searchProducts({ ...body, facetConfigurations: [] }, session);
      return NextResponse.json(results);
    } catch (fallbackError) {
      return NextResponse.json({ error: 'Product search failed' }, { status: 500 });
    }
  }
}

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

Navigate to app/[locale]/checkout-quote/page.tsx with ?quoteId=<id> to initiate acceptance. 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 SWR 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 null to the SWR 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: SWR Hooks

HookReturns
useQuotes()Paginated list of quotes for the active BU
useQuote(id)Single quote detail; pass null to skip
useQuoteThread(stagedQuoteId)All rounds sharing the same stagedQuote.id; pass null 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 null to skip
All hooks scope the SWR cache key to [KEY, businessUnitKey] so data is isolated per BU. Pass null as the key to defer fetching until the required ID is available.

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
  • SWR 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 session JWT 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:
// lib/types.ts — 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 Next.js 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"Next.js 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 Route Handler — 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:
// lib/ct/stores.ts
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:
// app/api/business-units/[id]/select/route.ts
export async function POST(request: NextRequest) {
  const session = await getSession();
  if (!session?.customerId) {
    return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
  }

  const { businessUnitKey, storeKey } = await request.json();
  if (!businessUnitKey || !storeKey) {
    return NextResponse.json({ error: 'businessUnitKey and storeKey are required' }, { status: 400 });
  }

  // Resolve distributionChannelId, supplyChannelId, productSelectionId
  const { supplyChannelId, distributionChannelId, productSelectionId } =
    await getStoreChannelData(storeKey);

  const response = NextResponse.json({ success: true });
  await setSession(response, {
    ...session,
    businessUnitKey,
    storeKey,
    supplyChannelId,
    distributionChannelId,
    productSelectionId,
    // cartId intentionally kept — existing cart is still valid for the new BU+store
  });
  return response;
}
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.

Pattern 4: BusinessUnitContext

INCORRECT: Letting each component call fetch('/api/business-units') independently:
// WRONG — N fetches, no shared state, no auto-invalidation
useEffect(() => {
  fetch('/api/business-units').then(...);
}, []);
CORRECT — BusinessUnitProvider owns all BU state, SWR-backed, auto-invalidates on logout:
// context/BusinessUnitContext.tsx (key excerpts)
'use client';

export function BusinessUnitProvider({ children }: { children: ReactNode }) {
  const { isLoggedIn, loading: authLoading } = useAuth();
  const [currentBusinessUnit, setCurrentBusinessUnit] = useState<BusinessUnit | null>(null);
  const [currentStore, setCurrentStore] = useState<Store | null>(null);

  // Fetch BU list via SWR — null key when not logged in (skips fetch)
  const { data: buData } = useSWR<BusinessUnitsData>(
    isLoggedIn && !authLoading ? KEY_BUSINESS_UNITS : null,
    businessUnitsFetcher
  );

  // Auto-select on first load (persisted key from server, or first BU)
  useEffect(() => {
    if (businessUnits.length > 0 && !autoSelectedRef.current) {
      autoSelectedRef.current = true;
      const bu = persisted ?? businessUnits[0];
      const store = bu.stores?.[0];
      if (store) {
        selectBusinessUnitRequest(bu.id, bu.key, store.key)
          .then((ok) => {
            if (ok) { setCurrentBusinessUnit(bu); setCurrentStore(store); }
          });
      }
    }
  }, [businessUnits]);

  // Clear on logout — reset autoSelectedRef so next login re-picks
  useEffect(() => {
    if (!authLoading && !isLoggedIn) {
      setCurrentBusinessUnit(null);
      setCurrentStore(null);
      autoSelectedRef.current = false;
      globalMutate(KEY_BUSINESS_UNITS, { businessUnits: [] }, false);
    }
  }, [isLoggedIn, authLoading]);

  const selectBusinessUnit = useCallback(async (id: string) => {
    const bu = businessUnits.find((b) => b.id === id);
    const store = bu?.stores?.[0];
    if (!bu || !store) return;
    const ok = await selectBusinessUnitRequest(bu.id, bu.key, store.key);
    if (ok) { setCurrentBusinessUnit(bu); setCurrentStore(store); }
  }, [businessUnits]);

  const selectStore = useCallback(async (storeKey: string) => {
    if (!currentBusinessUnit) return;
    const store = currentBusinessUnit.stores?.find((s) => s.key === storeKey);
    if (!store) return;
    const ok = await selectBusinessUnitRequest(currentBusinessUnit.id, currentBusinessUnit.key, storeKey);
    if (ok) setCurrentStore(store);
  }, [currentBusinessUnit]);

  // ... rest of context value
}

export function useBusinessUnit(): BusinessUnitContextValue {
  const context = useContext(BusinessUnitContext);
  if (!context) throw new Error('useBusinessUnit must be used within BusinessUnitProvider');
  return context;
}

Pattern 5: Reading Session Fields in API Routes

INCORRECT: Calling commercetools functions without passing the required B2B context:
// WRONG — cart created without BU or store; product search returns global prices
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 Route Handler that needs BU context
export async function POST(req: NextRequest) {
  const session = await getSession();
  const { customerId, businessUnitKey, storeKey } = session;

  if (!customerId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
  if (!businessUnitKey || !storeKey) {
    return NextResponse.json({ error: 'No active business unit' }, { status: 400 });
  }

  // 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 Route Handler proceeds
  • session passed to searchProducts() — never call with empty/partial session
  • BusinessUnitProvider wraps the locale layout and is inside AuthProvider
  • SWR keys for BU-scoped data use [KEY, businessUnitKey] tuple
  • SWR cache cleared on logout (globalMutate(KEY_BUSINESS_UNITS, { businessUnits: [] }, false))
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 (lib/ct/wishlists.ts and lib/ct/purchase-lists.ts) — 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. Route handlers validate customerId only. The SWR 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: SWR and Mutation

Extends Pattern 4 from the shared reference.
List typeSWR keyFires when
Wishlist[KEY_WISHLISTS, customerId]customerId is resolved
Purchase list[KEY_PURCHASE_LISTS, businessUnitKey]businessUnitKey is resolved
For purchase lists, passing null as the SWR key when businessUnitKey is not yet available is critical — it prevents fetching as the wrong BU or before context is ready.
After any mutation, call mutate with the same key tuple used by the hook. For purchase lists this means mutate([KEY_PURCHASE_LISTS, businessUnitKey]). The businessUnitKey in the mutation call must come from the same source as the hook — usually currentBusinessUnit.key from useBusinessUnit() — so the 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.


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
  • Route handlers validate customerId only
  • SWR 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
  • Route handlers validate customerId AND businessUnitKey
  • SWR key is [KEY_PURCHASE_LISTS, businessUnitKey]; fires only when businessUnitKey is resolved
  • All mutations call mutate([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 lib/ct/variant-config.ts.

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:

// lib/ct/variant-config.ts
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 (lib/ct/product-api.ts 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 (commercetools 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 — commercetools 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 commercetools checkout frontend SDK on the payment step. Do not implement a separate POST /api/checkout route for order creation.
Reference: See the commercetools 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 commercetools 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, Route Handler structure, SWR hook, and logout patterns.

Anonymous Cart Merge

Pass anonymousCartId and anonymousCartSignInMode to apiRoot.login().post() when a guest cart exists:
// lib/ct/auth.ts
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 Route Handler 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

Client Component that redirects to /login?redirect=<path> when useAccount resolves to null. Return null while loading (user === undefined) to avoid a layout flash.
// app/[locale]/account/layout.tsx
'use client';
export default function AccountLayout({ children }) {
  const { user } = useAccount();
  const router = useRouter();
  const pathname = usePathname();

  useEffect(() => {
    if (user === null) router.replace(`/login?redirect=${encodeURIComponent(pathname)}`);
  }, [user, router, pathname]);

  if (!user) return null;
  return <div>{children}</div>;
}

Checklist

  • signIn passes anonymousCartId + anonymousCartSignInMode: 'MergeWithExistingCustomerCart' when a guest cart exists
  • Login Route Handler 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; wrong Link usage breaks locale routing.

This reference covers the Header Server 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 with useEffect or a client-side SWR call — adds a loading waterfall on every page.
CORRECT — fetch the category tree once in the locale layout (Server Component) and pass it as a prop:
// app/[locale]/layout.tsx
import { getCategoryTree } from '@/lib/ct/categories';
import { getLocale } from '@/lib/session';
import Header from '@/components/layout/Header';

export default async function LocaleLayout({ children }: Props) {
  const { locale } = await getLocale();
  const categoryTree = await getCategoryTree(locale);

  return (
    <html>
      <body>
        <Header categoryTree={categoryTree} />
        {children}
      </body>
    </html>
  );
}
Header itself is a Server Component. Interactive children (mega menu open/close, search input, locale switcher) are 'use client' sub-components in their own files receiving data as props.

Pattern 2: Logo

  • Render as a <Link href="/"> using <Link> from @/i18n/routing so locale prefix is preserved
  • Use next/image 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 lib/ct/categories.ts).

Desktop mega menu

  • A 'use 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 usePathname() — compare current path to /category/<slug>
  • All category links use <Link href={/category/${slug}}> from @/i18n/routing
  • Close the panel on outside click (useEffect + document listener) and on Escape key

Mobile drawer

  • A 'use 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 <Link> usage as desktop

Pattern 4: Search Bar

  • A 'use client' input component
  • On submit (Enter key or search button click), navigate to /search?q=<encoded-query> using useRouter from @/i18n/routing
  • The /search page is a Server Component that reads searchParams.q and calls searchProducts
  • Keep input state local (useState) — no global store needed
// components/layout/SearchBar.tsx
'use client';
import { useRouter } from '@/i18n/routing';

export default function SearchBar() {
  const router = useRouter();
  // controlled input; on submit: router.push(`/search?q=${encodeURIComponent(query)}`)
}

Pattern 5: Country & Locale Switcher

These are two distinct concerns wired differently:

Locale switcher (URL-based)

  • Use usePathname + useRouter from @/i18n/routing to switch locale without losing the current path
  • next-intl handles the URL rewrite — no session update needed
// 'use client'
import { usePathname, useRouter } from '@/i18n/routing';

// on select: router.replace(pathname, { locale: selectedLocale })

Country / currency switcher (session-based)

  • Changing country must update country and currency in the server session, then reload
  • POST /api/locale with { country, currency, locale } — the Route Handler calls setSessionCookie and returns the updated values
  • After the response, call router.refresh() to re-render Server Components 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
// 'use client'
// on select:
//   await fetch('/api/locale', { method: 'POST', body: JSON.stringify({ country, currency }) })
//   router.refresh()

Checklist

  • getCategoryTree called once in app/[locale]/layout.tsx (Server Component) — not inside Header
  • Header is a Server Component; mega menu open/close and search are 'use client' sub-components
  • Logo uses <Link> from @/i18n/routing — not next/link or <a>
  • Logo image uses next/image with priority
  • All category links use <Link> from @/i18n/routing
  • Active category detected with usePathname() — no manual URL parsing
  • Mega menu closes on outside click and Escape key
  • Search navigates to /search?q= — does not call an API route
  • Locale switch uses router.replace(pathname, { locale }) from @/i18n/routing
  • Country switch POSTs to /api/locale then calls router.refresh()
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 lib/cache-keys.ts. 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 useSWR callUse KEY_CHANNELS from lib/cache-keys.ts

Reference

TaskReference
Channels API (lib/ct/channels.ts), route handlers, 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

// site/lib/ct/channels.ts
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);
}

Route Handlers:

// site/app/api/channels/route.ts
export async function GET() {
  const channels = await getAllChannels();
  return NextResponse.json(channels);
}

// site/app/api/channels/[id]/route.ts
export async function GET(_: Request, { params }: { params: { id: string } }) {
  const channel = await getChannelById(params.id);
  if (!channel) return NextResponse.json({ error: 'Not found' }, { status: 404 });
  return NextResponse.json(channel);
}

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:
// site/lib/ct/cart.ts
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;
}
// site/app/api/cart/items/route.ts
export async function POST(request: Request) {
  const session = await getSession();
  const { productId, variantId, quantity = 1, supplyChannelId } = await request.json();
  const cart = await getCart(session.cartId!);
  const updated = await addLineItem(session.cartId!, cart.version, productId, variantId, quantity, supplyChannelId);
  return NextResponse.json({ cart: updated });
}

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

// site/lib/cache-keys.ts
export const KEY_CHANNELS = 'channels';
export const keyChannel = (id: string) => `channel-${id}`;
Use these as the SWR cache key and as the revalidateTag argument in Route Handlers that mutate channels.

Pattern 5: useChannels Hook

// site/hooks/useChannels.ts
'use client';
import useSWR from 'swr';
import { KEY_CHANNELS, keyChannel } from '@/lib/cache-keys';
import type { Channel } from '@/lib/types';

async function channelsFetcher() {
  const res = await fetch('/api/channels');
  if (!res.ok) return [];
  return res.json();
}

async function channelFetcher([, id]: [string, string]) {
  const res = await fetch(`/api/channels/${id}`);
  if (!res.ok) return null;
  return res.json();
}

export function useChannels() {
  const { data, error, isLoading } = useSWR<Channel[]>(KEY_CHANNELS, channelsFetcher, {
    revalidateOnFocus: false,
  });
  return { channels: data ?? [], error, isLoading };
}

export function useChannel(id: string | null) {
  const { data, error, isLoading } = useSWR<Channel>(
    id ? [keyChannel(id), id] : null,
    channelFetcher,
    { revalidateOnFocus: false }
  );
  return { channel: data ?? null, error, isLoading };
}

Pattern 6: Type Extensions

// site/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 — tabs for delivery vs pickup, persists mode to localStorage:
// site/components/bopis/ChannelSelector.tsx
'use client';
import { useState, useEffect } from 'react';
import { useChannels } from '@/hooks/useChannels';

type DeliveryMode = 'delivery' | 'pickup';

export default function ChannelSelector({
  onSelect,
}: {
  onSelect: (channelId: string | null) => void;
}) {
  const { channels } = useChannels();
  const [mode, setMode] = useState<DeliveryMode>('delivery');
  const pickupChannels = channels.filter((c) => c.roles?.includes('InventorySupply'));

  useEffect(() => {
    const saved = localStorage.getItem('deliveryMode') as DeliveryMode | null;
    if (saved) setMode(saved);
  }, []);

  const handleModeChange = (m: DeliveryMode) => {
    setMode(m);
    localStorage.setItem('deliveryMode', m);
    if (m === 'delivery') onSelect(null);
  };

  return (
    <div>
      <button onClick={() => handleModeChange('delivery')}>Delivery</button>
      <button onClick={() => handleModeChange('pickup')}>Pick Up In Store</button>
      {mode === 'pickup' && (
        <select onChange={(e) => onSelect(e.target.value)}>
          {pickupChannels.map((c) => (
            <option key={c.id} value={c.id}>{c.name}</option>
          ))}
        </select>
      )}
    </div>
  );
}
Pickup badge in cart item:
// site/components/cart/CartItem.tsx
import { useChannel } from '@/hooks/useChannels';

function PickupBadge({ channelId }: { channelId: string }) {
  const { channel } = useChannel(channelId);
  if (!channel) return null;
  return (
    <span className="rounded-full bg-green-100 px-2 py-0.5 text-xs text-green-800">
      Pickup: {channel.name}
    </span>
  );
}

// In CartItem:
{item.supplyChannelId && <PickupBadge channelId={item.supplyChannelId} />}

Checklist

  • getAllChannels, getChannelById, getChannelByKey implemented in lib/ct/channels.ts
  • Route handlers at app/api/channels/route.ts and app/api/channels/[id]/route.ts
  • addLineItem accepts supplyChannelId and uses { typeId: 'channel', id } reference
  • app/api/cart/items/route.ts passes supplyChannelId through to addLineItem
  • KEY_CHANNELS and keyChannel(id) added to lib/cache-keys.ts
  • useChannels() and useChannel(id) hooks created with dedupingInterval: 60_000
  • 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 SWR fetcher, not in components. Apply grouping once in cartFetcherWithBundles inside useCartSWR 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 cartFetcher — 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 route, bundle-utils, useCartSWR 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

// site/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:
// site/lib/ct/cart.ts
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:
// site/lib/mappers/cart-mapper.ts
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: items/route.ts

// site/app/api/cart/items/route.ts
export async function POST(request: Request) {
  const session = await getSession();
  const { productId, variantId, quantity = 1, bundledSKUList } = await request.json();

  let cart = await getCart(session.cartId!);
  const parentKey = bundledSKUList?.length ? uuidv4() : undefined;

  cart = await addLineItem(cart.id, cart.version, productId, variantId, quantity, parentKey);

  if (parentKey && bundledSKUList?.length) {
    cart = await addBundledLineItems(cart.id, cart.version, parentKey, bundledSKUList);
  }

  return NextResponse.json({ cart });
}

Pattern 6: bundle-utils.ts

// site/lib/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: useCartSWR

Apply bundleItems in the cart fetcher so all components receive pre-grouped data:
// site/hooks/useCartSWR.ts  (extends the base hook)
import { bundleItems } from '@/lib/bundle-utils';
import { KEY_CART } from '@/lib/cache-keys';

async function cartFetcherWithBundles(): Promise<Cart | null> {
  const res = await fetch('/api/cart');
  if (!res.ok) return null;
  const data = await res.json();
  if (!data.cart) return null;
  return { ...data.cart, lineItems: bundleItems(data.cart.lineItems ?? []) };
}

export function useCartSWR(fallback?: Cart | null) {
  return useSWR<Cart | null>(KEY_CART, cartFetcherWithBundles, {
    fallbackData: fallback ?? undefined,
    revalidateOnFocus: true,
  });
}

Pattern 8: UI

CartItem — render bundled children as sub-rows:
// site/components/cart/CartItem.tsx
import Image from 'next/image';

export default function CartItem({ item }: { item: CartLineItem }) {
  return (
    <div>
      {/* Main item row */}
      <div className="flex items-center gap-4">
        {item.imageUrl && (
          <div className="relative h-16 w-16 flex-shrink-0 overflow-hidden rounded">
            <Image src={item.imageUrl} alt={item.name} fill className="object-cover" sizes="64px" />
          </div>
        )}
        <div>
          <p className="font-medium">{item.name}</p>
          <p className="text-sm text-gray-500">{formatMoney(item.price)}</p>
        </div>
      </div>

      {/* Bundled children */}
      {item.bundledItems?.map((child) => (
        <div key={child.id} className="ml-8 mt-1 flex items-center gap-2 text-sm text-gray-600">
          <span>+ {child.name}</span>
        </div>
      ))}
    </div>
  );
}
BundleAddToCart — passes bundledSKUList to the API:
// site/components/pdp/BundleAddToCart.tsx
'use client';
import { useState } from 'react';
import { useCartContext } from '@/context/CartContext';
import Button from '@/components/ui/Button';

export default function BundleAddToCart({
  productId, variantId, bundledSKUs,
}: { productId: string; variantId: number; bundledSKUs: string[] }) {
  const [loading, setLoading] = useState(false);
  const { addToCart } = useCartContext();

  const handleAdd = async () => {
    setLoading(true);
    try {
      // addToCart handles POST /api/cart/items and opens mini-cart
      // bundledSKUList is passed as extra data picked up by the extended route handler
      await addToCart(productId, variantId, 1, { bundledSKUList: bundledSKUs });
    } finally {
      setLoading(false);
    }
  };

  return <Button variant="primary" isLoading={loading} onClick={handleAdd}>Add Bundle to Cart</Button>;
}

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
  • items/route.ts generates UUID parent key and calls addBundledLineItems
  • bundleItems() and cartItemCount() in lib/bundle-utils.ts
  • bundleItems applied in cartFetcherWithBundles inside useCartSWR override
  • CartItem renders item.bundledItems as sub-rows (uses next/image, not <img>)
  • BundleAddToCart uses useCartContext().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 + SWR KEY_CART invalidation via POST /api/cart/discount and DELETE /api/cart/discount. 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 lib/mappers/product.ts
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.
// site/lib/ct/products.ts
const productProjectionParameters = {
  body: {
    query: { ... },
    productProjectionParameters: {
      expand: [
        'masterVariant.price.discounted.discount',
        'variants[*].price.discounted.discount',
      ],
    },
  },
};
// site/lib/mappers/product.ts
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,
  };
}
// site/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.
// site/components/cart/DiscountCodeForm.tsx  (already exists)
// Reads and mutates KEY_CART automatically via useSWR.
// POST /api/cart/discount  { code: string }
// DELETE /api/cart/discount  { code: string }

Usage in cart page:

import DiscountCodeForm from '@/components/cart/DiscountCodeForm';

// Inside CartPage or CartDrawer:
<DiscountCodeForm />

The form:

  • Shows an input for entering a code
  • On submit: calls POST /api/cart/discount, revalidates cart SWR key
  • Shows applied codes as chips with a remove button (calls DELETE /api/cart/discount)
  • Displays commercetools error messages (e.g. "Code not found", "Already applied")

Route handlers (already exist):

// site/app/api/cart/discount/route.ts

// POST — apply code
export async function POST(request: Request) {
  const { code } = await request.json();
  const cart = await applyCartAction(session.cartId!, session.customerId, [
    { action: 'addDiscountCode', code },
  ]);
  return NextResponse.json(mapCart(cart));
}

// DELETE — remove code
export async function DELETE(request: Request) {
  const { code } = await request.json();
  const cart = await applyCartAction(session.cartId!, session.customerId, [
    { action: 'removeDiscountCode', discountCode: { typeId: 'discount-code', id: codeId } },
  ]);
  return NextResponse.json(mapCart(cart));
}

Pattern 4: Promotion Banner

Two options — choose one:

Option A: Static banner in Header.tsx:
// site/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:
// site/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',
},
// site/components/home/MessageBanner.tsx
import type { ItemProps } from '@/lib/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 'content/message': dynamic(() => import('../home/MessageBanner')) in Item.tsx.

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 route handlers 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 route handler 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 route handlers 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 lib/ct/cart.ts:
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 JWT 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 JWT cookie. 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. SWR hook reads /api/auth/superuser 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 routeReturn 403 when session.csrId is absent — always, even if UI hides the input
NEXT_PUBLIC_CSR_GROUP_ID env varServer-only — no NEXT_PUBLIC_ prefix
CSR state in localStorage or React stateJWT session cookie; expose via /api/auth/superuser SWR
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 site/.env. This is the commercetools Customer Group that identifies CSR agents. Server-only — no NEXT_PUBLIC_ prefix.
# site/.env
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.
// site/lib/session.ts
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 endpoints collaborate:

// POST /api/auth/login
// Authenticates; if the customer is in CSR_GROUP_ID → returns flag
export async function POST(request: Request) {
  const { email, password } = await request.json();
  const customer = await loginCustomer(email, password);

  // check customer's customerGroup.id or customerGroupAssignments[*].customerGroup.id to include process.env.CSR_GROUP_ID

  if (includes) {
    // Don't create session yet — CSR must supply a customer email to impersonate
    return NextResponse.json({ requiresCsrEmail: true });
  }
  // Normal login: write session and return
  await writeSession(response, { customerId: customer.id, email: customer.email, ... });
  return response;
}

// POST /api/auth/csr-login
// Called after /login returns requiresCsrEmail: true
// Body: { csrEmail, csrPassword, impersonatedEmail }
export async function POST(request: Request) {
  const { csrEmail, csrPassword, impersonatedEmail } = await request.json();
  const csr = await loginCustomer(csrEmail, csrPassword);
  const target = await getCustomerByEmail(impersonatedEmail);

  await writeSession(response, {
    // Impersonated customer in the normal fields
    customerId: target.id,
    email:      target.email,
    firstName:  target.firstName,
    lastName:   target.lastName,
    // CSR identity in csr* fields
    csrId:       csr.id,
    csrEmail:    csr.email,
    csrFirstName: csr.firstName,
    csrLastName:  csr.lastName,
  });
  return response;
}

// GET /api/auth/superuser  → { csrId, csrEmail, csrFirstName, csrLastName } | {}
export async function GET(request: Request) {
  const session = await readSession(request);
  if (!session?.csrId) return NextResponse.json({});
  return NextResponse.json({
    csrId:       session.csrId,
    csrEmail:    session.csrEmail,
    csrFirstName: session.csrFirstName,
    csrLastName:  session.csrLastName,
  });
}

Pattern 4: Price Override

PUT /api/cart/items/[itemId]/price checks session.csrId first — returns 403 if absent.
// site/app/api/cart/items/[itemId]/price/route.ts
export async function PUT(request: Request, { params }: { params: { itemId: string } }) {
  const session = await readSession(request);

  // CRITICAL: guard — only CSR agents may override prices
  if (!session?.csrId) {
    return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
  }

  const { centAmount, currencyCode } = await request.json();
  const cart = await applyCartAction(session.cartId!, session.customerId, [
    {
      action: 'setLineItemPrice',
      lineItemId: params.itemId,
      externalPrice: { currencyCode, centAmount },
    },
  ]);
  return NextResponse.json(mapCart(cart));
}

Pattern 5: SuperUserContext

// site/contexts/SuperUserContext.tsx
'use client';
import useSWR from 'swr';
import { createContext, useContext } from 'react';

interface CsrState {
  csrId?:        string;
  csrEmail?:     string;
  csrFirstName?: string;
  csrLastName?:  string;
}

const SuperUserContext = createContext<CsrState>({});

const csrFetcher = (url: string) => fetch(url).then((r) => r.json());

export function SuperUserProvider({ children }: { children: React.ReactNode }) {
  const { data = {} } = useSWR<CsrState>('/api/auth/superuser', csrFetcher, {
    dedupingInterval: 30_000,
  });
  return <SuperUserContext.Provider value={data}>{children}</SuperUserContext.Provider>;
}

export function useSuperUser() {
  return useContext(SuperUserContext);
}
Add <SuperUserProvider> to app/[locale]/layout.tsx wrapping the children.

Pattern 6: UI

Yellow banner in Header when CSR is active:
// site/components/layout/Header.tsx
import { useSuperUser } from '@/contexts/SuperUserContext';

export default function Header() {
  const { csrId, csrFirstName, csrLastName } = useSuperUser();

  return (
    <>
      {csrId && (
        <div className="bg-yellow-400 py-1 text-center text-xs font-semibold text-yellow-900">
          CSR Mode — {csrFirstName} {csrLastName} impersonating customer
        </div>
      )}
      {/* ... rest of header */}
    </>
  );
}
PriceOverrideInput in cart line item (shown only to CSR):
// site/components/cart/CartItem.tsx
import { useSuperUser } from '@/contexts/SuperUserContext';
import PriceOverrideInput from '@/components/ui/PriceOverrideInput';

export default function CartItem({ item }: { item: CartLineItem }) {
  const { csrId } = useSuperUser();

  return (
    <div>
      {/* ... quantity, name, etc. */}
      {csrId && (
        <PriceOverrideInput lineItemId={item.id} currentPrice={item.price} />
      )}
    </div>
  );
}

Checklist

  • CSR_GROUP_ID set in site/.env (server-only, no NEXT_PUBLIC_)
  • 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 session
  • GET /api/auth/superuser returns CSR fields or {}
  • PUT /api/cart/items/[itemId]/price returns 403 when session.csrId is absent
  • SuperUserProvider added to root layout wrapping children
  • Yellow banner visible in Header during active impersonation
  • PriceOverrideInput rendered in CartItem only when csrId is set
b2c/overview.md

Next.js + commercetools B2C Storefront

Production-tested patterns for building a B2C storefront on commercetools with Next.js 16, TypeScript, Tailwind v4, and JWT sessions. Derived from the b2c-starter-kit — a working production storefront.

Shared foundation: BFF architecture, JWT session setup, commercetools SDK singleton, project scaffold, COUNTRY_CONFIG, performance patterns, image config, Vercel deployment, and the shared auth base are in the commercetools-storefront skill. Load that skill alongside this one when starting a new project.

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. next-intl routes under /en-US/, /de-DE/, etc. The your-shop-country-locale cookie stores the BCP-47 locale and drives which locale the proxy redirects to on first visit.

Reference Index

Shared Foundation

Load the commercetools-storefront skill for these references:
TaskReference
Scaffold the app, Tailwind v4, next-intl routing, locale proxyRun /commercetools-nextjs-setup-project
commercetools SDK singleton, JWT sessions, BFF architecturect-client.md
Shared auth base: commercetools login, Route Handler, SWR hook, logoutcustomer-auth.md
Add a new country / currency / locale (COUNTRY_CONFIG)add-country.md
Parallel fetching, unstable_cache, SWR prefetch, 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, CartContext, SWR 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 + SWR 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 vs SWR 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, unstable_cache) are in the commercetools-storefront skill.

CRITICAL

  • Next.js version — Always use next@^16. Never write "next": "15.x" in package.json. Next.js 15.x has known security vulnerabilities and is unsupported. For new projects, run /commercetools-nextjs-setup-project which pins the correct version automatically.
  • NextIntl version — Always use next-intl@^4 compatible with next@^16. Never write "next-intl": "3.x" in package.json.

HIGH

  • Anonymous cart merge — Pass anonymousCartId to commercetools login so the cart is preserved on sign-in.
  • SWR cache invalidation — Mutate 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 client, fetch in component, NEXT_PUBLIC_ secrets, etc.) are in the commercetools-storefront skill.
Anti-patternCorrect approach
import Link from 'next/link' in a page componentimport { Link } from '@/i18n/routing'
Per-user data in unstable_cacheSWR hook (client) or direct commercetools call (per-request server)
b2c/product-detail.md

Product Detail Page — B2C

The core PDP patterns — route structure, Server Component 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 Component category page.

Table of Contents


Pattern 1: Category Helper Functions

lib/ct/categories.ts 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 lib/mappers/product.ts, components only receive Product from @/lib/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.
lib/ct/search.ts 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 <Link> from @/i18n/routing
  • 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 Component)

INCORRECT: fetch('/api/products') from a category page — unnecessary round-trip through the BFF for data that's only ever server-rendered.
CORRECT — call lib/ct/* directly in an async Server Component, parallel-fetch independent data:
// app/[locale]/category/[slug]/page.tsx
export default async function CategoryPage({ params, searchParams }: PageProps) {
  const { slug } = await params;
  const { country, currency, locale } = await getLocale();

  // Parallel fetch — category metadata and tree at the same time
  const [category, categoryTree] = await Promise.all([
    getCategoryBySlug(slug, locale),
    getCategoryTree(locale),
  ]);
  if (!category) notFound();

  // Build breadcrumb by walking the in-memory tree — no extra API calls

  const result = await searchProducts({ categoryId: category.id, locale, currency, country, ... });

  return (
    // DOM: breadcrumb, heading, ProductGrid, pagination
  );
}

Checklist

  • lib/ct/categories.ts exports getCategoryBySlug, getCategoryById, getCategoryTree
  • getCategoryTree fetches with limit: 500
  • lib/mappers/product.ts exports mapProduct — components never receive raw commercetools types
  • lib/ct/search.ts 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 <Link> from @/i18n/routing — no bare <a> tags
  • Prices display with discounted amount + strikethrough original when applicable
  • notFound() called 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 site/components/ui/. Check it before writing any interactive element from scratch.

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 Component

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';
import { useState } from 'react';

function CartDrawer() {
  const [open, setOpen] = useState(false);

  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 lib/ct/
import { getProduct } from '@/lib/ct/products';

export default function ProductBadge({ sku }: { sku: string }) {
  // fetches product data — domain knowledge, not generic UI
}
CORRECT — no domain knowledge, extends HTML attributes:
// site/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 lib/ct/, lib/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

Pattern 6: Link Component

INCORRECT: raw <a> tag for internal navigation — causes a full page reload and bypasses the Next.js router.
// BAD
<a href="/products">Browse products</a>
CORRECT — import Link from @/i18n/routing:
// 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 useRouter
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 lib/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 site/lib/ct/variant-config.ts. 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.
// site/lib/ct/variant-config.ts
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'.
// site/lib/ct/variant-config.ts
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.
// site/lib/ct/variant-config.ts
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.
// site/lib/ct/variant-config.ts
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.
// site/lib/ct/variant-config.ts
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: SWR Hook and Mutation

Extends Pattern 4 from the shared reference.
The SWR 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) call mutate([KEY_WISHLISTS, customerId]) to trigger a re-fetch. Pass the key tuple explicitly — do not use the global mutate() to avoid invalidating unrelated caches.

For the add/remove heart icon, the mutation should feel instant. Use SWR's optimistic update: immediately toggle the local state, fire the request in the background, and revert only on error.


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 SWR 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 SWR 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
  • Route handlers validate customerId only (not businessUnitKey)
  • SWR hook key is [KEY_WISHLISTS, customerId]; fires only when customerId is resolved
  • All mutations call mutate([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 SWR 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 Route Handler + commercetools helper + SWR hook — the three-layer BFF pattern every data source must follow.

Table of Contents


Pattern 1: Data Flow Rule

INCORRECT: Importing lib/ct/* in a Client Component or calling fetch('/api/*') directly inside a component:
// WRONG — leaks server code into the browser bundle
import { getCustomerOrders } from '@/lib/ct/auth';
// WRONG — direct fetch in component, no cache key
const res = await fetch('/api/orders');
CORRECT — strict one-way data flow:
Client Component
  → hook (site/hooks/*.ts)         'use client' — calls fetch('/api/…')
  → Route Handler (app/api/*)      server-only — calls lib/ct/*
  → lib/ct/<namespace>.ts          server-only — calls apiRoot
  → commercetools API
If a client file needs a type from a commercetools module, import it from @/lib/types instead:
// ✅ correct
import type { Product } from '@/lib/types';

// ❌ wrong — even for types only
import type { ProductProjection } from '@/lib/ct/search';

Pattern 2: Cache Key

INCORRECT: Inlining key strings in the hook — same resource gets different keys across components:
// WRONG
return useSWR('widgets', fetcher);
return useSWR(`widget-${id}`, fetcher);
CORRECT — all keys in lib/cache-keys.ts:
// lib/cache-keys.ts
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: Route Handler

INCORRECT: Writing raw commercetools SDK calls inside the Route Handler:
// WRONG — commercetools logic leaks into the Route Handler
export async function GET() {
  const { body } = await apiRoot.orders().get({ queryArgs: { where: `...` } }).execute();
  return NextResponse.json({ orders: body.results });
}
CORRECT — Route Handler delegates to lib/ct/<namespace>.ts, validates session first:
// app/api/widgets/route.ts
import { NextResponse } from 'next/server';
import { getSession } from '@/lib/session';
import { getWidgets } from '@/lib/ct/widgets';

export async function GET() {
  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 });
  }
}
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)

Pattern 4: commercetools Helper Function

INCORRECT: Adding commercetools SDK calls anywhere outside lib/ct/<namespace>.ts:
// WRONG — commercetools call in a Route Handler
const { body } = await apiRoot.orders().withId({ ID: id }).get().execute();
CORRECT — one function per operation in the matching namespace file:
// lib/ct/widgets.ts
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;
}
commercetools namespace ownership:
FileOwns
lib/ct/auth.tssignInCustomer, signUpCustomer, getCustomerById, updateCustomer
lib/ct/cart.tsAll cart + order operations
lib/ct/orders.tsgetCustomerOrders, getOrderById
lib/ct/search.tssearchProducts, getProductBySku
lib/ct/categories.tsgetCategoryTree, getCategoryBySlug
lib/ct/wishlist.tsShopping list operations

Pattern 5: SWR Hook with Mutations

INCORRECT: Mutating without updating the SWR cache — requires a full refetch to see the change:
// WRONG — cache not updated, UI stale until next revalidation
async function deleteWidget(id: string) {
  await fetch(`/api/widgets/${id}`, { method: 'DELETE' });
}
CORRECT — mutations update SWR cache from the response body, throw on error:
// hooks/useWidgets.ts
'use client';

import useSWR, { useSWRConfig } from 'swr';
import { KEY_WIDGETS, keyWidget } from '@/lib/cache-keys';

export interface Widget { id: string; name: string }

async function widgetsFetcher(): Promise<Widget[]> {
  const res = await fetch('/api/widgets');
  if (!res.ok) return [];
  const data = await res.json();
  return data.widgets ?? [];
}

export function useWidgets() {
  return useSWR<Widget[]>(KEY_WIDGETS, widgetsFetcher, { revalidateOnFocus: false });
}

export function useWidgetMutations() {
  const { mutate } = useSWRConfig();

  async function createWidget(data: Partial<Widget>) {
    const res = await fetch('/api/widgets', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(data),
    });
    if (!res.ok) {
      const d = await res.json().catch(() => ({}));
      throw new Error(d.error || 'Failed to create');
    }
    // Option A: set cache directly from response — no extra round-trip
    const newData = await res.json();
    mutate(KEY_WIDGETS, newData.widgets, { revalidate: false });
    // Option B: revalidate (simpler but adds one request)
    // mutate(KEY_WIDGETS);
  }

  async function deleteWidget(id: string) {
    const res = await fetch(`/api/widgets/${id}`, { method: 'DELETE' });
    if (!res.ok) throw new Error('Failed to delete');
    const newData = await res.json();
    mutate(KEY_WIDGETS, newData.widgets, { revalidate: false });
    mutate(keyWidget(id), null, { revalidate: false }); // clear detail cache
  }

  return { createWidget, deleteWidget };
}
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 (tuple key):
export function useWidgetsByLocale() {
  const { country, currency } = useLocale();
  const key = country && currency ? [KEY_WIDGETS, country, currency] : null;
  return useSWR<Widget[]>(key, ([, c, cur]) => fetchWidgets(c, cur), {
    revalidateOnFocus: false,
  });
}

Checklist

  • Cache key(s) added to lib/cache-keys.ts
  • Route Handler in app/api/ validates session before accessing user data
  • commercetools calls in lib/ct/<namespace>.ts — not inside the Route Handler
  • Hook uses revalidateOnFocus: false (exception: useCartSWR uses true — cart must stay fresh when the user returns from another tab)
  • Mutations throw on error; read hooks return safe defaults
  • Mutations update SWR cache from response body (revalidate: false)
  • Types exported from the hook file — not from lib/ct/
  • No 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 Header.tsx:
const locales = ['en-US', 'de-DE'];
CORRECT — add to COUNTRY_CONFIG in lib/utils.ts only:
// site/lib/utils.ts
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

INCORRECT: hardcoding the locales array instead of deriving it from COUNTRY_CONFIG.
// BAD — must be manually updated every time a country is added
export const routing = defineRouting({
  locales: ['en-US', 'de-DE', 'fr-FR'],
  defaultLocale: 'en-US',
});
CORRECT — derive locales from COUNTRY_CONFIG keys so routing stays in sync automatically:
// site/i18n/routing.ts
import { defineRouting } from 'next-intl/routing';
import { COUNTRY_CONFIG } from '@/lib/utils';

export const routing = defineRouting({
  locales: Object.keys(COUNTRY_CONFIG) as [string, ...string[]],
  defaultLocale: 'en-US',
});
Adding a new entry to COUNTRY_CONFIG is all that's needed — the locales array updates automatically. The COUNTRY_CONFIG key is the BCP-47 locale (e.g. fr-FR), which is the same format commercetools uses for API calls. The URL segment matches the key exactly: /fr-FR/, /de-DE/.

Pattern 3: Message File

INCORRECT: reusing an existing locale file or naming it incorrectly.
// BAD — wrong filename, won't be picked up by next-intl
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 site/messages/de-DE.json site/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).
// site/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 site/lib/utils.ts with locale, currency, country, label
  • BCP-47 locale added to locales array in site/i18n/routing.ts
  • site/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 — Using next/link instead of the locale-aware Link, or omitting generateMetadata, creates broken navigation and missing SEO metadata.

Two patterns: standalone page (most cases) or a layout/sections CMS page (marketing pages).

Table of Contents


Pattern 1: Standalone Page (Server Component)

INCORRECT: Making the page a Client Component or fetching commercetools directly in the page:
// WRONG — no metadata, client component for no reason, direct commercetools import
'use client';
import { apiRoot } from '@/lib/ct/client';
export default function MyPage() { ... }
CORRECT — async Server Component with generateMetadata and commercetools calls via lib/ct/:
// app/[locale]/my-new-page/page.tsx
import type { Metadata } from 'next';
import { getLocale } from '@/lib/session';

export const metadata: Metadata = {
  title: 'My Page',        // root layout template appends '| Home'
  description: 'Page description for SEO',
};

export default async function MyPage() {
  const { locale, currency, country } = await getLocale();
  const data = await fetchMyData(locale);
  return <MyPageContent data={data} />;
}
Never call commercetools SDK directly in a page. Use functions from lib/ct/ which encapsulate the apiRoot calls.
For dynamic generateMetadata, avoiding duplicate fetches with cache(), and OG image generation, see the next-best-practices skill's metadata.md.

Pattern 2: Locale-Aware Linking

INCORRECT: Using next/link or next/navigation in locale-aware pages:
// WRONG — ignores locale prefix, creates broken /en-US/en-US/... URLs
import Link from 'next/link';
import { useRouter } from 'next/navigation';
CORRECT — always import from @/i18n/routing:
// ✅ correct — locale prefix handled automatically
import { Link, useRouter, usePathname } from '@/i18n/routing';

// href values are locale-path-agnostic — the routing layer prefixes them
<Link href="/my-new-page">Go to page</Link>
The Link, useRouter, usePathname, and redirect exports from @/i18n/routing are created by createNavigation(routing) in i18n/routing.ts and handle locale prefixing automatically.

Pattern 3: Dynamic Routes

INCORRECT: Not awaiting params (required since Next.js 15, including 16):
// WRONG — params is a Promise in Next.js 15+ (including 16)
export default function Page({ params }: { params: { id: string } }) {
  const { id } = params; // TypeError
CORRECT — params is always a Promise, always await it:
// app/[locale]/my-thing/[id]/page.tsx
interface PageProps {
  params: Promise<{ id: string; locale: string }>;
}

export default async function MyThingPage({ params }: PageProps) {
  const { id } = await params;
  const data = await fetchThing(id);
  if (!data) notFound();
  return <MyThingView data={data} />;
}

Pattern 4: Client Components Within a Server Page

INCORRECT: Making the whole page a Client Component to handle interactivity:
// WRONG — loses server rendering, all data fetches become client-side
'use client';
export default function MyPage() {
  const [data, setData] = useState(null);
  useEffect(() => { fetch('/api/data').then(...) }, []);
  // ...
}
CORRECT — keep the page as a Server Component, extract interactive parts into a new file:
// page.tsx — Server Component
import MyInteractiveWidget from '@/components/my-page/MyInteractiveWidget';

export default async function MyPage() {
  const data = await fetchData();           // server-side, no loading state
  return <MyInteractiveWidget initialData={data} />;
}

// components/my-page/MyInteractiveWidget.tsx — Client Component
'use client';
export default function MyInteractiveWidget({ initialData }: { initialData: Data }) {
  const [state, setState] = useState(initialData);
  // ... interactive logic
}

Pattern 5: JS event handlers in Server Components

INCORRECT: any event handlers like onChange, onBlur, etc in a Server Component
// WRONG — error when loading page
export default function MyPage() {
  //...
  return (
    <>
    ...
    <select onChange=((e) => {
      // handle event
    })>
    </>
  )
}
CORRECT — keep the page as a Server Component, extract interactive parts into a new file:
// page.tsx — Server Component
import MyInteractiveSelect from '@/components/my-page/MyInteractiveSelect';

export default async function MyPage() {
  const data = await fetchData();           // server-side, no loading state
  return <MyInteractiveSelect selectedValue={} onChange={actionCall} />;
}

// components/my-page/MyInteractiveSelect.tsx — Client Component
'use client';
export default function MyInteractiveSelect({ , onChange } {
 return (

    <select onChange=((e) => {
      // handle event
      // then call onChange()
    })>
  )
}

Checklist

  • Page file at app/[locale]/my-page/page.tsx
  • export const metadata or export async function generateMetadata present
  • import { Link, useRouter } from '@/i18n/routing' — never from next/link / next/navigation
  • Dynamic routes await params (required since Next.js 15)
  • notFound() called for missing required resources
  • Page is an async Server Component by default — 'use client' only on child components that need it
  • Translations added to messages/en-US.json, messages/en-GB.json, messages/de-DE.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 Route Handlers, useCartSWR, CartContext, the mini-cart drawer, and the full cart page.

Table of Contents


Pattern 1: commercetools Cart Helper Functions

lib/ct/cart.ts (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 Route Handlers

Based on the usage, the Helper functions might be used from hooks. So we need api routes to reflect them

Example: Main cart route (GET/POST/PATCH)

// app/api/cart/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { getSession, getLocale, createSessionToken, setSessionCookie } from '@/lib/session';
import { getCart, ... } from '@/lib/ct/cart';

export async function GET() {
  const session = await getSession();
  if (!session.cartId) return NextResponse.json({ cart: null });
  try {
    const cart = await getCart(session.cartId);
    // Discard non-Active carts (Ordered, Merged) — client should see empty cart
    if (cart.cartState && cart.cartState !== 'Active') {
      const token = await createSessionToken({ ...session, cartId: undefined });
      const resp = NextResponse.json({ cart: null });
      return setSessionCookie(resp, token);
    }
    return NextResponse.json({ cart });
  } catch {
    // Cart not found in commercetools — clear stale cartId from session
    const token = await createSessionToken({ ...session, cartId: undefined });
    const resp = NextResponse.json({ cart: null });
    return setSessionCookie(resp, token);
  }
}

export async function POST() {
  // create cart
}


Pattern 3: Cart SWR Hook

INCORRECT: Calling fetch('/api/cart') directly in a component.
CORRECT — useCartSWR + useCartMutations in hooks/useCartSWR.ts:
// types.ts

export interface Cart {
...
}
// hooks/useCartSWR.ts
'use client';

import useSWR, { useSWRConfig } from 'swr';
import { KEY_CART } from '@/lib/cache-keys';
import { Cart } from '@/types'


async function cartFetcher(): Promise<Cart | null> {
  const res = await fetch('/api/cart');
  if (!res.ok) return null;
  const data = await res.json();
  return data.cart ?? null;
}

export function useCartSWR(fallback?: Cart | null) {
  return useSWR<Cart | null>(KEY_CART, cartFetcher, {
    fallbackData: fallback ?? undefined,
    revalidateOnFocus: true,
  });
}

export function useCartMutations() {
  const { mutate } = useSWRConfig();

  // all methods to modify a cart

  return { ... };
}
mutate(KEY_CART, data.cart, { revalidate: false }) — updates the SWR cache directly from the API response body without triggering a second fetch. Always prefer this over mutate(KEY_CART) (which refetches).

Pattern 4: CartContext

// context/CartContext.tsx
'use client';

import { createContext, useContext, useState, useCallback, type ReactNode } from 'react';
import { useCartSWR, useCartMutations, type Cart } from '@/hooks/useCartSWR';

interface CartContextValue {
  cart: Cart | null | undefined;
  isLoading: boolean;
  showMiniCart: boolean;
  openMiniCart: () => void;
  closeMiniCart: () => void;
  addToCart: (productId: string, variantId: number, quantity?: number) => Promise<void>;
  mutateCart: ReturnType<typeof useCartMutations>;
}

const CartContext = createContext<CartContextValue | null>(null);

export function CartProvider({ children, initialCart }: { children: ReactNode; initialCart?: Cart | null }) {
  const [showMiniCart, setShowMiniCart] = useState(false);
  const { data: cart, isLoading } = useCartSWR(initialCart);
  const mutations = useCartMutations();

  const addToCart = useCallback(async (productId: string, variantId: number, quantity = 1) => {
    await mutations.addItem(productId, variantId, quantity);
    setShowMiniCart(true);
  }, [mutations]);

  return (
    <CartContext.Provider value={{ cart, isLoading, showMiniCart, openMiniCart: () => setShowMiniCart(true), closeMiniCart: () => setShowMiniCart(false), addToCart, mutateCart: mutations }}>
      {children}
    </CartContext.Provider>
  );
}

export function useCartContext() {
  const ctx = useContext(CartContext);
  if (!ctx) throw new Error('useCartContext must be inside CartProvider');
  return ctx;
}
Add CartProvider to app/[locale]/layout.tsx. Pass initialCart fetched server-side to eliminate the client-side loading state:
// app/[locale]/layout.tsx (Server Component)
import { getSession } from '@/lib/session';
import { getCart } from '@/lib/ct/cart';
import { CartProvider } from '@/context/CartContext';

export default async function LocaleLayout({ children }: Props) {
  const session = await getSession();
  const initialCart = session.cartId
    ? await getCart(session.cartId).catch(() => null)
    : null;

  return (
    <html lang={locale}>
      <body>
        <NextIntlClientProvider messages={messages}>
          <CartProvider initialCart={initialCart}>
            <Header />
            {children}
            <MiniCart />
          </CartProvider>
        </NextIntlClientProvider>
      </body>
    </html>
  );
}

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

  • lib/ct/cart.ts creates carts with shippingMode: 'Single'
  • GET /api/cart discards non-Active carts and clears cartId from session
  • POST /api/cart/items creates cart on demand if cartId is absent
  • useCartMutations updates SWR cache from response body — no extra refetch
  • CartProvider wraps the locale layout with initialCart from server
  • KEY_CART from lib/cache-keys.ts is the single SWR 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 commercetools checkout frontend SDK, and the confirmation page. Address step details and order placement are storefront-specific — see the relevant extension file.

Table of Contents


Pattern 1: Multi-Step Checkout Page Structure

The checkout is URL-based with three steps: addresses, shipping, payment. The index page reads the cart state and redirects to the furthest step the user can access.
// app/[locale]/checkout/page.tsx  ← redirect index
'use client';

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 has a guard that redirects back if prerequisites are not met:

useEffect(() => {
  if (cart === undefined) return;
  const hasAddr = !!(cart?.shippingAddress?.streetName && cart?.billingAddress?.streetName);
  const hasMethod = !!cart?.shippingInfo;
  if (step === 'shipping' && !hasAddr) router.replace('/checkout/addresses');
  if (step === 'payment' && (!hasAddr || !hasMethod)) router.replace('/checkout/addresses');
}, [cart]);

Layout: two-column grid — steps on the left (3 cols), sticky order summary on the right (2 cols).


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 Route Handler that filters by the session currency. A shipping method with no rate for the current currency must never appear.

// app/api/shipping-methods/route.ts
export async function GET() {
  const { currency } = await getLocale();

  try {
    const result = await getShippingMethods();
    // Filter to methods that have a matching rate for the session currency
    return NextResponse.json({ shippingMethods });
  } catch {
    return NextResponse.json({ shippingMethods: [] });
  }
}
// hooks/useShippingMethods.ts
'use client';

export function useShippingMethods() {
  const { country, currency } = useLocale();
  const key = country && currency ? [keyShippingMethods(country, currency), country, currency] : null;
  return useSWR<ShippingMethod[]>(key, shippingMethodsFetcher, { revalidateOnFocus: false });
}
revalidateOnFocus: false — shipping methods change rarely; no need to re-fetch on tab switch.
When the user selects a method, PATCH /api/cart with shippingMethodId and update the SWR cache from the response.

Pattern 4: Payment Step — commercetools Checkout Frontend SDK

The payment step is handled entirely by the commercetools checkout frontend SDK, which renders the full payment UI and drives order placement.

Reference: See the commercetools 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 a Server Component that fetches the order directly from commercetools by orderId from the URL. Do not rely on client-side SWR here — the order may not yet appear in a freshly revalidated client cache.
// app/[locale]/checkout/confirmation/[orderId]/page.tsx
export default async function ConfirmationPage({ params }: PageProps) {
  const { locale } = await getLocale();
  const { orderId } = await params;

  let order = null;
  try {
    order = await getOrderById(orderId);
  } catch {
    // Order not found — show minimal confirmation without line items
  }

  return (
    <div className="max-w-2xl mx-auto px-4 py-16 text-center">
      {/* Success indicator, order number, line items summary, CTA links */}
    </div>
  );
}
Both flows (cart checkout and quote checkout) redirect to /checkout/confirmation?orderId=<id> on success.

Checklist

  • Checkout index redirects to the correct step based on cart state
  • Step skip guards redirect back if prerequisites are not met
  • GET /api/shipping-methods filters by session currency
  • Address changes debounced to update cart address method
  • Payment step mounts the commercetools checkout frontend SDK — no custom payment form
  • cartId cleared from session after the SDK signals order completion
  • Confirmation page is a Server Component that fetches 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, JWT session management, and the BFF (Backend-for-Frontend) architecture that prevents credential leaks.

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 lib/ct/client.ts — never instantiate ClientBuilder inside a page, component, or Route Handler.

Pattern 2: Environment Variables

See sdk-setup.md for the full .env template, auth URLs by region, and required API client scopes.
SESSION_SECRET (storefront-specific, not in sdk-setup.md) must be at least 32 characters in production.

Pattern 3: JWT Session Management

INCORRECT: Storing cartId or customerId in localStorage — accessible to XSS attacks. Or using server-side session storage — requires infrastructure.
CORRECT — HTTP-only cookie signed with HS256 JWT (server-only jose library):
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;
}

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;
}
Session fields:
FieldSet whenCleared when
customerIdLogin/registerLogout
cartIdCart created or loginOrder placed
country/currency/localeCountry selectorNever (persists)

Pattern 4: BFF Route Handler Shape

INCORRECT: Calling lib/ct/* directly from a Client Component or SWR fetcher.
CORRECT — every commercetools call goes through a Route Handler:
Browser component
  → SWR hook (hooks/*.ts)        — 'use client', calls fetch('/api/...')
  → Route Handler (app/api/...)  — server-only, calls lib/ct/*
  → lib/ct/<namespace>.ts        — server-only, calls apiRoot
  → commercetools API

Typical Route Handler:

// app/api/<resource>/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { getSession } from '@/lib/session';
import { someCTFunction } from '@/lib/ct/<namespace>';

export async function GET(_req: NextRequest) {
  const session = await getSession();
  // Use session.customerId, session.cartId, etc.
  const data = await someCTFunction(/* args */);
  return NextResponse.json({ data });
}

Pattern 5: commercetools Helper Function Shape

INCORRECT: Inlining apiRoot.carts().withId()...execute() directly in a Route Handler. 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 lib/ct/ file:
// lib/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
lib/ct/client.tsapiRoot singleton
lib/ct/auth.tssignInCustomer, signUpCustomer, getCustomerById, updateCustomer
lib/ct/cart.tsAll cart operations (create, addLineItem, removeLineItem, discounts, shipping)
lib/ct/orders.tsgetOrderById, getCustomerOrders
lib/ct/categories.tsgetCategoryBySlug, getCategoryTree
lib/ct/search.tssearchProducts, getProductBySku

Pattern 6: Connection Health Check

After wiring up the client, verify credentials with a one-off health route. Delete it 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"}

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
  • SESSION_SECRET is at least 32 characters in production
  • lib/session.ts exports getSession, 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, Route Handler structure, SWR 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():
// lib/ct/auth.ts
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: Route Handler Structure

Login, register, and logout are BFF Route Handlers — never called client-side from components directly.

Browser component
  → hooks/*Api.ts or useAccount hook   — 'use client', calls fetch('/api/auth/...')
  → app/api/auth/*/route.ts            — server-only, reads/writes session, calls lib/ct/auth.ts
  → lib/ct/auth.ts                     — calls apiRoot

Minimal login Route Handler shape:

// app/api/auth/login/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { loginCustomer } from '@/lib/ct/auth';
import { getSession, createSessionToken, setSessionCookie } from '@/lib/session';

export async function POST(request: NextRequest) {
  const { email, password } = await request.json();
  if (!email || !password) {
    return NextResponse.json({ error: 'Email and password are required' }, { status: 400 });
  }

  const customer = await loginCustomer(email, password);
  const response = NextResponse.json({ customer });

  // Write session — domain-specific handlers add their own fields here
  const token = await createSessionToken({
    customerId: customer.id,
    customerEmail: customer.email,
    customerFirstName: customer.firstName,
    customerLastName: customer.lastName,
  });
  setSessionCookie(response, token);
  return response;
}
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.

Pattern 3: useAccount SWR Hook

INCORRECT: Reading customerId from localStorage or a cookie on the client — not reactive, not server-safe.
CORRECT — SWR hook backed by a /api/auth/me (or /api/account/profile) Route Handler:
// hooks/useAccount.ts
'use client';

import useSWR from 'swr';
import { KEY_ACCOUNT } from '@/lib/cache-keys';

async function accountFetcher() {
  const res = await fetch('/api/account/profile');
  if (!res.ok) return null;
  const data = await res.json();
  return data.customer ?? null;
}

export function useAccount() {
  const { data, mutate } = useSWR(KEY_ACCOUNT, accountFetcher, {
    revalidateOnFocus: false,
  });
  return { user: data, mutate };
}
The Route Handler reads the session cookie and returns the customer object (or null if unauthenticated):
// app/api/account/profile/route.ts
export async function GET() {
  const session = await getSession();
  if (!session.customerId) return NextResponse.json({ customer: null });
  try {
    const customer = await getCustomerById(session.customerId);
    return NextResponse.json({ customer });
  } catch {
    return NextResponse.json({ customer: null });
  }
}
B2B storefronts use GET /api/auth/me and an AuthContext wrapper in addition to the hook — see B2B customer-auth.md for the full pattern.

Pattern 4: Logout — Session and SWR Cache Clearing

INCORRECT: Clearing only the auth cache after logout — cart and other user data remain visible until next page load:
// WRONG — stale cart/order data still in SWR cache
await fetch('/api/auth/logout', { method: 'POST' });
mutate(KEY_ACCOUNT, null, { revalidate: false });
CORRECT — clear all user-scoped SWR caches and clear the session cookie:
// In the component or hook handling logout:
import { mutate } from 'swr';
import { KEY_ACCOUNT, KEY_CART } from '@/lib/cache-keys';

async function handleLogout() {
  await fetch('/api/auth/logout', { method: 'POST' });
  mutate(KEY_ACCOUNT, null, { revalidate: false });
  mutate(KEY_CART, null, { revalidate: false });
  // B2B: also mutate KEY_BUSINESS_UNITS
  router.push('/login');
}
// app/api/auth/logout/route.ts
import { getSession, createSessionToken, setSessionCookie } from '@/lib/session';

export async function POST() {
  const session = await getSession();
  const response = NextResponse.json({ success: true });
  // Preserve locale/currency/country — clear all user fields
  const token = await createSessionToken({
    locale: session.locale,
    currency: session.currency,
    country: session.country,
    // customerId, cartId, and domain-specific fields are intentionally omitted
  });
  setSessionCookie(response, token);
  return response;
}

Checklist

  • lib/ct/auth.ts uses apiRoot.login().post() — NOT apiRoot.customers().login()
  • Login Route Handler writes session cookie with at minimum customerId and customer name fields
  • useAccount hook uses KEY_ACCOUNT as SWR key with revalidateOnFocus: false
  • Logout Route Handler preserves locale, currency, country and clears user fields
  • Logout clears both KEY_ACCOUNT and KEY_CART from SWR cache
Domain extensions:
core/data-loading.md

Data Loading

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

Use async Server Components for first-paint data — no spinner, no hydration delay, SEO-friendly. Use SWR only for data that changes after user interaction.

DataPatternReason
Initial product listServer ComponentFirst paint, SEO, no spinner
Category treeServer Component + TTL cacheStable, needs SSR
CartSWR (useCartSWR)Changes after add/remove actions
Account / ordersSWRChanges after login
Search resultsServer Component (via URL params)SEO, shareable URLs

Rules:

  • All page components are async by default — no 'use client' unless the page needs browser APIs
  • Always await params — it's a Promise in Next.js 15+
  • Call notFound() for missing required resources
  • Pass session to commercetools functions rather than calling getSession() inside each function

Pattern 2: commercetools Type Boundary

commercetools responses must be mapped to app types before leaving lib/ct/. Components import from @/lib/types — never from @commercetools/platform-sdk.
Mappers live in lib/mappers/. Each file maps one commercetools resource to one app type:
FileMaps
lib/mappers/product.tsProductProjectionProduct
lib/mappers/category.tscommercetools Category → app Category
lib/mappers/cart.tscommercetools Cart → app Cart
lib/mappers/order.tscommercetools Order → app Order
lib/mappers/line-item.tscommercetools LineItem → app LineItem
lib/mappers/customer.tscommercetools Customer → app Account
lib/mappers/money.tscommercetools TypedMoney → app Money
lib/mappers/facet.tscommercetools facet results → FacetResult[]
getLocalizedString(field, locale) resolves LocalizedString to a plain string — falls back to default locale then first available. Call it only inside lib/ct/ or lib/mappers/, never in components.

Pattern 3: BFF API Route Shape

Route handlers have exactly three responsibilities — no more:

  1. Validate session
  2. Call lib/ct/<namespace>.ts — never the commercetools SDK directly
  3. Return JSON with the correct status
Never put raw SDK calls in a route handler. Never call fetch('/api/*') directly in a component — put it in hooks/*Api.ts.

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 lib/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 lib/ct/cart.ts and use it in the cart update.

Pattern 5: Server-Side Caching

Use unstable_cache for stable, rarely-changing public data (category tree, project config). It is shared across all requests on the same server — never cache per-user or per-session data here.
export const getCategoryTree = unstable_cache(
  fetchCategoryTree,
  ['category-tree'],
  { revalidate: 60 }
);
Prefer unstable_cache over module-level variables — module-level caches reset on cold starts and are not shared across serverless instances.

Checklist

  • lib/ct/ never imported in 'use client' files — import types from @/lib/types
  • commercetools responses mapped to app types inside lib/ct/<namespace>.ts via mappers
  • getLocalizedString called only in lib/ct/ or lib/mappers/
  • Components import from @/lib/types — never from @commercetools/platform-sdk
  • All independent server-side fetches use Promise.all
  • API routes have exactly 3 responsibilities: validate session, call lib/ct/, return JSON
  • fetch('/api/*') calls live in hooks/*Api.ts, not in components
  • Avoid Cart version conflict by refetch — logic lives in lib/ct/cart.ts
  • Stable public data cached with unstable_cache — never per-user data
core/image-config.md

Image Config

Impact: LOW — All product image URL transforms are in site/lib/ct/image-config.ts. 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.

Table of Contents


Pattern 1: Three Transform Functions

// site/lib/ct/image-config.ts

/**
 * 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.

Pattern 2: Keep unoptimized: true

next.config.ts sets images.unoptimized = true. Do not remove this.
commercetools images come from a CDN that returns 403 or 400 when Next.js appends ?url=...&w=...&q=... optimisation query params. The transform functions in image-config.ts handle sizing directly, making Next.js optimisation redundant.
// site/next.config.ts  (do not change)
const nextConfig: NextConfig = {
  images: {
    unoptimized: true,
  },
};

Pattern 3: next/image Usage and LCP Priority

Always use next/image, never a raw <img> tag. Even with unoptimized: true, next/image lazy-loads below-fold images and prevents layout shift.
import Image from 'next/image';
import { transformListingImageUrl, transformDetailImageUrl } from '@/lib/ct/image-config';

// Product card — lazy-loaded, no priority
<Image
  src={transformListingImageUrl(imageUrl)}
  alt={product.name}
  width={400}
  height={400}
  className="w-full h-full object-cover"
/>

// PDP main image — LCP element, preloads immediately
// Parent must have position: relative and explicit height (e.g. aspect-square)
<Image
  src={transformDetailImageUrl(imageUrl)}
  alt={productName}
  fill
  sizes="(max-width: 768px) 100vw, 50vw"
  className="object-cover"
  priority
/>
priority rule: one image per page, on the LCP element only. Using priority on multiple images defeats the preload — the browser can't prioritise everything. Apply it to:
  • The main PDP carousel image
  • Hero banners on marketing pages
Product card images on listing pages must not have priority — they are below the fold and should lazy-load.

Pattern 4: Suffix Pattern

Insert a size suffix before the file extension, preserving any query string:
// site/lib/ct/image-config.ts

// 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 5: CDN Hostname Replacement

Swap the GCS origin for a custom CDN hostname:

// site/lib/ct/image-config.ts

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 6: Imgix and Cloudinary

Imgix — append query params to the imgix domain:
// site/lib/ct/image-config.ts
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:
// site/lib/ct/image-config.ts
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 7: Adding a New Context

Export a new function from image-config.ts and import it in the component:
// site/lib/ct/image-config.ts
// New context: cart line item thumbnail
export function transformCartImageUrl(url: string): string {
  return addSuffix(url, '-thumb');
}
// site/components/cart/CartItem.tsx
import { transformCartImageUrl } from '@/lib/ct/image-config';

<Image
  src={transformCartImageUrl(item.imageUrl)}
  alt={item.name}
  width={80}
  height={80}
/>
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 SWR hooks serve different consumers:

  • useRecurrencePolicies() — returns Map<policyId, humanLabel> for inline display in cart items and mini cart
  • useRecurrencePoliciesList() — returns the full RecurrencePolicy[] for the PDP selector and subscription pages

Both hooks must share the same SWR 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: API Routes

Standard route 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 route 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 route.

Pattern 7: SWR and Cache

Use SWR for recurring order data — it changes only on explicit user action (pause, resume, cancel).

After any state-change action, call mutate for 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 SWR key for the list should encode the ownership scope (include customerId or businessUnitKey) so the cache auto-invalidates when the user switches context.

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.
SDK type gaps for extension fields: originOrder and nextOrderAt on RecurringOrderDraft, and recurrenceInfo on CartAddLineItemAction, are commercetools extension fields not yet in the generated SDK types. Cast the draft body as unknown at the call site in lib/ct/. Keep the cast as close to the commercetools API call as possible — not in route handlers or components.
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 (lib/mappers/product.ts), not in route handlers 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.
This field is a commercetools extension not in the CartAddLineItemAction SDK type. Cast the action at the call site in lib/ct/cart.ts — not in route handlers or components.

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.
SWR 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 SWR cache key so only one HTTP request is made.
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 Components, BFF). Violations show up as waterfalls, N+1 queries, or unnecessary client re-fetches.
This reference covers parallel data fetching, unstable_cache for stable data, SWR fallback injection from the server, image optimization, and the anti-patterns that crater TTFB.

Table of Contents


Pattern 1: Parallel Fetching in Server Components

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 unstable_cache
]);
// Total: ~50 ms (session/locale win, messages/validation cached)
Category page example — category metadata and tree must be parallel:
// app/[locale]/category/[slug]/page.tsx
const [category, categoryTree] = await Promise.all([
  getCategoryBySlug(slug, locale),
  getCategoryTree(locale),
]);
if (!category) notFound();

// 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: unstable_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:
// WRONG — fresh commercetools call on every render
export default async function RootLayout() {
  const { countries, currencies } = await apiRoot.get().execute(); // called every request
}
CORRECT — unstable_cache with a TTL for data that rarely changes:
// 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]) => {
      const localeMatches = languages.some(
        (l: string) => l.toLowerCase() === config.locale.toLowerCase()
      );
      return countries.includes(country) && currencies.includes(config.currency) && localeMatches;
    })
  );
}

// Cached for 300 seconds (5 minutes) — survives across multiple requests
export const getValidCountryConfig = unstable_cache(
  fetchValidCountryConfig,
  ['locale-validation'],
  { revalidate: 300 }
);
When to use unstable_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 with unstable_cache — its cache is shared across all requests. Use SWR (client-side) or direct commercetools calls (server-side per-request) for user-specific data.

Pattern 3: SWR Fallback Injection from the Server

INCORRECT: Letting SWR fetch the cart and account on initial page load — this causes a loading spinner flash on first render:
// WRONG — SWR fetches from scratch on mount
export function CartProvider({ children }) {
  const { data: cart } = useCartSWR(); // triggers /api/cart on mount
  // ...
}
CORRECT — inject server-fetched data into SWR's cache via SWRConfig fallback:
// 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 not Active, let SWR handle it client-side
  let initialCart = null;
  if (session.cartId) {
    try {
      // fetch (and check cart) cart and then 
       initialCart = cart;
    } catch {
      // stale cartId — SWR will clear it on first client fetch
    }
  }

  // Pre-populate user from session (no extra commercetools call needed)
  const initialUser = session.customerId
    ? {
        // build user object from session
      }
    : null;

  return (
    <html lang="en">
      <body>
        <NextIntlClientProvider messages={messages}>
          {/* KEY_CART and KEY_ACCOUNT pre-filled — useCartSWR and useAccount skip
              their first fetch and render immediately with server data */}
          <SWRConfig value={{ fallback: { [KEY_CART]: initialCart, [KEY_ACCOUNT]: initialUser } }}>
            ...
          </SWRConfig>
        </NextIntlClientProvider>
      </body>
    </html>
  );
}
SWRConfig fallback at the root layout level populates the SWR cache before any Client Component mounts. useCartSWR() and useAccount() see the pre-fetched data immediately — no loading state, no extra round-trip.
Why build initialUser from the session instead of fetching from commercetools:
The session JWT 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 might update fields.

Pattern 4: Image Optimization

Image rendering and URL transforms are fully covered in image-config.md. The performance-critical rules in brief:
  • Never use <img> — always next/image so below-fold images lazy-load automatically.
  • priority on one image per page — the PDP main carousel image or hero banner only. Product card images on listing pages must not have priority.
  • unoptimized: true is intentional — do not remove it; the commercetools CDN rejects Next.js optimisation query params.
See image-config.md for next/image usage patterns, transform functions, CDN/Imgix/Cloudinary config, and the full priority rule.

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
  ... // any other calls
]);

Checklist

  • All independent fetches in Server Components use Promise.all
  • Stable commercetools data (category tree, shipping methods, project config) wrapped in unstable_cache
  • unstable_cache is never used for per-user or per-session data
  • SWRConfig fallback at root layout pre-populates KEY_CART and KEY_ACCOUNT from server
  • initialUser is built from session fields — no extra getCustomerById call in layout
  • PDP main image uses priority prop; product card images do not — see image-config.md Pattern 3
  • 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.

Route Structure

Pick one identifier and use it consistently:

  • SKU-based: app/[locale]/p/[sku]/page.tsx
  • Product ID-based: app/[locale]/p/[productId]/page.tsx
Don't mix strategies — your getProductBy* helper must match the chosen identifier.

PDP Page (Server Component)

The PDP is a Server Component. 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
  ...
]);

if (!product) notFound();
Call notFound() immediately when the product is null — don't render a fallback.

Variant URL Strategy

Switching variants updates only the [sku] URL segment — the Server Component 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 (Server Component 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 <Link> from @/i18n/routing — no bare <a>

Metadata

Fetch with the same context as the page — a mismatch can serve wrong SEO title or description:

export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
  const product = await getProductBySku(sku, ...).catch(() => null);
  if (!product) return {};
  return {
    title: product.metaTitle ?? product.name,
    description: product.metaDescription ?? product.description,
  };
}

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)
  • generateMetadata returns title + description for SEO
  • notFound() called when product doesn't resolve
  • Promise.all for all independent fetches — no waterfalls
  • Breadcrumb uses <Link> from @/i18n/routing — 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 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 'use client' component that receives facets and searchRequest as serializable props from the server page. It:
  1. Reads f_* URL params with 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 with router.push on every selection change, preserving unrelated params
Wrap the panel in a <Suspense> boundary on the server page — it reads useSearchParams which requires Suspense in Next.js App Router.

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

lib/ct/shopping-lists.ts (or lib/ct/wishlists.ts / lib/ct/purchase-lists.ts) 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 lib/ct/. Components must never import from @commercetools/platform-sdk.

Pattern 3: Route Handlers

Shopping list route handlers 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: SWR Hook

Shopping lists change only on explicit user action (create, rename, add item, remove item, delete), so they are a good fit for SWR with revalidateOnFocus: false.

The SWR 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), call mutate(key) — where key uses the same 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.

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.