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
In any Claude Code session:
/plugin marketplace add commercetools/commercetools-skills
/plugin install commercetools@commercetools
If you've updated the plugin or installed it in another window and need the current session to pick up the latest version:
/reload-plugins
commercetools/commercetools-ai-pluginsthen click on the plugin and click "Install"
Instructions Included
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:
-
Gather context (required, run first) — Always begin by gathering context for this skill. This is the mandatory grounding step: it gathers the latest verified documentation as context for you (the agent). Do not skip it, and do not replace it with another tool (such as an MCP documentation-search tool) — run this command:
node scripts/gather-context.mjs \ --query "<extract key terms from user's question>" \ --client-name "<current-client>" \ --model "<current-model>" \ --skill-name "commercetools-storefront" \ --limit 3Use its output as your primary grounding. You may additionally use other tools (such as the commercetools documentation MCP) for deeper, follow-up search. -
Combine with skill references — Cross-reference the analysis output with local references in
./references/for complete context. -
Provide implementation guidance — Synthesize the documentation with the specific integration mode the user is targeting.
Key Takeaways
app/api/). The browser never calls commercetools directly. Secrets never get a NEXT_PUBLIC_ prefix.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.lib/ct/* directly. Cart, account, and user-specific data use SWR hooks → Route Handlers → commercetools SDK.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.apiRoot.asAssociate().withAssociateIdValue({ associateId }).inBusinessUnitKeyWithBusinessUnitKeyValue({ businessUnitKey }).*. commercetools enforces associate permissions server-side.Reference Index
Shared Foundation (references/core/)
| Task | Reference |
|---|---|
| Scaffold the app, Tailwind v4, next-intl routing, locale proxy, folder structure | Run /commercetools-nextjs-setup-project |
| commercetools SDK singleton, JWT sessions, BFF Route Handler shape | core/ct-client.md |
| Shared auth patterns: commercetools login endpoint, Route Handler structure, SWR hook, logout | core/customer-auth.md |
Add a new country / currency / locale — COUNTRY_CONFIG flat structure | core/add-country.md |
Parallel fetching, unstable_cache, SWR prefetch, N+1 avoidance | core/performance.md |
| Product image URL transforms (CDN, Imgix, Cloudinary) | core/image-config.md |
| Cart CRUD, CartContext, SWR hook, mini-cart drawer | core/cart.md |
| Full-text search, facet config, URL state, renderers | core/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 page | core/add-page.md |
| Server vs SWR decisions, mappers, BFF shape, 409 retry | core/data-loading.md |
| Checkout page flow, step routing, order placement | core/checkout-page.md |
| PDP route, variant selectors, shared product detail patterns | core/product-detail.md |
| Shopping lists (wishlist, saved items) | core/shopping-lists.md |
Core Optional Features (references/core/optional/)
| Task | Reference |
|---|---|
| Recurring prices — mapper, PDP gate, selector component, add-to-cart with recurrenceInfo | core/recurring-prices.md |
| Recurring orders — scoping, state transitions, post-checkout creation, recurrence policies | core/recurring-orders.md |
| Deploy to Vercel | Run /deploy-vercel — checks commercetools credentials, then hands off to Vercel's official agent skill |
| Deploy to Netlify | Run /deploy-netlify — checks commercetools credentials, then hands off to Netlify's official agent skill |
B2C Storefront (references/b2c/)
| Task | Reference |
|---|---|
| B2C overview, key takeaways, full reference index | b2c/overview.md |
| Category pages, product mapper, commercetools Search API, ProductCard/Grid | b2c/product-listing.md |
| B2C PDP route, image gallery, variant selectors, AddToCartButton | b2c/product-detail.md |
| Register, login, anonymous cart merge, protected account layout | b2c/customer-auth.md |
| Multi-step checkout, shipping methods, order placement | b2c/checkout.md |
| Navigation patterns, header, mobile menu | b2c/navigation.md |
| Shared UI component library | b2c/ui-components.md |
| PDP variant selector configuration (blocklist, swatch, sort) | b2c/variant-config.md |
| Wishlist functionality | b2c/wishlist.md |
B2C Optional Features (references/b2c/optional/)
| Task | Reference |
|---|---|
| CSR impersonation, dual session, line-item price override | b2c/optional/superuser.md |
| Buy Online Pick Up In Store — channel API, per-store inventory | b2c/optional/bopis.md |
| Product bundles — parent/child cart items, cascade updates | b2c/optional/bundles.md |
| Product discounts, cart discounts, discount codes, promotion banners | b2c/optional/promotions.md |
| Recurring prices — recurrencePrices[] array, gate | b2c/recurring-prices.md |
| Recurring orders — customer scoping, originOrder expand, post-checkout create, skip/setSchedule | b2c/recurring-orders.md |
B2B Storefront (references/b2b/)
| Task | Reference |
|---|---|
| B2B overview, key takeaways, full reference index | b2b/overview.md |
| Session fields, BU/store selection, channel data, BusinessUnitContext | b2b/session-and-bu.md |
| ProductApi session scoping — store, channels, price injection, availability | b2b/product-listing.md |
| B2B PDP route, variant selectors, session-scoped pricing | b2b/product-detail.md |
| as-associate cart CRUD, CartContext, auto-creation with BU+store | b2b/cart.md |
| Cart checkout and "Request a Quote" submission, BU addresses, order placement | b2b/checkout.md |
| Login endpoint, BU auto-select, session fields written at login | b2b/customer-auth.md |
| RBAC — all permission strings, usePermissions, UI gating patterns | b2b/permissions.md |
| Quotes dashboard — CT data model, unified thread list per BU, status labels, SWR hooks | b2b/quotes.md |
| Quote buyer actions — accept & place order, decline, renegotiate, state guards | b2b/quote-actions.md |
| Approval rules, approval flows, predicate builder, tier model | b2b/approval-workflows.md |
| Dashboard shell, stat widgets, pages, sidebar nav items | b2b/dashboard.md |
| Purchase lists (commercetools ShoppingList via as-associate, BU-scoped) | b2b/purchase-lists.md |
| Add a new B2B BFF endpoint + SWR hook | b2b/add-api.md |
| B2B data loading — server vs SWR, mappers, BFF shape | b2b/data-loading.md |
| B2B variant selector configuration | b2b/variant-config.md |
B2B Optional Features (references/b2b/optional/)
| Task | Reference |
|---|---|
| Superuser role — view all store carts, switch carts | b2b/superuser.md |
| Recurring prices — recurrencePrices[] array, as-associate add-to-cart, PDP gate | b2b/recurring-prices.md |
| Recurring orders — BU scoping, cart expand, create-from-cart, duplicate, dashboard | b2b/recurring-orders.md |
Next.js Framework Patterns (references/next-best-practices/)
| Task | Reference |
|---|---|
next/image usage, unoptimized: true, image-config transforms, LCP priority | next-best-practices/image.md |
Static & dynamic metadata, generateMetadata, OG images, cache() deduplication | next-best-practices/metadata.md |
| Server vs Client Component boundary, event handler rules | next-best-practices/server-components.md |
error.tsx, not-found.tsx, redirect() and notFound() gotchas, unstable_rethrow | next-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@^4compatible withnext@^16. - BFF architecture —
lib/ct/*is server-only. Zero commercetools SDK imports in any'use client'file. - Session secrets —
SESSION_SECRETandCTP_CLIENT_SECRETare server-only env vars, never hardcoded orNEXT_PUBLIC_. - commercetools login endpoint —
apiRoot.login().post(), neverapiRoot.customers().login(). - B2B: as-associate chain — ALL B2B writes (cart, order, quote, approval, BU) go through
apiRoot.asAssociate().*. Never use project-levelapiRoot.*for user-facing B2B mutations. - B2B: session B2B fields —
businessUnitKey+storeKey+distributionChannelId+supplyChannelId+productSelectionIdare always written together fromgetStoreChannelData(storeKey). - B2B: three-field locale atomicity —
locale,currency,countrymust all be updated together. ResetcartIdon locale/currency change.
HIGH
- Parallel fetching —
Promise.allfor independent fetches in Server Components. No request waterfalls. - Type safety — Frontend components import types from
lib/types.ts, never fromlib/ct/*. - commercetools type boundary — Map commercetools SDK responses to app types in
lib/mappers/before they leavelib/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 deprecatedproductProjections().search(). See thecommercetools-platformskill → 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-pattern | Correct approach |
|---|---|
import { apiRoot } from '@/lib/ct/client' in a 'use client' file | Use a SWR hook → Route Handler → lib/ct/ |
fetch('/api/*') directly in a component | Encapsulate in a hook in hooks/ |
new ClientBuilder() inside a page or Route Handler | Singleton apiRoot in lib/ct/client.ts |
Raw fetch() to commercetools REST endpoints | Always 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 fetches | Promise.all([fetchA(), fetchB()]) |
apiRoot.customers().login() | apiRoot.login().post() |
| commercetools SDK types in components | Types from lib/types.ts; mapped in lib/mappers/ |
next-intl < 4 or next ≤ 16 | Use next@>16 and next-intl@^4 |
import Link from 'next/link' in a page component | import { Link } from '@/i18n/routing' |
B2B: apiRoot.carts().post(...) for a logged-in user | asAssociate().withAssociateIdValue(...).inBusinessUnitKey(...).carts().post(...) |
B2B: useSWR(KEY_ORDERS, ...) without BU key | useSWR([KEY_ORDERS, businessUnitKey], ...) |
B2B: StagedQuote.sellerComment for per-round display | Quote.sellerComment — the snapshot at quote creation time |
B2B: apiRoot.shoppingLists() for purchase lists | asAssociate().*.shoppingLists() — BU-scoped, permission-enforced |
References
Adding a BFF API Endpoint (B2B)
This reference covers B2B-specific additions only: scoping 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
// WRONG — one BU's orders appear for every other BU
return useSWR(KEY_ORDERS, fetcher);
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 includesbuKeyin the SWR key. Any data that also differs per store (prices, inventory, product selections) includes bothbuKeyandstoreKey.
B2B Addition 2: commercetools helpers must use the as-associate chain
apiRoot for user-facing B2B operations — commercetools does not enforce associate permissions:// WRONG — bypasses B2B permission model; commercetools will not check associate roles
const { body } = await apiRoot.orders().get(...).execute();
asAssociate():// 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
}
associateIdis alwayssession.customerId.businessUnitKeyis alwayssession.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
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 });
// 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 —nullwhenbuKeyabsent - SWR keys for store-scoped data (prices, inventory) use
[KEY, businessUnitKey, storeKey]tuple - commercetools helper uses
asAssociate().withAssociateIdValue(...).inBusinessUnitKeyWithBusinessUnitKeyValue(...) - Route Handler validates both
customerIdANDbusinessUnitKey - After mutation:
mutate([KEY, buKey])to invalidate BU-scoped list
Approval Workflows
This reference covers approval rules (create/edit), approval flows (read/approve/reject), the predicate builder, and the tier model.
Table of Contents
- Pattern 1: How Approval Flows Work
- Pattern 2: Approval Rule Draft Structure
- Pattern 3: Approve/Reject — Read-Then-Write
- Pattern 4: Eligibility Check Before Showing Approve/Reject Buttons
- Pattern 5: PredicateBuilder — Adding a New Condition Type
- Pattern 6: Graceful Degradation on 403
- Checklist
Pattern 1: How Approval Flows Work
- An admin creates an approval rule with a predicate and a tier chain of approver roles.
- When any associate places an order, commercetools evaluates all active rules automatically — no app code triggers this.
- If a rule matches, commercetools creates an approval flow linked to the order.
- Associates with eligible roles see the flow on the order detail page.
- Creates/edits approval rules
- Lists and displays approval flows
- Approves or rejects flows via
{ action: 'approve' }/{ action: 'reject' }
Pattern 2: Approval Rule Draft Structure
// WRONG — commercetools requires the nested tiers/and/or structure
approvers: [{ associateRole: { key: 'approver', typeId: 'associate-role' } }]
and: [{ or: [roles] }]:// 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;
}
const draft = {
name: 'Large Order Approval',
status: 'Active',
predicate: 'totalPrice.centAmount > 500000',
requesters: [{ associateRole: { key: 'buyer', typeId: 'associate-role' } }],
approvers: {
tiers: [
{
// Tier 1: any associate with 'approver' role must approve first
and: [{ or: [{ associateRole: { key: 'approver', typeId: 'associate-role' } }] }],
},
{
// Tier 2: any associate with 'admin' role must approve after tier 1
and: [{ or: [{ associateRole: { key: 'admin', typeId: 'associate-role' } }] }],
},
],
},
};
| Field | commercetools predicate | Value |
|---|---|---|
| Total price | totalPrice.centAmount > 500000 | Integer (display × 100) |
| Line item count | lineItemCount > 5 | Integer |
| Currency | totalPrice.currencyCode = "USD" | ISO 4217 |
and. parsePredicate in PredicateBuilder.tsx handles the order. prefix as well.Pattern 3: Approve/Reject — Read-Then-Write
// WRONG — version may be stale if another approver acted concurrently
await performApprovalAction(flowId, cachedFlow.version, 'approve');
// 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
// WRONG — shows buttons to associates who are not eligible or not in the active tier
{flow.status === 'Pending' && (
<Button onClick={handleApprove}>Approve</Button>
)}
eligibleApprovers and currentTierPendingApprovers:// 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 usesroleKeys(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
components/approval-rules/PredicateBuilder.tsx:-
fieldOptionsarray — add{ value: 'myField', label: 'Display Name', description: '...' } -
handleFieldChange— add anelse if (field === 'myField')branch with default operator/value -
Input JSX — add
{condition.field === 'myField' && (...)}inside the conditions map -
buildPredicateString— addif (c.field === 'myField')branch with commercetools syntax:if (c.field === 'myField') return `myField ${c.operator} ${c.value}`; -
parsePredicate— add a regex branch to recognize the new field:const myFieldMatch = str.match(/(?:order\.)?myField\s*(>|>=|<|<=|=|!=)\s*(.+)/); if (myFieldMatch) return { field: 'myField', operator: myFieldMatch[1], value: myFieldMatch[2].trim() };
totalPrice.centAmount, lineItemCount, totalPrice.currencyCode.Pattern 6: Graceful Degradation on 403
// WRONG — causes an error page for associates who just can't see flows
if (response.status === 403) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
// 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 → orstructure - Approve/reject always calls
fetchApprovalFlowRawfirst to get current version - Approve/reject buttons gated on both
eligibleApproversANDcurrentTierPendingApprovers -
GET /api/approval-flowsreturns empty list on commercetools 403 (no error to browser) - Always expand
order,approvals[*].approver.customer,rejection.rejecter.customerwhen fetching flow detail - New predicate field: touch all 5 locations in
PredicateBuilder.tsx
Cart — B2B extensions
apiRoot.carts() bypasses associate permission enforcement and breaks B2B semantics.Table of Contents
- B2B Extension: The as-associate Chain
- B2B Extension: Cart Creation with BU + Store Context
- B2B Extension: Auto-Creation on First Item Add
- B2B Extension: Distribution Channel on Line Items
- Checklist
B2B Extension: The as-associate Chain
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();
}
associateId and businessUnitKey as additional parameters and
routes through this builder. The associateId is always session.customerId; businessUnitKey is
always session.businessUnitKey. Both are mandatory — never make them optional.B2B Extension: Cart Creation with BU + Store Context
businessUnit and a store reference. Without them commercetools will not
enforce associate permissions and the cart will not be visible within the correct BU scope.// 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);
}
businessUnitKey and storeKey are present in the session
before calling createCart — return 400 if either is missing.B2B Extension: Auto-Creation on First Item Add
/api/cart/items route handler) from the shared reference.createCart call also
requires associateId and businessUnitKey, and the distribution channel must be resolved before
the addLineItem call (see next section). Write cartId to the session before calling addLineItem
so it is persisted even if the item add fails.B2B Extension: Distribution Channel on Line Items
addLineItem helper) from the shared reference.getStoreChannelData(storeKey) (shared cache) and
pass it to every addLineItem call.// 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' }andstore: { key, typeId: 'store' } - POST route validates
businessUnitKeyandstoreKeyare present before creating a cart -
distributionChannelIdfromgetStoreChannelData(storeKey)passed to everyaddLineItem -
cartIdwritten to session beforeaddLineItemcall during auto-creation -
session.cartIdcleared after successful order placement or quote request creation
Checkout — B2B
Table of Contents
Flow 1: Cart Checkout
Address Step — Saved Addresses from the Business Unit
businessUnit.addresses) and identify defaults via businessUnit.defaultShippingAddressId and businessUnit.defaultBillingAddressId. Auto-select these on load; allow the user to pick another BU address.Order Placement Sequence
addresses (shipping + billing) → shipping method → payment (commercetools checkout SDK) → confirmation
- Addresses step — shipping and billing addresses persisted to cart when moving to the next step. All cart writes use the as-associate chain — see the reference.
- Shipping step — user selects a method with
shippingMethodId - Payment step — 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.
- Confirmation — SDK signals completion → clear
cartIdfrom session → redirect to/checkout/confirmation?orderId=<id>
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
session.businessUnitKey and session.storeKey are present. Return a 400 or redirect to an error page if either is missing — a cart without BU/store context cannot be checked out via the as-associate chain.Approval Flows
If the order triggers a B2B approval rule, commercetools creates an ApprovalFlow automatically upon order creation.
Flow 2: Request a Quote
Entry Point
Steps
addresses (shipping + billing) → shipping method → comment → submit
Steps 1 and 2 (addresses and shipping method) follow the same patterns as Flow 1 — BU address selection and shipping method selection from the shared reference. Step 3 is specific to this flow.
comment field. It is optional but the step must always be shown so the user has the opportunity to add context for the seller.Submission
QuoteRequest can be created. The address step in this flow satisfies that — do not skip it or allow bypassing it. If the BU has no addresses and none can be selected, show an error before reaching this step.QuoteRequest from the current cart. All cart writes up to this point (addresses, shipping method) use the as-associate chain.cartId from the session and redirect to:/checkout/quote-request-confirmation?quoteRequestId=<id>
Confirmation Page
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).Checklist
- Extends shared checkout patterns (page structure, shipping methods, payment SDK, confirmation)
- Address step reads BU addresses, auto-selects
defaultShippingAddressId/defaultBillingAddressId - All cart writes in checkout use the as-associate chain
-
session.businessUnitKeyandsession.storeKeyvalidated before rendering checkout - Order placement driven by 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
QuoteRequestcreation (enforced by the address step) - Comment step always rendered; value stored as
commenton the QuoteRequest - Submit button labelled "Submit Quote Request"
-
cartIdcleared from session after quote request creation - Quote request confirmation at
/checkout/quote-request-confirmation?quoteRequestId=<id> -
cartIdcleared from session after order or quote order completion
Customer Authentication — B2B Extensions
BU Auto-Selection at Login
loginCustomer, fetch all business units the customer is an associate of and auto-select the first BU and its first store. This must happen in the same login Route Handler — never leave the session without a businessUnitKey.apiRoot.businessUnits() filtered by associate ID), not the as-associate chain. The as-associate chain is used for all subsequent operations once businessUnitKey is in the session.getStoreChannelData(storeKey) to resolve the channel IDs needed for pricing and inventory.Session Fields Written at Login
setSession() call alongside the base auth fields:- Auth:
customerId,customerEmail,customerFirstName,customerLastName - B2B context:
businessUnitKey,storeKey,storeId,distributionChannelId,supplyChannelId,productSelectionId - Locale: preserve existing
locale,currency,countryfrom the prior session if present; fall back to defaults
Writing these atomically ensures no intermediate state where auth fields exist but B2B context does not.
AuthContext and useAccount
AuthContext reads from GET /api/auth/me, useAccount exposes the same key. No B2B-specific changes needed here.Logout
KEY_AUTH_ME, KEY_CART, and KEY_BUSINESS_UNITS from the 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, andKEY_BUSINESS_UNITSfrom SWR cache - Logout preserves
locale,currency,countryin the session
Dashboard — Shell, Widgets, Pages, Nav
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
- Pattern 2: BU-Keyed SWR Hook
- Pattern 3: Adding a Stat Widget
- Pattern 4: Adding a Dashboard Page
- Pattern 5: Sidebar Nav Items
- Shared UI Primitives
- Checklist
Pattern 1: Dashboard Shell
app/[locale]/dashboard/layout.tsx is a 'use client' component that:- Redirects to
/loginwhen!isLoggedIn(viauseAuth) - Shows a BU-selection screen when
!currentBusinessUnit(viauseBusinessUnit) - Renders two-column:
<aside>DashboardNav</aside>+<main>{children}</main>
Inside any dashboard page, these contexts are always available:
useAuth()—user,isLoggedInuseBusinessUnit()—currentBusinessUnit,currentStore,businessUnitsusePermissions()—can,hasAnyPermission,roleKeysuseToast()—addToast(message)useFormatters()—formatMoney(centAmount, currency),formatDate(isoString)
Pattern 2: BU-Keyed SWR Hook
// WRONG — stale data persists when user switches business units
return useSWR(KEY_ORDERS, ordersFetcher, { revalidateOnFocus: false });
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 }
);
}
nullkey skips the SWR fetch — use it whenbusinessUnitKeyis not yet known. SWR automatically re-fetches when the key changes (BU switch).
Pattern 3: Adding a Stat Widget
app/[locale]/dashboard/page.tsx) renders a statCards array.// 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 }
);
}
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
},
];
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>
);
}
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
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
},
];
hasAnyPermission(item.requiredPermissions) returns false.messages/*.json under "nav":{ "nav": { "mySection": "My Section" } }
Shared UI Primitives
components/ui/:| Component | Key props |
|---|---|
Table | columns, data, loading, emptyMessage, optional onRowClick |
Pagination | total, limit, offset, onChange |
Button | variant (primary/secondary/ghost/danger), href (renders as <Link>), loading, disabled |
Badge | variant (success/warning/error/info/neutral) |
Modal | isOpen, onClose, title, footer, size |
Input / Select | standard labeled form controls with error prop |
Checklist
- New hook uses
[KEY, businessUnitKey]tuple —nullwhen 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/*.jsonfiles
Data Loading — B2B Extensions
as-associate Chain in lib/ct/
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
[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:
| File | Maps |
|---|---|
lib/mappers/business-unit.ts | commercetools BusinessUnit → app BusinessUnit |
lib/mappers/quote.ts | commercetools Quote / QuoteRequest → app types |
lib/mappers/approval-flow.ts | commercetools ApprovalFlow → app ApprovalFlow |
lib/mappers/associate-role.ts | commercetools 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;nullkey when BU not yet resolved - B2B mapper files present for
business-unit,quote,approval-flow,associate-role -
unstable_cachenever used for per-BU data
Recurring Orders — B2B
Table of Contents
- B2B Extension: Scoping and Auth
- B2B Extension: Line Items — originOrder Expand
- B2B Extension: Create Post-Checkout
- B2B Extension: Duplicate
- B2B Extension: API Routes
- B2B Extension: Dashboard Pages
- Checklist
B2B Extension: Scoping and Auth
Scope the list query to the active business unit:
where: businessUnit(key="${businessUnitKey}")
customerId AND businessUnitKey from the session. Single-order fetch and state-change routes require customerId only — the BU filter is only needed on the list.where clause is the only enforcement mechanism — omitting it returns all recurring orders across the entire project.B2B Extension: Line Items — originOrder Expand
originOrder to access line items:expand: ['originOrder']
recurrencePolicyId is not a first-class field on RecurringOrder. Derive it by walking the expanded originOrder's line items in the mapper:originOrder.obj?.lineItems?.find(li => li.recurrenceInfo?.recurrencePolicy?.id)
?.recurrenceInfo?.recurrencePolicy?.id
lib/mappers/recurring-order.ts, not in route handlers.B2B Extension: Create Post-Checkout
startsAt and expiresAt to control when the subscription becomes active and expires:{
originOrder: { typeId: 'order', id: orderId },
cart: { typeId: 'cart', id: cartId },
customer: { typeId: 'customer', id: customerId },
startsAt?: string, // ISO 8601 — optional
expiresAt?: string, // ISO 8601 — optional
}
recurrenceInfo attached to the cart's line items. There is no need to pass a schedule or recurrencePolicyId in the body.B2B Extension: Duplicate
expand: ['originOrder'] to get line item context, then call createRecurringOrder with the same cartId and cartVersion.This is useful for re-activating a cancelled subscription with the same items without rebuilding the cart from scratch.
B2B Extension: API Routes
| Method | Path | Action | Auth required |
|---|---|---|---|
GET | /api/recurring-orders | List, filtered by BU | customerId + businessUnitKey |
GET | /api/recurring-orders/[id] | Fetch single with originOrder expand | customerId |
POST | /api/recurring-orders/[id]/pause | State → paused | customerId |
POST | /api/recurring-orders/[id]/resume | State → active | customerId |
POST | /api/recurring-orders/[id]/cancel | State → canceled | customerId |
POST | /api/recurring-orders/[id]/duplicate | Clone from same cart | customerId |
GET | /api/recurrence-policies | List all policies | Session |
POST routes (not a single PUT). The list route is the only one that requires businessUnitKey.B2B Extension: Dashboard Pages
Recurring orders are a procurement management feature and live under the B2B dashboard:
/[locale]/dashboard/recurring-orders— list with state filter tabs (All / Active / Paused / Cancelled)/[locale]/dashboard/recurring-orders/[id]— detail with pause / resume / cancel actions and order snapshot
customerId + businessUnitKey in session).Checklist
- List
whereclause usesbusinessUnit(key="${businessUnitKey}")— not customer scoping - List route validates both
customerIdANDbusinessUnitKey; all other routes validatecustomerIdonly - Always
expand: ['originOrder'] -
recurrencePolicyIdderived fromoriginOrder.obj.lineItems[].recurrenceInfo.recurrencePolicy.idin the mapper - Create draft includes
originOrder,cart,customer+ optionalstartsAt/expiresAt— noschedulein body - Duplicate fetches with
expand: ['originOrder']and reuses the samecartIdandcartVersion - No
POST /api/recurring-orderscreation route — creation happens from checkout - State-change routes are separate per-action
POSTroutes - Pages under
/[locale]/dashboard/recurring-orders/ -
priceSelectionMode: 'Fixed'on all cart line items with recurrence
Recurring Prices — B2B
Table of Contents
B2B Extension: PDP Gate
The B2B subscription UI requires three conditions to all be true:
- The user is logged in (
isLoggedIn === true) recurringPrices.length > 0for the selected variant- At least one recurrence policy is available (
recurrencePolicies.length > 0)
recurrencePrices entries is eligible. Login is required because anonymous B2B users cannot have recurring orders.recurringPrices (availablePolicies = policies.filter(pol => recurringPrices.some(p => p.recurrencePolicy?.id === pol.id))). Showing all policies would let users select a schedule with no corresponding price.B2B Extension: Add to Cart with Recurrence
associateId (= session.customerId) and businessUnitKey. Implement a dedicated addLineItemWithRecurrence wrapper in 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
-
recurrencePricesmapped fromvariant.recurrencePrices[]— not filtered out ofvariant.prices[] -
regularPriceandrecurringPricesseparated inPDPAddToCartand passed as distinct props — same pattern as core Pattern 2/3 - Gate:
isLoggedIn && recurringPrices.length > 0 && recurrencePolicies.length > 0 -
availablePoliciesfiltered to only those with a matchingrecurrencePricesentry on the variant -
addLineItemWithRecurrencegoes through the as-associate chain -
priceSelectionMode: 'Fixed'on all B2B recurrence add-to-cart actions
Superuser / CSR
superuser. They can view all active carts in their store, switch the active cart to any of them, create merchant-originated carts, and reassign carts to other associates.Session Flag
isSuperuser: boolean is stored in the 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
}));
}
apiRoot.carts() (not as-associate) — superusers read carts they don't own.Create merchant-originated cart
export async function createSuperuserCart(associateId, businessUnitKey, storeKey, currency = 'USD', country = 'US') {
const response = await apiRoot
.asAssociate()
.withAssociateIdValue({ associateId })
.inBusinessUnitKeyWithBusinessUnitKeyValue({ businessUnitKey })
.carts()
.post({
body: {
currency, country,
origin: 'Merchant', // commercetools-native way to mark merchant-created carts
businessUnit: { key: businessUnitKey, typeId: 'business-unit' },
store: { key: storeKey, typeId: 'store' },
},
})
.execute();
return response.body;
}
origin: 'Merchant' marks the cart as merchant-created. Do not set customerId — merchant carts are owner-less until reassigned.Reassign cart to another customer
export async function reassignCart(cartId, version, associateId, businessUnitKey, targetCustomerId) {
const response = await apiRoot
.asAssociate()
.withAssociateIdValue({ associateId })
.inBusinessUnitKeyWithBusinessUnitKeyValue({ businessUnitKey })
.carts()
.withId({ ID: cartId })
.post({ body: { version, actions: [{ action: 'setCustomerId', customerId: targetCustomerId }] } })
.execute();
return response.body;
}
API Routes
| Route | Description |
|---|---|
GET /api/superuser/status | Returns { isSuperuser, carts: [] } — never 403 for non-superusers |
POST /api/superuser/carts | Create merchant cart; writes new cartId to session |
POST /api/superuser/carts/switch | Switch active cart; writes cartId to session |
POST /api/superuser/carts/[id]/reassign | Reassign cart to targetCustomerId |
GET /api/superuser/status returns { isSuperuser: false, carts: [] } for non-superusers — no 403, no information leakage.SuperuserContext
// 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
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
| Component | File | Purpose |
|---|---|---|
SuperuserBanner | components/superuser/SuperuserBanner.tsx | Amber banner — "You are in superuser mode" |
CartBrowser | components/superuser/CartBrowser.tsx | Dropdown listing all store carts — switch or create |
ReassignCartButton | components/superuser/ReassignCartButton.tsx | Select from BU associates to reassign active cart |
CartBrowser appears as a dropdown from a caret next to the cart icon in the Header. ReassignCartButton appears on the cart page.commercetools Prerequisite
superuser in commercetools Merchant Center:- Assign at minimum:
ViewOthersCarts,UpdateOthersCarts,CreateOthersCarts - Assign this role to the test user in their business unit
Key Patterns
| Pattern | Why |
|---|---|
Project-level apiRoot.carts() for listing | Superusers read carts they don't own |
| As-associate chain for create/reassign | commercetools enforces BU membership |
origin: 'Merchant' in cart draft | commercetools-native merchant-cart marker |
expand: ['createdBy.customer'] | One query, no N+1 |
window.location.replace() after switch | Full reload ensures all components see the new cart |
Return { isSuperuser: false } not 403 | No info leakage to non-superusers |
Checklist
-
isSuperuserstored in session at login — not re-checked on every request -
GET /api/superuser/statusreturns empty carts for non-superusers (never 403) -
SuperuserProviderinsideAuthProvider, outsideCartProvider -
SuperuserBannerrendered in layout (after Header, before main) - commercetools associate role
superusercreated with correct permissions
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 thecommercetools-storefrontskill. Load that skill alongside this one when starting a new project.
Key Takeaways (B2B-specific)
apiRoot.asAssociate().withAssociateIdValue({ associateId }).inBusinessUnitKeyWithBusinessUnitKeyValue({ businessUnitKey }).*. The associateId is always session.customerId; the businessUnitKey is always session.businessUnitKey. commercetools enforces associate permissions server-side — no app-level permission checks in Route Handlers.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.ProductApi.buildProjectionParams() injects priceChannel (distributionChannelId), storeProjection (storeKey), and priceCustomerGroupAssignments (accountGroupIds) into every search. Without a store context (unauthenticated users), commercetools returns "Price on request."COUNTRY_CONFIG with three-field atomicity. Same COUNTRY_CONFIG flat structure as B2C (key = BCP-47 locale like de-DE; same value used for commercetools API calls and URL routing). When changing locale or currency, locale, currency, and country must all be updated together and cartId must be reset — commercetools cart currency is immutable.usePermissions() to hide/disable buttons. The API enforces everything automatically via the as-associate chain — a 403 from commercetools means the associate lacks the permission. No app-level authorization code in Route Handlers.Reference Index
Shared Foundation
commercetools-storefront skill for these references:| Task | Reference |
|---|---|
| Scaffold the app, Tailwind v4, next-intl routing, locale proxy | Run /commercetools-nextjs-setup-project |
| commercetools SDK singleton, JWT sessions, BFF architecture | ct-client.md |
| Shared auth base: commercetools login, Route Handler, SWR hook, logout | customer-auth.md |
Add a new country / currency / locale (COUNTRY_CONFIG) | add-country.md |
Parallel fetching, unstable_cache, SWR prefetch, image optimization | performance.md |
| Product image URL transforms (CDN, Imgix, Cloudinary) | image-config.md |
Core — B2B Foundation (follow in order)
| Task | Reference |
|---|---|
| Session fields, BU/store selection, channel data, BusinessUnitContext | session-and-bu.md |
| ProductApi session scoping — store, channels, price injection, availability | product-listing.md |
| PDP route, variant selectors, session-scoped PDP pricing | product-detail.md |
| as-associate cart CRUD, CartContext, auto-creation with BU+store | cart.md |
| Order placement from cart and from quote, confirmation | checkout.md |
| Login endpoint, BU auto-select, session fields written at login | customer-auth.md |
| Full-text search, facet config, URL state, renderers | search-facets.md |
| RBAC — all permission strings, usePermissions, UI gating patterns | permissions.md |
B2B Feature Modules
| Task | Reference |
|---|---|
| Quote lifecycle, multi-round negotiation, commercetools data model, SWR hooks | quotes.md |
| Approval rules, approval flows, predicate builder, tier model | approval-workflows.md |
| Dashboard shell, stat widgets, pages, sidebar nav items | dashboard.md |
| Recurring orders — pause, resume, cancel, duplicate | recurring-orders.md |
| Purchase lists (commercetools ShoppingList via as-associate, BU-scoped) | purchase-lists.md |
Enhancement — Modify Existing Features
| Task | Reference |
|---|---|
| Add a new BFF endpoint + SWR hook (no-fetch-in-client, 3-layer pattern) | add-api.md |
| Server vs SWR decisions, mappers, BFF shape, commercetools type boundary | data-loading.md |
| Add a new page — standalone or dashboard section | add-page.md |
| Configure PDP variant selectors (blocklist, swatch, sort order) | variant-config.md |
Optional Features — Not Required for Core B2B Storefront
| Task | Reference |
|---|---|
| Superuser role — view all store carts, switch carts, merchant-origin carts | superuser.md |
| Personal wishlists (project-level, not as-associate) | wishlists.md |
| Deploy to Vercel | Run /deploy-vercel — checks commercetools credentials, then hands off to Vercel's official agent skill |
| Deploy to Netlify | Run /deploy-netlify — checks commercetools credentials, then hands off to Netlify's official agent skill |
Priority Tiers (B2B-specific additions)
Shared CRITICAL/HIGH/MEDIUM rules (BFF, session secrets, commercetools login endpoint, parallel fetching, type safety, mappers, Product Search API,unstable_cache) are in thecommercetools-storefrontskill.
CRITICAL
- Next.js version — Always use
next@^16. Never write"next": "15.x"inpackage.json. Next.js 15.x has known security vulnerabilities and is unsupported. For new projects, run/commercetools-nextjs-setup-projectwhich pins the correct version automatically. - NextIntl version — Always use
next-intl@^4compatible withnext@^16. Never write"next-intl": "3.x"inpackage.json. - as-associate chain — ALL B2B writes (cart, order, quote, approval, BU) go through
apiRoot.asAssociate().*. Never use project-levelapiRoot.*for user-facing mutations. - Session B2B fields —
businessUnitKey+storeKey+distributionChannelId+supplyChannelId+productSelectionIdare always written together fromgetStoreChannelData(storeKey). - Three-field locale atomicity —
locale,currency,countrymust all be updated together. ResetcartIdon locale/currency change. - Session fields for product pricing — always pass
sessiontosearchProducts()andgetProductBySku(). WithoutdistributionChannelIdandstoreKey, commercetools returns unscoped "Price on request" prices.
HIGH
- BU key in 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.cartIdis absent when adding an item, the Route Handler creates a cart withbusinessUnit+store+currency+countryfrom the session.
MEDIUM
- No-fetch-in-client — all
fetch('/api/*')calls live inhooks/*Api.tsfunctions, not in component or context files. - Store data cache —
storeDataCacheinlib/ct/stores.tsis a module-levelMapwith no TTL. It is the single source forstoreId,distributionChannelId,supplyChannelId,productSelectionId. - Product type cache —
_productTypesCacheinfacets.tshas a 60-second TTL (the only timed cache in the codebase). - Approval flow graceful degradation —
GET /api/approval-flowsreturns{ results: [], total: 0 }on commercetools 403, never a 4xx to the browser. - Quote
sellerCommentis per-round — read fromQuote.sellerComment(snapshot), not fromStagedQuote.sellerComment(mutable latest).
Anti-Patterns Quick Reference (B2B-specific)
Shared anti-patterns (apiRoot in client, NEXT_PUBLIC_ secrets, sequential awaits, etc.) are in thecommercetools-storefrontskill.
| Anti-pattern | Correct approach |
|---|---|
apiRoot.carts().post(...) for a logged-in user | asAssociate().withAssociateIdValue(...).inBusinessUnitKey(...).carts().post(...) |
Separate urlLocale / locale fields in session | Single locale field in BCP-47 (e.g. de-DE) — same value for routing and commercetools API calls |
Setting locale without resetting currency, country, cartId | Update all three fields atomically via POST /api/session/locale |
Omitting distributionChannelId in product search | Pass full session to searchProducts() — ProductApi injects channel automatically |
useSWR(KEY_ORDERS, ...) without BU key | useSWR([KEY_ORDERS, businessUnitKey], ...) — cache must scope to the active BU |
| Reading approval flow version from client state | fetchApprovalFlowRaw() to get current version before every approve/reject |
StagedQuote.sellerComment for per-round display | Quote.sellerComment — the snapshot at quote creation time |
apiRoot.shoppingLists() for purchase lists | asAssociate().*.shoppingLists() — BU-scoped, permission-enforced |
Permissions & RBAC
usePermissions(). 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.usePermissions resolves them, UI gating patterns, and "My vs Others" semantics.Table of Contents
- Pattern 1: Permission Architecture
- Pattern 2: usePermissions Resolution
- Pattern 3: UI Gating Patterns
- Pattern 4: All Permission Strings
- Pattern 5: Nav Item Gating
- Checklist
Pattern 1: Permission Architecture
// 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 });
// ...
}
// 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
// WRONG — role definitions live in commercetools, not in code
const BUYER_PERMISSIONS = ['CreateMyCarts', 'ViewMyOrders'];
const isBuyer = currentUser.roles.includes('buyer');
const canCreateCart = isBuyer;
usePermissions fetches associate roles from commercetools and resolves dynamically:// 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.usePermissionsfetches 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;
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
lib/types.ts:AddChildUnits— create sub-divisionsUpdateBusinessUnitDetails— edit BU name, email, addressesUpdateAssociates— add/remove/change roles of associates
CreateMyCarts/CreateOthersCartsUpdateMyCarts/UpdateOthersCartsDeleteMyCarts/DeleteOthersCartsViewMyCarts/ViewOthersCarts
CreateMyOrdersFromMyCarts/CreateOrdersFromOthersCartsCreateMyOrdersFromMyQuotes/CreateOrdersFromOthersQuotesViewMyOrders/ViewOthersOrdersUpdateMyOrders/UpdateOthersOrders
CreateMyQuoteRequestsFromMyCarts/CreateQuoteRequestsFromOthersCartsAcceptMyQuotes/AcceptOthersQuotesDeclineMyQuotes/DeclineOthersQuotesRenegotiateMyQuotes/RenegotiateOthersQuotesReassignMyQuotes/ReassignOthersQuotesViewMyQuotes/ViewOthersQuotes
CreateApprovalRulesUpdateApprovalRulesUpdateApprovalFlows
ViewMyShoppingLists/ViewOthersShoppingListsCreateMyShoppingLists/CreateOthersShoppingListsUpdateMyShoppingLists/UpdateOthersShoppingListsDeleteMyShoppingLists/DeleteOthersShoppingLists
"My" vs "Others":My*= resources whereresource.customer.id === user.id.Others*= resources owned by any other associate in the BU. commercetools enforces this at the data level — an associate with onlyViewMyOrdersonly receives their own orders from the as-associate endpoint.
Pattern 5: Nav Item Gating
// WRONG — user sees the link, clicks it, then gets an error
<Link href="/dashboard/approval-rules">Approval Rules</Link>
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()orhasAnyPermission() - "My vs Others" pattern used for resource-scoped actions (quotes, orders, carts)
- Approval flow actions gated with
roleKeys(pattern D), not named permissions - Nav items specify
requiredPermissions— items not shown if associate lacks them - New feature: check
lib/types.tsfor the correctPermissionunion strings - Role definitions configured in commercetools Merchant Center — never hardcoded in the app
Product Detail Page — B2B
notFound(), parallel fetching, variant URL strategy, component list, metadata, and attribute labels follow the shared patterns.Data Fetching
Promise.all as per the shared pattern. The helper you call (getProductBySku or getProductById) depends on your chosen route identifier. The critical B2B addition: always pass session to the product fetch — it carries the channel and store context required for correct pricing and availability.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
session to ProductApi injects these parameters into every commercetools query automatically:priceChannel→session.distributionChannelId— scopes price to the customer's channelavailabilityChannel→session.supplyChannelId— scopes availabiltiy to the customer's channelstoreProjection→session.storeKey— filters to store-visible productspriceCustomerGroupAssignments→ applies B2B customer group discounts
session, the product loads at list price with no channel or store filtering. The session is populated during Business Unit selection — never bypass it.Availability
variant.availability.channels[supplyChannelId].availableQuantity for stock display — not variant.availability.isOnStock.isOnStock aggregates across all channels and shows in-stock even when the customer's specific supply channel is out of stock. In a multi-channel B2B setup, this is always wrong.const channelStock = variant.availability?.channels?.[supplyChannelId];
// channelStock.availableQuantity → correct
// variant.availability.isOnStock → never use in B2B
supplyChannelId comes from session, populated during BU selection.Pricing
session is passed. Price display follows the shared pattern — discount with strikethrough, recurring price support.Components
Same as the shared component list, with these B2B additions:
- Product title — product name + active SKU
- Description — from the product description field
- Info attributes — attributes listed in
PDP_INFO_ATTRIBUTES - Related products — products sharing the same category
- Purchase list — add to BU shopping list (requires auth)
Purchase List
Authenticated B2B users can add items to their Business Unit's shared shopping list. Render the purchase list button only when the user is authenticated — it is not available to guests.
Checklist
- Pass
sessionto product fetch — never call without it - Pass
sessioningenerateMetadata— ensures channel-scoped SEO content - Use
channelStock.availableQuantity(notisOnStock) for availability -
supplyChannelIdcomes fromsession— set during BU selection - Purchase list rendered only for authenticated users
Product Listing & Session-Scoped Pricing
ProductApi session injection, how distributionChannelId, storeKey, and accountGroupIds scope prices, and how supplyChannelId drives availability display.Table of Contents
- Pattern 1: Session-Scoped Product Search
- Pattern 2: Price Injection via buildProjectionParams
- Pattern 3: Store-Scoped Category Filtering
- Pattern 4: Availability via supplyChannelId
- Pattern 5: Facet Retry on commercetools Error
- Checklist
Pattern 1: Session-Scoped Product Search
searchProducts without the session — returns global prices:// WRONG — no channel context; commercetools returns unscoped prices or "Price on request"
const results = await searchProducts({ query: searchTerm, locale: 'en-US' });
session to searchProducts():// 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);
}
ProductApireads all B2B fields from the session automatically. The caller passes the full session andProductApiinjects the appropriate commercetools parameters.
Pattern 2: Price Injection via buildProjectionParams
productProjectionParameters manually without channel/store scoping:// WRONG — prices not scoped to the customer's distribution channel
productProjectionParameters: {
priceCurrency: currency,
priceCountry: country,
}
ProductApi.buildProjectionParams() injects all B2B scoping parameters:// 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 } : {}),
};
}
| Parameter | Session field | commercetools effect |
|---|---|---|
priceChannel | distributionChannelId | Returns only prices assigned to this distribution channel |
storeProjection | storeKey | Filters to products in the store's product selection |
priceCustomerGroupAssignments | accountGroupIds | Applies B2B contract pricing for the customer's group |
priceCurrency | currency | Returns prices in this currency |
priceCountry | country | Applies country-specific price scoping |
When the user is not logged in (no store context), all these fields are absent and commercetools returns global prices or "Price on request". This is intentional — B2B pricing requires authentication.
Pattern 3: Store-Scoped Category Filtering
// WRONG — shows categories that have no products in the active store
const categories = await getCategories();
ProductApi.queryCategories uses the store's product selection to filter:// 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
// WRONG — shows availability without considering the store's supply channel
const inStock = product.variants[0].availability?.isOnStock;
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,
},
})),
};
}
supplyChannelIdis NOT sent as a commercetools query parameter — it is only used by the mapper to pick the right channel's inventory data from the response. commercetools returns all channel inventory when no channel filter is applied.
Pattern 5: Facet Retry on commercetools Error
// WRONG — commercetools 400 on invalid facet expression leaves the user with an error page
const results = await searchProducts({ facetConfigurations });
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 -
buildProjectionParamsincludespriceChannel,storeProjection,priceCustomerGroupAssignments -
supplyChannelIdpassed tomapProduct(fromsession.supplyChannelId) - Category listing calls
queryCategorieswith store context to filter empty categories - Product search API retries without facets on commercetools error (products always load)
- Unauthenticated users see "Price on request" — intentional, no fix needed
Quote Actions
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
quote.quoteState:| Action | Allowed states |
|---|---|
| Accept | Pending, RenegotiationAddressed |
| Decline | Pending, RenegotiationAddressed |
| Renegotiate | Pending only |
Show an error and no action buttons if the quote is not in an allowed state.
Accept & Place Order
- Guard: quote must be
PendingorRenegotiationAddressed - User clicks Accept & Place Order
- Transition the quote to
AcceptedviachangeQuoteState - Create the order using
createOrderFromQuote— use the version returned from step 3, not the original quote version - Clear
cartIdfrom session and redirect to/checkout/confirmation?orderId=<id>
Accepted state when it fires.Decline
- Guard: quote must be
PendingorRenegotiationAddressed - User clicks Decline
- Transition the quote to
DeclinedviachangeQuoteState - Redirect to the quotes dashboard
No order is created. The thread remains visible in the dashboard with state "Declined".
Renegotiate
- Guard: quote must be
Pending - Present a textarea for the buyer's counter-comment
- User submits — call
requestQuoteRenegotiationwith thebuyerComment - The quote transitions to
RenegotiationRequested; thebuyerCommentis stored on theQuote - Redirect to the quote detail page
StagedQuote and publishing a new Quote (round N+1). That new Quote shares the same stagedQuote.id and will appear as the next entry in the thread timeline. The buyer then sees state RenegotiationAddressed and can accept, decline, or renegotiate again.Associate Permission Guard
Before rendering any action button, also check that the associate has the appropriate permission:
AcceptMyQuotes— whenquote.customer.id === currentUser.idAcceptOthersQuotes— when acting on another associate's quote
Checklist
- State guard shown before any action button; error displayed for invalid states
- Accept and order creation are sequential — order creation uses the version from the accept response
- No payment SDK step on the quote acceptance page
-
cartIdcleared from session after order creation - Redirects to
/checkout/confirmation?orderId=<id>on order success - Decline redirects to the quotes dashboard with
Declinedstate visible - Renegotiate stores
buyerCommenton the Quote; thread gains a new round after seller responds - Associate permission (
AcceptMyQuotes/AcceptOthersQuotes) checked before rendering actions
Quotes Dashboard
Quote.sellerComment is a per-round snapshot; StagedQuote.sellerComment is the latest-only mutable value. Always use Quote.sellerComment when displaying individual rounds in a thread.Table of Contents
- Pattern 1: CT Data Model — Three Resources
- Pattern 2: as-associate API Constraint
- Pattern 3: Unified List — Thread Grouping
- Pattern 4: Status Labels
- Pattern 5: SWR Hooks
- Checklist
Pattern 1: CT Data Model — Three Resources
QuoteRequest → StagedQuote → Quote (round 1)
↓ renegotiate
Quote (round 2) [same StagedQuote]
| Resource | Who creates it | Key fields |
|---|---|---|
QuoteRequest | Buyer (from active cart) | comment, purchaseOrderNumber, lineItems, totalPrice |
StagedQuote | commercetools automatically | sellerComment (mutable — always latest seller edit) |
Quote | Seller (in Merchant Center) | sellerComment (snapshot per round), buyerComment, validTo, quoteState |
StagedQuote + one or more Quote rounds sharing the same stagedQuote.id. The QuoteRequest is the thread's origin.Pattern 2: as-associate API Constraint
All quote operations — list, detail, actions — must go through the as-associate chain:
apiRoot.asAssociate().withAssociateIdValue(associateId)
.inBusinessUnitKeyWithBusinessUnitKeyValue(buKey)
.quotes()
apiRoot.quotes() bypasses BU scoping and associate permission enforcement.['quoteRequest', 'stagedQuote'] on every fetch — required for the sellerComment per-round snapshot fallback and for reading the buyer's original quoteRequestComment.Pattern 3: Unified List — Thread Grouping
Quote object.Quote objects by stagedQuote.id. Each group represents one thread. Display the QuoteRequest details (date, buyer comment) as the thread's origin row, then the most recent Quote round's state and seller comment as the thread summary.Quote.quoteState as the row's state. If no Quote exists yet for a QuoteRequest, fall back to QuoteRequest.quoteRequestState.Quote round.- Buyer request comment — shown once above all rounds, from
thread[0].quoteRequestComment - Per round:
- Seller comment —
Quote.sellerComment(per-round snapshot, notStagedQuote.sellerComment) - Buyer counter-comment —
Quote.buyerComment, only shown when present (set on renegotiate) - Expiry date — info line when
quote.validTois present
- Seller comment —
- Sort quotes within a thread by
createdAtascending to maintain chronological order
whitespace-pre-wrap to preserve line breaks.Quote objects with the same stagedQuote.id. Defer this fetch until stagedQuote.id is known (pass null to the SWR hook to skip until available).Pattern 4: Status Labels
Map the entity state to a user-facing display label:
| Entity | State | Display label |
|---|---|---|
QuoteRequest | Submitted | Pending review |
QuoteRequest | Accepted | In negotiation |
Quote | Pending | Quote ready |
Quote | RenegotiationRequested | Renegotiation in progress |
Quote | RenegotiationAddressed | Updated quote ready |
Quote | Accepted | Accepted |
Quote | Declined | Declined |
QuoteRequest | Withdrawn | Withdrawn |
Pattern 5: SWR Hooks
| Hook | Returns |
|---|---|
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 |
[KEY, businessUnitKey] so data is isolated per BU. Pass null as the key to defer fetching until the required ID is available.Checklist
-
Quote.sellerCommentused (notStagedQuote.sellerComment) for per-round display - Always
expand: ['quoteRequest', 'stagedQuote']when fetching quotes - All quote API calls via as-associate chain
- Dashboard groups quotes by
stagedQuote.id— one row per thread - Thread state driven by most recent
Quote.quoteState; falls back toQuoteRequest.quoteRequestState - SWR hooks use
[KEY, businessUnitKey]tuple for cache isolation - Actions (accept, decline, renegotiate) handled by quote-actions.md
Session & Business Unit Context
BusinessUnitContext that drives the UI.Table of Contents
- Pattern 1: Session Fields
- Pattern 2: Store Channel Resolution
- Pattern 3: BU Selection — Writing the Session
- Pattern 4: BusinessUnitContext
- Pattern 5: Reading Session Fields in API Routes
- Checklist
Pattern 1: Session Fields
// WRONG — missing B2B fields; product prices and cart scoping will be wrong
await setSession(response, {
customerId: customer.id,
cartId: undefined,
locale: 'en-US', // BCP-47 locale must come with currency and country
});
setSession call:// 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'
}
| Field | Example | Used in |
|---|---|---|
customerId | "abc123" | associateId in every as-associate chain call |
businessUnitKey | "acme-eu" | businessUnitKey in every as-associate chain call |
storeKey | "acme-eu-de" | storeProjection in product search; cart store reference |
distributionChannelId | "ch-abc" | priceChannel in product search; line item distributionChannel |
supplyChannelId | "sc-abc" | Passed to mapProduct for availability display |
productSelectionId | "ps-abc" | Stored for reference; commercetools auto-enforces via storeKey |
accountGroupIds | ["cg-abc"] | priceCustomerGroupAssignments in product search |
locale | "de-DE" | 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
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;
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 };
}
}
storeDataCacheis a module-levelMap— it persists for the server instance lifetime with no TTL. It is the single source of truth for all store → channel mappings. All call sites importgetStoreChannelDatafrom this file.
Pattern 3: BU Selection — Writing the Session
businessUnitKey and storeKey without channel data:// WRONG — products will return unscoped prices; cart creation will fail
await setSession(response, { ...session, businessUnitKey, storeKey });
// 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 onesetSessioncall, so there is never a partially-updated state.
Pattern 4: BusinessUnitContext
fetch('/api/business-units') independently:// WRONG — N fetches, no shared state, no auto-invalidation
useEffect(() => {
fetch('/api/business-units').then(...);
}, []);
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
// 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: '...' });
// 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 -
businessUnitKeyandstoreKeyvalidated before any B2B Route Handler proceeds -
sessionpassed tosearchProducts()— never call with empty/partial session -
BusinessUnitProviderwraps the locale layout and is insideAuthProvider - SWR keys for BU-scoped data use
[KEY, businessUnitKey]tuple - SWR cache cleared on logout (
globalMutate(KEY_BUSINESS_UNITS, { businessUnits: [] }, false))
Shopping Lists — B2B (Wishlists + Purchase Lists)
| Type | Scope | Who sees it |
|---|---|---|
| Wishlist | Personal (customer-owned) | Only the customer |
| Purchase List | BU-shared | All associates in the business unit |
ShoppingList. The separation is entirely in which API chain you use and which session fields you require. Implement them as two separate namespaces (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)
- B2B Extension: Purchase Lists (BU-Scoped)
- B2B Extension: SWR and Mutation
- B2B Extension: UI — Header Icon
- B2B Extension: UI — PDP Add-to-List Flow
- Checklist
B2B Extension: Wishlists (Personal)
apiRoot.shoppingLists() chain, enforce ownership with list.customer?.id === customerId in app code, and do not include a store field in the create draft. Route handlers validate customerId only. The SWR cache key is [KEY_WISHLISTS, customerId]./wishlists/ (personal space), not under /dashboard/ (BU space). This boundary makes the scoping visible in the URL.B2B Extension: Purchase Lists (BU-Scoped)
API Chain
All purchase list operations go through the as-associate chain. commercetools enforces BU membership at the API layer — an associate without access receives a 403, so app-level ownership checks are not needed.
apiRoot
.asAssociate()
.withAssociateIdValue({ associateId })
.inBusinessUnitKeyWithBusinessUnitKeyValue({ businessUnitKey })
.shoppingLists()
associateId is always session.customerId. businessUnitKey is always session.businessUnitKey. Both are mandatory — never make them optional or fall back to a default.Create Draft
customer and store:name: { [locale]: name }
customer: { id: customerId, typeId: 'customer' }
store: { typeId: 'store', key: storeKey }
store field ties the list to the active store so pricing and availability stay consistent when items are moved to cart.Permissions
Create, Add item, Remove item, Rename, Delete) on CreatePurchaseLists / UpdatePurchaseLists via usePermissions(). Hide the purchase lists nav item when the associate lacks ViewPurchaseLists.B2B Extension: SWR and Mutation
| List type | SWR key | Fires when |
|---|---|---|
| Wishlist | [KEY_WISHLISTS, customerId] | customerId is resolved |
| Purchase list | [KEY_PURCHASE_LISTS, businessUnitKey] | businessUnitKey is resolved |
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.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
/wishlists, empty state for unauthenticated users.ViewPurchaseLists.B2B Extension: UI — PDP Add-to-List Flow
In B2B, clicking "Save" or "Add to list" on a PDP opens a modal or popover rather than a heart toggle. The flow supports both wishlists and purchase lists from the same entry point.
- Show two sections: "My Wishlists" (personal) and "Purchase Lists" (BU-shared, only if the associate has
ViewPurchaseLists) - Each section lists existing lists with a checkbox — checking one adds the current product/variant to that list
- A "+ New wishlist" inline input at the bottom of the "My Wishlists" section lets the customer create and add in one step
- A "+ New purchase list" inline input (gated on
CreatePurchaseListspermission) at the bottom of the purchase list section does the same - On confirm, fire all selected add-item mutations in parallel; show a single success toast when all resolve
variantId. This lets associates see at a glance where the product is saved and toggle membership.Checklist
- commercetools calls use project-level
apiRoot.shoppingLists()— not the as-associate chain - Ownership check in app code after single-list fetch:
list.customer?.id === customerId→ 404 on mismatch - Create draft has no
storefield - Route handlers validate
customerIdonly - SWR key is
[KEY_WISHLISTS, customerId] - Pages at
/wishlists/— not under/dashboard/
- All commercetools calls use
asAssociateInStore(associateId, businessUnitKey)— not project-level -
store: { key: storeKey }included in create draft - Route handlers validate
customerIdANDbusinessUnitKey - SWR key is
[KEY_PURCHASE_LISTS, businessUnitKey]; fires only whenbusinessUnitKeyis resolved - All mutations call
mutate([KEY_PURCHASE_LISTS, businessUnitKey])after completing - Permission gates:
ViewPurchaseListsfor nav,CreatePurchaseLists/UpdatePurchaseListsfor write actions - Dashboard nav item hidden when associate lacks
ViewPurchaseLists - Pages under
/dashboard/purchase-lists/
- "Save to list" button opens modal (not a simple heart toggle)
- Modal shows both wishlists and purchase lists in separate sections
- Existing membership pre-fills checkboxes
- Inline create for both list types within the modal
- Add-item mutations fire in parallel on confirm
-
expand: ['lineItems[*].variant']on all list fetches
Variant Selector Configuration
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
'pill':export type VariantRenderer = 'pill' | 'color';
export const VARIANT_RENDERER_MAP: Record<string, VariantRenderer> = {
color: 'color', // → circular swatch using COLOR_HEX
model: 'pill', // → text button (same as default, explicit for clarity)
};
| Renderer | Component | Use for |
|---|---|---|
'pill' | Text button | Any attribute (default) |
'color' | Circular swatch | Color attributes — fills from COLOR_HEX |
COLOR_HEX
Maps lowercase color names/keys to CSS color values for swatch rendering:
export const COLOR_HEX: Record<string, string> = {
black: '#1A1A1A',
gray: '#9CA3AF',
white: '#F9FAFB',
blue: '#3B82F6',
yellow: '#EAB308',
red: '#EF4444',
green: '#22C55E',
silver: '#C0C0C0',
gold: '#D97706',
multicolored: 'linear-gradient(135deg, #3B82F6, #EC4899, #22C55E)',
// add more as needed
};
'yellow', 'black'.VARIANT_SORT_ORDER
Display order for variant selector groups (left to right). Attributes listed here appear first; unlisted attributes are appended after:
export const VARIANT_SORT_ORDER: string[] = ['color'];
// color swatch first, then other attributes in commercetools-defined order
PDP_INFO_ATTRIBUTES
Attribute names to render as informational text sections on the PDP, below the product description. These are excluded from the product details grid:
export const PDP_INFO_ATTRIBUTES: string[] = ['mobility', 'capacity', 'iso45001'];
Each listed attribute renders with its localised commercetools label and its value as preformatted text (preserving line breaks). Attributes with no value on the active variant are silently skipped.
How Variant Navigation Works
<Link href={opt.targetUrl}>. The targetUrl is /{locale}/{categorySlug}/p/{variantSku}.// components/product/VariantSelector.tsx
export interface VariantOption {
label: string;
targetUrl: string; // URL for this variant
colorCode?: string; // hex from COLOR_HEX
isActive: boolean; // current SKU matches
isAvailable: boolean; // stock from supplyChannelId
}
<span> (not <Link>) with opacity-35 cursor-not-allowed styling.How Availability Is Determined
isAvailable on each VariantOption comes from the supplyChannelId in session:// In the variant options builder (lib/ct/product-api.ts or mapper)
const channelStock = variant.availability?.channels?.[session.supplyChannelId];
const isAvailable = channelStock ? channelStock.availableQuantity > 0 : true;
supplyChannelId is not in session (no BU selected), falls back to isOnStock — acceptable for unauthenticated users.Adding a New Color
- Add the commercetools attribute name to
VARIANT_RENDERER_MAPwithrenderer: 'color' - Add hex codes to
COLOR_HEXfor each color value in the commercetools enum - Ensure the commercetools attribute is a variant attribute (not product-level)
Checklist
- New color attribute: add to
VARIANT_RENDERER_MAP+ hex values toCOLOR_HEX - Hidden selector: add attribute name to
VARIANT_SELECTOR_BLOCKLIST - Attribute display order: add to
VARIANT_SORT_ORDER - Info-only attribute (not a selector): add to
PDP_INFO_ATTRIBUTES - Availability uses
channels[supplyChannelId]when BU is active
Checkout — B2C
Address Step — Saved Addresses from Logged-In Customer
useAccount(). On load, auto-select the address flagged isDefaultShipping for the shipping field and isDefaultBilling for the billing field. The user may pick a different saved address from the list or enter a new one manually.Saved addresses are display-only in the selector — editing or adding a new address during checkout writes only to the cart (when moving to the next step), not back to the customer account.
Order Placement Sequence
addresses (shipping + billing) → shipping method → payment (commercetools checkout SDK) → confirmation
- Addresses step — shipping and billing addresses persisted to cart in real time
- Shipping step — user selects a method
- Payment step — commercetools checkout frontend SDK mounts, handles payment capture and order placement
- Confirmation — SDK signals completion → clear
cartIdfrom session → redirect to/checkout/confirmation?orderId=<id>
POST /api/checkout route for order creation.Reference: See the 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/isDefaultBillingfromuseAccount() - Saved address list shown as selectable options; manual entry also allowed
- Address changes debounced to
PATCH /api/cart(inherited from shared) - Shipping method filtered by session currency (inherited from shared)
- Order placement driven by commercetools checkout frontend SDK — no custom order route
-
cartIdcleared from session after SDK order completion event
Customer Authentication — B2C Extensions
Anonymous Cart Merge
anonymousCartId and anonymousCartSignInMode to apiRoot.login().post() when a guest cart exists:// 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
}
session.cartId as anonymousCartId, calls signIn, then writes body.cart.id back into the session as the new cartId.Register
apiRoot.customers().post() creates the account but does not log the customer in. Immediately call signIn after registration so the session is populated and the anonymous cart is merged.Protected Account Layout
/login?redirect=<path> when 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
-
signInpassesanonymousCartId+anonymousCartSignInMode: 'MergeWithExistingCustomerCart'when a guest cart exists - Login Route Handler writes
cart.idfrom the commercetools response back into the session - Register calls
signInimmediately afterapiRoot.customers().post() - Account layout redirects to
/login?redirect=<path>onnull; returnsnullwhile loading
B2C BOPIS (Buy Online, Pick Up In Store)
Store channels expose per-store inventory on the PDP. A ChannelSelector lets customers choose delivery or pickup. The chosen supply channel is attached to cart line items so fulfilment knows which store ships or holds the item.
Key Takeaways
channelId string — always { typeId: 'channel', id: channelId } in cart actions.InventorySupply role for the store selector. Distribution or fulfilment-only channels should not appear in the pickup UI.localStorage. ChannelSelector saves 'delivery' or 'pickup' across navigation. Switching to delivery calls onSelect(null) to clear the supply channel.KEY_CHANNELS / keyChannel(id) from lib/cache-keys.ts. Don't inline cache key strings in hooks.Anti-Patterns
| Anti-pattern | Correct approach |
|---|---|
supplyChannel: channelId (string) | supplyChannel: { typeId: 'channel', id: channelId } |
| Showing all channels in the selector | channels.filter(c => c.roles?.includes('InventorySupply')) |
Hardcoded string key in useSWR call | Use KEY_CHANNELS from lib/cache-keys.ts |
Reference
| Task | Reference |
|---|---|
Channels API (lib/ct/channels.ts), route handlers, supply channel on cart, per-channel inventory, cache keys, useChannels hook, ChannelSelector UI, pickup badge in CartItem | bopis.md |
BOPIS (Buy Online, Pick Up In Store)
{ typeId: 'channel', id: channelId }.BOPIS adds store channels to the cart and shows per-store stock on the PDP.
Table of Contents
- Pattern 1: Channels API
- Pattern 2: Cart Supply Channel
- Pattern 3: Per-Channel Inventory
- Pattern 4: Cache Keys
- Pattern 5: useChannels Hook
- Pattern 6: Type Extensions
- Pattern 7: UI Components
Pattern 1: Channels API
// 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
// BAD
supplyChannel: channelId // string only — commercetools rejects this
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}`;
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
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>
);
}
// 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,getChannelByKeyimplemented inlib/ct/channels.ts - Route handlers at
app/api/channels/route.tsandapp/api/channels/[id]/route.ts -
addLineItemacceptssupplyChannelIdand uses{ typeId: 'channel', id }reference -
app/api/cart/items/route.tspassessupplyChannelIdthrough toaddLineItem -
KEY_CHANNELSandkeyChannel(id)added tolib/cache-keys.ts -
useChannels()anduseChannel(id)hooks created withdedupingInterval: 60_000 -
CartLineItem.supplyChannelId?: stringadded to types -
VariantAvailabilityandVariantChannelAvailabilityinterfaces added -
ChannelSelectorpersists delivery mode tolocalStorage - Pickup badge visible in cart line items when
supplyChannelIdis set
B2C Product Bundles
parentKey. All cart mutations cascade from parent to all matching children. bundleItems() groups them before any component sees the data.Key Takeaways
node tools/create-bundles-custom-type.mjs creates the custom type line-item-additional-info with a parentKey String field. This must exist in commercetools before any bundle add-to-cart calls.parentKey is the cascade link. Parent line items get a UUID key. Children get custom.fields.parentKey set to that UUID. Without this, removing the parent leaves orphaned children in the cart.applyCartAction call. changeLineItemQuantity and removeLineItem must batch parent + all child actions together — not sequential calls that can race on the cart version.bundleItems() runs in the 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-pattern | Correct approach |
|---|---|
Adding children without a parentKey link | Parent gets UUID key; children get custom.fields.parentKey referencing it |
| Removing parent without cascade | Find children by parentKey === parent.key, batch all removals in one action call |
| Grouping in a component | Apply bundleItems() in cartFetcher — components receive pre-grouped data |
| Counting children in cart badge | cartItemCount() filters !i.parentKey before summing |
| Sequential action calls for cascades | Batch all actions in a single applyCartAction to avoid 409 version conflicts |
Reference
| Task | Reference |
|---|---|
| commercetools setup script, CartLineItem extension, cascade cart operations, cart-mapper, items route, bundle-utils, useCartSWR fetcher override, CartItem UI, BundleAddToCart component | bundles.md |
Bundles
parentKey). Without it, removing the parent leaves orphaned child line items in the cart.parentKey custom field. All cart operations cascade from parent to children.Table of Contents
- Pattern 1: commercetools Setup
- Pattern 2: CartLineItem Extension
- Pattern 3: Cart Operations
- Pattern 4: cart-mapper.ts
- Pattern 5: items/route.ts
- Pattern 6: bundle-utils.ts
- Pattern 7: useCartSWR
- Pattern 8: UI
Pattern 1: commercetools Setup
parentKey field:node tools/create-bundles-custom-type.mjs
line-item-additional-info with a parentKey String field.In commercetools Merchant Center, add a bundle attribute to the product type:
- Type:
SetofReferencetoProduct - Name:
bundledProducts(or similar) - Searchable: no
Pattern 2: CartLineItem Extension
// 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
// BAD — no parentKey, no way to cascade removal
await addLineItem(cartId, version, childSku, 1);
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
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
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
// 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>
);
}
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.mjsrun — custom typeline-item-additional-infowithparentKeyfield exists in commercetools -
CartLineItemextended withkey,parentKey,bundledItems -
addLineItemaccepts optionalkeyparameter -
addBundledLineItemscreates children withcustom.fields.parentKey -
changeLineItemQuantityandremoveLineItemcascade to children by matchingparentKey -
cart-mapper.tsmapsctItem.keyandctItem.custom.fields.parentKey -
items/route.tsgenerates UUID parent key and callsaddBundledLineItems -
bundleItems()andcartItemCount()inlib/bundle-utils.ts -
bundleItemsapplied incartFetcherWithBundlesinsideuseCartSWRoverride -
CartItemrendersitem.bundledItemsas sub-rows (usesnext/image, not<img>) -
BundleAddToCartusesuseCartContext().addToCart()— no directfetchin component
B2C Promotions & Discounts
variant.price.discounted and require search expansion to show the name. Cart Discounts silently reduce totals. Discount Codes trigger Cart Discounts and are managed by the existing DiscountCodeForm component — don't re-implement it.Key Takeaways
discountName is always undefined. Add masterVariant.price.discounted.discount and variants[*].price.discounted.discount to productProjectionParameters.expand. The mapper then reads ctPrice.discounted.discount.obj.name to surface the name.DiscountCodeForm already exists — import it, don't rewrite it. It handles apply + remove + SWR KEY_CART invalidation via POST /api/cart/discount and DELETE /api/cart/discount. Just drop <DiscountCodeForm /> where needed.content/message section. Static is simpler; CMS-driven via lib/layout.ts allows content changes without deploys and supports localized strings.Anti-Patterns
| Anti-pattern | Correct approach |
|---|---|
| No expand on discount refs in search | Add both masterVariant and variants[*] discount expand to search params |
| Custom discount code fetch/form | Import <DiscountCodeForm /> from components/cart/ — it already handles everything |
| Hardcoding discount name string | Map from expanded ctPrice.discounted.discount.obj.name in lib/mappers/product.ts |
| Product Discount badge without expand | Name is undefined without the expand — always expand before mapping |
Reference
| Task | Reference |
|---|---|
| Discount types overview, product discount expand + mapper + ProductCard, DiscountCodeForm usage, promotion banner (static + CMS-driven) | promotions.md |
Promotions & Discounts
Table of Contents
- Pattern 1: Discount Types Overview
- Pattern 2: Product Discount Display
- Pattern 3: Discount Code Form
- Pattern 4: Promotion Banner
Pattern 1: Discount Types Overview
| Type | How it works | Where it surfaces |
|---|---|---|
| Product Discount | Changes variant.price.discounted on matching products | Badge + strikethrough on ProductCard and PDPPrice |
| Cart Discount | Reduces lineItem.totalPrice and/or cart.totalPrice silently | Line item price difference, cart total reduction |
| Discount Code | Customer-entered code that triggers a Cart Discount | Applied chip in cart, cart.discountCodes[] |
Pattern 2: Product Discount Display
discountName is undefined.// BAD — discount ref not expanded
const productProjectionParameters = {
body: {
// No expand — variant.price.discounted.discount is just { id: '...' }
},
};
// In component: product.price.discounted?.discountName → undefined
masterVariant and variants discount references:See product-search.md — Pattern 6: Discount Expansion for the full explanation and mapper code.
// 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
<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:
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 */}
</>
);
}
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>
);
}
'content/message': dynamic(() => import('../home/MessageBanner')) in Item.tsx.Checklist
- When showing discount badge/name: expand
masterVariant.price.discounted.discountandvariants[*].price.discounted.discountin search params -
DiscountCodeFormimported (not custom fetch) wherever discount codes are entered - commercetools Merchant Center: Product Discount created and active (if using product-level discounts)
- commercetools Merchant Center: Cart Discount created and active (if using cart-level discounts)
- commercetools Merchant Center: Discount Code created and linked to a Cart Discount (if using codes)
- Promotion banner added via Header (static) or layout sections (CMS-driven)
Recurring Orders — B2C
Table of Contents
- B2C Extension: Scoping and Auth
- B2C Extension: Line Items — originOrder Expand
- B2C Extension: Create Post-Checkout
- B2C Extension: Additional State Actions
- B2C Extension: API Routes
- B2C Extension: Account Pages
- Checklist
B2C Extension: Scoping and Auth
Scope recurring order list fetches to the authenticated customer:
where: customer(id="${customerId}")
customerId from the session. Always read customerId from getSession() on the server — never trust a client-supplied ID.B2C Extension: Line Items — originOrder Expand
originOrder — not cart — to access the line items:expand: ['originOrder']
sub.lineItems?.length ? sub.lineItems : sub.originOrder?.obj?.lineItems ?? []. The top-level lineItems on RecurringOrder is often empty even when the expand succeeds.nextOrderAt → nextOrderDate. This ensures UI components use a consistent field name regardless of which commercetools API version returns which field name.B2C Extension: Create Post-Checkout
recurrenceInfo.recurrencePolicy set, fetch the policy by ID and create one RecurringOrder.originOrder, nextOrderAt, and top-level schedule are commercetools extension fields not in RecurringOrderDraft. Cast the entire body as unknown:{
originOrder: { typeId: 'order', id: orderId },
cart: { typeId: 'cart', id: cartId },
customer: { typeId: 'customer', id: customerId },
startsAt: now.toISOString(),
nextOrderAt: computedDate.toISOString(),
recurringOrderState: 'Active',
schedule: { type: 'standard', value, intervalUnit },
}
nextOrderAt from the policy's schedule.value and schedule.intervalUnit: add N months or N×7 days to the current date. Fetch the full policy by ID at checkout time to get these values — do not trust the summary stored on the line item.B2C Extension: Additional State Actions
B2C supports two additional update actions beyond pause / resume / cancel:
action: 'setOrderSkipConfiguration'
skipConfigurationInputDraft: { type: 'Counter', totalToSkip: N }
action: 'setSchedule'
recurrencePolicy: { id: recurrencePolicyId } // or pass a raw schedule object
PUT /api/account/subscriptions/[id] endpoint, dispatched on an action field in the request body.B2C Extension: API Routes
| Method | Path | Action | Auth |
|---|---|---|---|
GET | /api/account/subscriptions | List customer's recurring orders | customerId |
GET | /api/account/subscriptions/[id] | Single with originOrder expand | customerId |
PUT | /api/account/subscriptions/[id] | All state actions (pause/resume/cancel/skip/setSchedule) | customerId |
GET | /api/recurrence-policies | List all policies | Session |
POST | /api/cart/items | Add line item; recurrencePolicyId optional | customerId |
PUT endpoint — not per-action routes. The action field in the request body dispatches to the correct commercetools update action. There is no separate POST creation route for subscriptions — creation happens post checkout..B2C Extension: Account Pages
Subscriptions are a personal account feature in B2C:
/account/subscriptions— list with status badges (Active / Paused / Cancelled)/account/subscriptions/[id]— detail with pause / resume / cancel / skip actions
Protect both routes with a customer auth guard — unauthenticated requests redirect to login.
Checklist
- List
whereclause usescustomer(id="${customerId}")— not BU scoping - All route handlers validate
customerIdonly — nobusinessUnitKey -
customerIdalways read fromgetSession()— never from request body or query params - Always
expand: ['originOrder']— notcart - Fall back to
originOrder?.obj?.lineItemswhen top-levellineItemsis empty - Normalise
nextOrderAt→nextOrderDateat the API layer, not in the UI - Recurring orders created post-checkout, one per subscription line item
-
RecurringOrderDraftbody cast asunknown— extension fields not in SDK type -
nextOrderAtcomputed from policy schedule fetched at checkout time - Order not rolled back if
createRecurringOrderfails — log and continue - Cancel uses
'canceled'(single-l) as the commercetools state value - Skip uses
setOrderSkipConfiguration; schedule change usessetSchedule - Single
PUTroute handles all state actions viaactionfield dispatch - No dedicated subscription creation route — creation is inside the checkout route
Recurring Prices — B2C
Table of Contents
- B2C Extension: PDP Gate
- B2C Extension: Add to Cart with Recurrence
- Legacy Note: Older SDK Cast Pattern
- Checklist
B2C Extension: PDP Gate
recurringPrices.length > 0 for the selected variant. If the variant has no recurrencePrices entries, render the standard Add to Cart button only.In B2C there is no required login gate — anonymous users can also see subscription pricing. If your storefront requires login before subscribing, enforce that in the add-to-cart handler (redirect to login), not by hiding the selector.
B2C Extension: Add to Cart with Recurrence
apiRoot.carts() — not the as-associate chain. The recurrenceInfo field on CartAddLineItemAction is a commercetools extension not yet in the SDK type. Cast the entire action at the call site in lib/ct/cart.ts:const action = {
action: 'addLineItem',
productId, variantId, quantity,
...(recurrencePolicyId ? { recurrenceInfo: { ... } } : {}),
} as CartUpdateAction;
recurrenceInfo: undefined. The absence of the field is meaningful; an explicit undefined may behave differently depending on the serialiser.Checklist
- Recurring prices read from
variant.recurrencePrices[]— not filtered out ofvariant.prices[] - Mapped
Pricetype includesrecurrencePolicy?: { id: string } - Show subscription UI when
recurringPrices.length > 0for the selected variant — no product attribute check required - Cart operations use project-level
apiRoot.carts()— not as-associate chain -
addLineItemaction cast asCartUpdateActionwhen attachingrecurrenceInfo - One-time selection: omit
recurrenceInfoentirely — do not spreadrecurrenceInfo: undefined - Recurring prices filtered by current currency + country before passing to the selector
- Policy name resolved via
policies.find(p => p.id === price.recurrencePolicy.id)?.name; raw ID as fallback
B2C Superuser (CSR Impersonation)
csrId presence check — UI hiding the input is not sufficient.Key Takeaways
customerId, email, etc.) hold the impersonated customer's data. CSR identity lives in csrId, csrEmail, csrFirstName, csrLastName.CSR_GROUP_ID is the membership gate. Server-only env var — the commercetools Customer Group ID that marks CSR accounts. POST /api/auth/login checks this group before deciding whether to issue a normal session or return { requiresCsrEmail: true }.POST /api/auth/csr-login with both credential sets → session holds dual identity. GET /api/auth/superuser exposes CSR state to client components.csrId guard is non-negotiable. PUT /api/cart/items/[itemId]/price must return 403 when session.csrId is absent. A missing guard lets any authenticated customer override prices.SuperUserContext drives all CSR UI. 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-pattern | Correct approach |
|---|---|
No csrId check on price override route | Return 403 when session.csrId is absent — always, even if UI hides the input |
NEXT_PUBLIC_CSR_GROUP_ID env var | Server-only — no NEXT_PUBLIC_ prefix |
| CSR state in localStorage or React state | JWT session cookie; expose via /api/auth/superuser SWR |
| UI-only gate on price override | Server must enforce; UI visibility is a UX courtesy, not a security control |
Reference
| Task | Reference |
|---|---|
| commercetools setup, session extension, login flow, price override endpoint, SuperUserContext, Header banner, CartItem PriceOverrideInput | superuser.md |
Superuser (CSR Impersonation)
csrId session guard on the price override endpoint. Missing it lets any authenticated user override line item prices.csrId check.Table of Contents
- Pattern 1: commercetools Setup
- Pattern 2: Session Extension
- Pattern 3: Login Flow
- Pattern 4: Price Override
- Pattern 5: SuperUserContext
- Pattern 6: UI
Pattern 1: commercetools Setup
CSR_GROUP_ID to 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:
- Customers → Customer Groups → Create "CSR Agents" group
- Copy the group ID to
CSR_GROUP_ID - Assign CSR agent customer accounts to that group
Pattern 2: Session Extension
Session interface with CSR fields. Normal customer fields (customerId, email, etc.) hold the impersonated customer's data when a CSR is active.// 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);
}
<SuperUserProvider> to app/[locale]/layout.tsx wrapping the children.Pattern 6: UI
// 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 */}
</>
);
}
// 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_IDset insite/.env(server-only, noNEXT_PUBLIC_) -
Sessioninterface extended withcsrId,csrEmail,csrFirstName,csrLastName -
POST /api/auth/loginreturns{ requiresCsrEmail: true }for CSR group members -
POST /api/auth/csr-loginwrites dual identity to session -
GET /api/auth/superuserreturns CSR fields or{} -
PUT /api/cart/items/[itemId]/pricereturns 403 whensession.csrIdis absent -
SuperUserProvideradded to root layout wrapping children - Yellow banner visible in Header during active impersonation
-
PriceOverrideInputrendered in CartItem only whencsrIdis set
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 thecommercetools-storefrontskill. Load that skill alongside this one when starting a new project.
Key Takeaways (B2C-specific)
anonymousCartId and anonymousCartSignInMode: 'MergeWithExistingCustomerCart' on login so the pre-login cart is preserved.en-US, de-DE — the same format commercetools uses. 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
commercetools-storefront skill for these references:| Task | Reference |
|---|---|
| Scaffold the app, Tailwind v4, next-intl routing, locale proxy | Run /commercetools-nextjs-setup-project |
| commercetools SDK singleton, JWT sessions, BFF architecture | ct-client.md |
| Shared auth base: commercetools login, Route Handler, SWR hook, logout | customer-auth.md |
Add a new country / currency / locale (COUNTRY_CONFIG) | add-country.md |
Parallel fetching, unstable_cache, SWR prefetch, image optimization | performance.md |
| Product image URL transforms (CDN, Imgix, Cloudinary) | image-config.md |
Core — Green-Field Build (follow in order)
| Task | Reference |
|---|---|
| Category pages, product mapper, commercetools Search API, ProductCard/Grid | product-listing.md |
| PDP route, image gallery, variant selectors, AddToCartButton | product-detail.md |
| Cart CRUD, CartContext, SWR hook, mini-cart drawer | cart.md |
| Shipping methods, order placement, multi-step checkout, confirmation | checkout.md |
| Register, login, anonymous cart merge, protected account layout | customer-auth.md |
| Full-text search, facet config, URL state, renderers | search-facets.md |
Enhancement — Modify Existing Features
| Task | Reference |
|---|---|
| Add a new BFF endpoint + 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 page | add-page.md |
| Use or extend the shared UI component library | ui-components.md |
| Server vs SWR decisions, mappers, BFF shape, 409 retry | data-loading.md |
| Configure PDP variant selectors (blocklist, swatch, sort order) | variant-config.md |
Optional Features — Separate Skills
These features have their own skills with focused trigger descriptions. Load them when needed.
| Feature | Skill |
|---|---|
| CSR impersonation, dual session, line-item price override | superuser.md |
| Buy Online Pick Up In Store — channel API, per-store inventory | bopis.md |
| Product bundles — parent/child cart items, cascade updates | bundles.md |
| Product discounts, cart discounts, discount codes, promotion banners | promotions.md |
| Deploy to Vercel | Run /deploy-vercel — checks commercetools credentials, then hands off to Vercel's official agent skill |
| Deploy to Netlify | Run /deploy-netlify — checks commercetools credentials, then hands off to Netlify's official agent skill |
Priority Tiers (B2C-specific additions)
Shared CRITICAL/HIGH/MEDIUM rules (BFF, session secrets, parallel fetching, type safety, mappers, Product Search API,unstable_cache) are in thecommercetools-storefrontskill.
CRITICAL
- Next.js version — Always use
next@^16. Never write"next": "15.x"inpackage.json. Next.js 15.x has known security vulnerabilities and is unsupported. For new projects, run/commercetools-nextjs-setup-projectwhich pins the correct version automatically. - NextIntl version — Always use
next-intl@^4compatible withnext@^16. Never write"next-intl": "3.x"inpackage.json.
HIGH
- Anonymous cart merge — Pass
anonymousCartIdto commercetools login so the cart is preserved on sign-in. - SWR cache invalidation — Mutate
KEY_CARTandKEY_ACCOUNTafter login/logout/order placement.
MEDIUM
- Single locale format — BCP-47 everywhere: URL segments, commercetools API calls, cookie, COUNTRY_CONFIG keys all use
en-US,de-DE. No conversion needed.
Anti-Patterns Quick Reference (B2C-specific)
Shared anti-patterns (apiRoot in client, fetch in component, NEXT_PUBLIC_ secrets, etc.) are in thecommercetools-storefrontskill.
| Anti-pattern | Correct approach |
|---|---|
import Link from 'next/link' in a page component | import { Link } from '@/i18n/routing' |
Per-user data in unstable_cache | SWR hook (client) or direct commercetools call (per-request server) |
Product Detail Page — B2C
getAttributeLabels(bcp47) in parallel with the product and pass the result to any component that renders product attributes.getLocale() to get country, currency, and locale for the current session. Pass these to product and price fetches to ensure market-correct pricing.Product Listing
This reference covers category data fetching, the commercetools Product Search API, the product mapper, ProductCard/Grid components, and the Server Component category page.
Table of Contents
- Pattern 1: Category Helper Functions
- Pattern 2: Product Mapper
- Pattern 3: Product Search API
- Pattern 4: Product UI Components
- Pattern 5: Category Page (Server Component)
- Checklist
Pattern 1: Category Helper Functions
lib/ct/categories.ts key functions:getCategoryBySlug(slug, locale): fetch a category by its localized sluggetCategoryById(id, locale): fetch a category by IDgetCategoryTree(locale): fetch all categories (limit: 500, sorted byorderHint) and return as a nested tree
commercetools slug query format:where: \slug(${locale}="${slug}")`— locale is BCP-47 (e.g.en-US), matching both the URL segment and the COUNTRY_CONFIG key. commercetools stores slugs as{ "en-US": "my-slug" }`.
Pattern 2: Product Mapper
ProductProjection objects to components — this pushes too much data to frontend.lib/mappers/product.ts, components only receive Product from @/lib/types:Functions to implement:
mapProduct(p, locale): maps aProductProjectionto the appProducttypemapVariant(v): maps variant fields — id, sku, images, price, prices, attributes, availabilitymapPrice(p): maps price — centAmount, currencyCode, discounted
Pattern 3: Product Search API
productProjectionParameters patterns.lib/ct/search.ts key functions for this storefront:searchProducts(params): queries using the v2 Product Search API. Supports text query,categoryIdfilter viacategoriesSubTree, pagination (limit/offset), and sortgetProductBySku(sku, locale, currency, country): fetches a single product by exact SKU match
Price selection: PasspriceCurrency+priceCountryinproductProjectionParametersso variants arrive with.pricealready resolved to the correct tier.
Pattern 4: Product UI Components
ProductCard: links to the PDP, shows product image, name, and price. If discounted, shows discounted price with original crossed out. Uses<Link>from@/i18n/routingProductGrid: renders a responsive grid ofProductCardcomponents; shows an empty state when no productsPagination: renders a Pagination component which modifies theoffset/limit- Other components to handle Client rendered components (sort, facets, etc)
Pattern 5: Category Page (Server Component)
fetch('/api/products') from a category page — unnecessary round-trip through the BFF for data that's only ever server-rendered.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.tsexportsgetCategoryBySlug,getCategoryById,getCategoryTree -
getCategoryTreefetches withlimit: 500 -
lib/mappers/product.tsexportsmapProduct— components never receive raw commercetools types -
lib/ct/search.tsusesapiRoot.products().search()(v2 API), not legacyproductProjections - Category page uses
Promise.allto fetch category + category tree in parallel - Breadcrumb walks the in-memory tree — no N+1 parent ID lookups
- Breadcrumb and pagination use
<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
UI Components
components/ui/ creates inconsistent styling and duplicated behaviour.site/components/ui/. Check it before writing any interactive element from scratch.Table of Contents
- Pattern 1: Check Before Writing
- Pattern 2: Button Component
- Pattern 3: Input Component
- Pattern 4: Drawer Component
- Pattern 5: Adding a New Component
- Pattern 6: Link Component
Pattern 1: Check Before Writing
// BAD
<button className="px-5 py-2.5 bg-black text-white rounded-lg hover:bg-gray-800">
Add to Cart
</button>
@/components/ui/:// GOOD
import Button from '@/components/ui/Button';
<Button variant="primary" onClick={handleAddToCart}>
Add to Cart
</Button>
components/ui/: Button, Input, Drawer, Badge, Spinner, Modal, Select.Pattern 2: Button Component
variant ('primary' | 'secondary' | 'outline' | 'ghost'), size ('sm' | 'md' | 'lg'), isLoading, disabled.import Button from '@/components/ui/Button';
// Primary CTA
<Button variant="primary" size="lg">
Checkout
</Button>
// Secondary action
<Button variant="secondary" size="md">
Save for Later
</Button>
// Outline / bordered
<Button variant="outline" size="sm">
View Details
</Button>
// Ghost / text-only
<Button variant="ghost" size="sm">
Remove
</Button>
// Loading state — shows spinner, disables click
<Button variant="primary" isLoading={submitting}>
Place Order
</Button>
// Disabled
<Button variant="primary" disabled>
Out of Stock
</Button>
// As a link (passes through HTML anchor attributes)
<Button variant="primary" as="a" href="/cart">
View Cart
</Button>
Pattern 3: Input Component
label, error. It is a forwardRef component — compatible with react-hook-form and similar.import Input from '@/components/ui/Input';
import { useRef } from 'react';
// Basic usage
<Input
label="Email address"
type="email"
placeholder="you@example.com"
onChange={(e) => setEmail(e.target.value)}
/>
// With validation error
<Input
label="Password"
type="password"
error="Password must be at least 8 characters"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
// With forwardRef (react-hook-form)
const { register, formState: { errors } } = useForm();
<Input
label="First name"
error={errors.firstName?.message}
{...register('firstName', { required: 'Required' })}
/>
Pattern 4: Drawer Component
isOpen, onClose, title, children, footer, position ('left' | 'right').import Drawer from '@/components/ui/Drawer';
import Button from '@/components/ui/Button';
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>
</>
);
}
Thefooterslot is rendered at the bottom of the drawer, above the scroll area. Use it for sticky CTAs.
Pattern 5: Adding a New Component to components/ui/
components/ui/.// BAD — ui/ component importing from lib/ct/
import { getProduct } from '@/lib/ct/products';
export default function ProductBadge({ sku }: { sku: string }) {
// fetches product data — domain knowledge, not generic UI
}
// 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>
);
}
components/ui/:- No imports from
lib/ct/,lib/mappers/, or domain hooks - Props interface must extend the relevant
HTML*Attributestype - Accept and spread
...propsso callers can addaria-*,data-*,classNameetc. - Export a single default component per file
Pattern 6: Link Component
<a> tag for internal navigation — causes a full page reload and bypasses the Next.js router.// BAD
<a href="/products">Browse products</a>
Link from @/i18n/routing:// GOOD
import Link from '@/i18n/routing';
<Link href="/products">Browse products</Link>
When to use each:
| Case | Use |
|---|---|
| Internal route (same origin) | <Link href="..."> |
External URL (https://...) | <a href="..." target="_blank" rel="noopener noreferrer"> |
| Button that navigates programmatically | router.push(...) via useRouter |
Button component — pass as="a" only for external links; use <Link> wrapping for internal ones:import Link from '@/i18n/routing';
import Button from '@/components/ui/Button';
// Internal CTA — Link wraps Button
<Link href="/cart">
<Button variant="primary">View Cart</Button>
</Link>
// External CTA — Button renders as <a>
<Button variant="outline" as="a" href="https://docs.example.com" target="_blank" rel="noopener noreferrer">
Read Docs
</Button>
Never use<a href="...">for routes inside the app.<Link>prefetches the destination, preserves scroll position, and avoids full-page reloads.
Checklist
-
components/ui/checked before writing raw HTML for buttons, inputs, drawers, badges - New generic UI components placed in
components/ui/(not in feature folders) - Props interface extends the appropriate
HTML*Attributestype - No
lib/ct/imports insidecomponents/ui/files -
...propsspread passed through to the underlying HTML element -
<Link href="...">used for all internal navigation — no bare<a>tags pointing to internal routes - External links use
<a target="_blank" rel="noopener noreferrer">(not<Link>)
Variant Config
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
- Pattern 2: Renderer Map
- Pattern 3: Color Code
- Pattern 4: Sort Order
- Pattern 5: Info Attributes
- Config Summary
Pattern 1: Blocklist
VARIANT_SELECTOR_BLOCKLIST: string[] — attribute names that are never shown as selectors. Add any attribute that should not appear as a clickable option on the PDP.// 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 inPDP_INFO_ATTRIBUTESshould also be added here — otherwise it will render as both a selector and an info block.
Pattern 2: Renderer Map
VARIANT_RENDERER_MAP: Record<string, VariantRenderer> — maps an attribute name to a renderer. Attributes not listed default to 'pill'.// 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 inVARIANT_COLOR_CODE_ATTR
Pattern 3: Color Code
VARIANT_COLOR_CODE_ATTR: Record<string, string> — maps a display attribute (e.g. 'color-label') to its companion hex attribute (e.g. 'color-code'). Used by the 'color' renderer to determine the swatch background colour.// 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',
};
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 toVARIANT_SELECTOR_BLOCKLISTas well to avoid duplicate rendering.
Config Summary
| Variable | Type | Purpose |
|---|---|---|
VARIANT_SELECTOR_BLOCKLIST | string[] | Attribute names never shown as selectors |
VARIANT_RENDERER_MAP | Record<string, VariantRenderer> | Maps attribute → 'pill' or 'color' renderer |
VARIANT_COLOR_CODE_ATTR | Record<string, string> | Maps display attribute → companion hex attribute for color swatches |
VARIANT_SORT_ORDER | string[] | Left-to-right display order; unlisted attributes appear after |
PDP_INFO_ATTRIBUTES | string[] | Attributes shown as text info blocks below description (in <pre>) |
Wishlists — B2C
Table of Contents
- B2C Extension: API Chain and Ownership
- B2C Extension: Create Draft
- B2C Extension: SWR Hook and Mutation
- B2C Extension: UI — Header Icon
- B2C Extension: UI — Heart Icon on PDP and PLP
- B2C Extension: Pages
- Checklist
B2C Extension: API Chain and Ownership
apiRoot.shoppingLists() — not the as-associate chain. commercetools does not restrict this endpoint by customer, so ownership must be enforced in app code on single-item fetches: after retrieving a list by ID, check that list.customer?.id === customerId and return 404 if it does not match. This prevents ID-guessing attacks where one customer could read another's wishlist.where: customer(id="${customerId}") filter on list fetches scopes the results to the authenticated customer without needing the as-associate chain.B2C Extension: Create Draft
name and customer. Do not include a store field — B2C wishlists are not store-scoped and should be visible regardless of which storefront the customer visits.name: { [locale]: name }
customer: { id: customerId, typeId: 'customer' }
B2C Extension: SWR Hook and Mutation
[KEY_WISHLISTS, customerId]. The hook should only fire when customerId is available — pass null as the key when the customer is not yet resolved to avoid fetching as anonymous.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.lengthacross all lists) - Navigate to
/wishlistson click - Render as an empty icon when the customer is not logged in (no badge, click redirects to login)
- Use the same icon weight and size as the cart icon in the header for visual consistency
The count should come from the same 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.
- Filled heart = product is already in at least one wishlist
- Outline heart = product is not in any wishlist
- Clicking an outline heart adds the product to the customer's default (or only) wishlist
- Clicking a filled heart removes the product from all wishlists it appears in
- If the customer has no wishlists yet, the first click creates a default one named "My Wishlist" and adds the item
- If the customer is not logged in, clicking the heart redirects to login (with a
?redirectparam to come back)
- PDP: prominent button or icon near the Add to Cart CTA — visually secondary to it
- PLP: overlaid icon in the top-right corner of the product card, visible on hover (desktop) or always visible (mobile)
isSaved state. Pass variantId (or productId as fallback) as the lookup key.B2C Extension: Pages
/wishlists/:/wishlists— list all of the customer's wishlists with item count and thumbnail preview/wishlists/[id]— detail view with item cards, add-to-cart per item, and remove-from-list action
Protect both routes with an auth guard — unauthenticated requests redirect to login.
Checklist
- commercetools calls use project-level
apiRoot.shoppingLists()— not the as-associate chain - Ownership check in app code after single-list fetch:
list.customer?.id === customerId→ 404 on mismatch - Create draft has no
storefield - Route handlers validate
customerIdonly (notbusinessUnitKey) - SWR hook key is
[KEY_WISHLISTS, customerId]; fires only whencustomerIdis 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
?redirectparam - 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
Adding a BFF API Endpoint
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
- Pattern 2: Cache Key
- Pattern 3: Route Handler
- Pattern 4: commercetools Helper Function
- Pattern 5: SWR Hook with Mutations
- Checklist
Pattern 1: Data Flow Rule
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');
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
@/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
// WRONG
return useSWR('widgets', fetcher);
return useSWR(`widget-${id}`, fetcher);
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
// 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 });
}
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 });
}
}
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
lib/ct/<namespace>.ts:// WRONG — commercetools call in a Route Handler
const { body } = await apiRoot.orders().withId({ ID: id }).get().execute();
// 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;
}
| File | Owns |
|---|---|
lib/ct/auth.ts | signInCustomer, signUpCustomer, getCustomerById, updateCustomer |
lib/ct/cart.ts | All cart + order operations |
lib/ct/orders.ts | getCustomerOrders, getOrderById |
lib/ct/search.ts | searchProducts, getProductBySku |
lib/ct/categories.ts | getCategoryTree, getCategoryBySlug |
lib/ct/wishlist.ts | Shopping list operations |
Pattern 5: SWR Hook with Mutations
// WRONG — cache not updated, UI stale until next revalidation
async function deleteWidget(id: string) {
await fetch(`/api/widgets/${id}`, { method: 'DELETE' });
}
// 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 intry/catchand shows the error. Read hooks return safe defaults (null,[]) on failure — never throw.
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:useCartSWRusestrue— 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
Add Country / Locale
COUNTRY_CONFIG object. Update it once, then add a messages file, routing entry, and hero config.Table of Contents
- Pattern 1: Single Source of Truth
- Pattern 2: Routing Update
- Pattern 3: Message File
- Pattern 4: Hero Config
Pattern 1: Single Source of Truth
// BAD — scattered across files
// In cart.ts:
currency: 'EUR'
// In checkout.ts:
country: 'DE'
// In Header.tsx:
const locales = ['en-US', 'de-DE'];
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: '🇫🇷',
},
};
locale value is used directly in commercetools search queries (language: locale). The currency is used when creating carts. Everything else derives from this map — no other files need direct currency/country hardcoding.Pattern 2: Routing Update
COUNTRY_CONFIG.// 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',
});
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 toCOUNTRY_CONFIGis all that's needed — thelocalesarray 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
// BAD — wrong filename, won't be picked up by next-intl
messages/fr.json
messages/fr-fr.json
messages/FR.json
messages/<BCP-47>.json matching the key in COUNTRY_CONFIG:# Copy the closest existing locale as a starting point
cp site/messages/de-DE.json site/messages/fr-FR.json
fr-FR.json. The filename must exactly match the COUNTRY_CONFIG key (e.g. fr-FR.json for the 'fr-FR' entry).// 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_CONFIGinsite/lib/utils.tswithlocale,currency,country,label - BCP-47 locale added to
localesarray insite/i18n/routing.ts -
site/messages/<BCP-47>.jsoncreated with all translation keys - commercetools Merchant Center: prices defined for the new currency in the product catalogue
- commercetools Merchant Center: shipping zones/methods set up for the new country
Adding a New Page
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)
- Pattern 2: Locale-Aware Linking
- Pattern 3: Dynamic Routes
- Pattern 4: Client Components Within a Server Page
- Checklist
Pattern 1: Standalone Page (Server Component)
// WRONG — no metadata, client component for no reason, direct commercetools import
'use client';
import { apiRoot } from '@/lib/ct/client';
export default function MyPage() { ... }
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 fromlib/ct/which encapsulate theapiRootcalls.
For dynamicgenerateMetadata, avoiding duplicate fetches withcache(), and OG image generation, see thenext-best-practicesskill's metadata.md.
Pattern 2: Locale-Aware Linking
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';
@/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>
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
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
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
// 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(...) }, []);
// ...
}
// 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
// WRONG — error when loading page
export default function MyPage() {
//...
return (
<>
...
<select onChange=((e) => {
// handle event
})>
</>
)
}
// 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 metadataorexport async function generateMetadatapresent -
import { Link, useRouter } from '@/i18n/routing'— never fromnext/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
Cart
cartId are the most common production bugs. Every write path must re-fetch version and retry.useCartSWR, CartContext, the mini-cart drawer, and the full cart page.Table of Contents
- Pattern 1: commercetools Cart Helper Functions
- Pattern 2: Cart Route Handlers
- Pattern 3: Cart SWR Hook
- Pattern 4: CartContext
- Pattern 5: Mini-Cart Drawer
- Checklist
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
fetch('/api/cart') directly in a component.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 overmutate(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;
}
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
cart greenfield skill. Key points:- Renders only when
showMiniCart === true - Backdrop click calls
closeMiniCart() - Items use
mutateCart.removeLineItem()for optimistic removal - "Proceed to Checkout" link closes the mini-cart
commercetools Concurrency Notes
version integer. If two requests arrive simultaneously (e.g. address + shipping method fired at the same time from the checkout page), one will be rejected with 409 ConcurrentModification.Checklist
-
lib/ct/cart.tscreates carts withshippingMode: 'Single' -
GET /api/cartdiscards non-Active carts and clearscartIdfrom session -
POST /api/cart/itemscreates cart on demand ifcartIdis absent -
useCartMutationsupdates SWR cache from response body — no extra refetch -
CartProviderwraps the locale layout withinitialCartfrom server -
KEY_CARTfromlib/cache-keys.tsis the single SWR key for cart data
Checkout Page
This reference covers the shared checkout structure used by both B2C and B2B storefronts: the multi-step page shell, shipping method selection, payment via the 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
- Pattern 2: Address Step
- Pattern 3: Shipping Method Selection
- Pattern 4: Payment Step — commercetools Checkout Frontend SDK
- Pattern 5: Confirmation Page
- Checklist
Pattern 1: Multi-Step Checkout Page Structure
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.
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
cartIdfrom the session and redirect to the confirmation page.
Pattern 5: Confirmation Page
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>
);
}
/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-methodsfilters by session currency - Address changes debounced to update cart address method
- Payment step mounts the commercetools checkout frontend SDK — no custom payment form
-
cartIdcleared from session after the SDK signals order completion - Confirmation page is a Server Component that fetches order by ID from commercetools
commercetools Client & Session
apiRoot, getSession, and the BFF boundary being correctly wired.This reference covers the commercetools SDK singleton, environment setup, JWT session management, and the BFF (Backend-for-Frontend) architecture that prevents credential leaks.
Table of Contents
- Pattern 1: SDK Client Singleton
- Pattern 2: Environment Variables
- Pattern 3: JWT Session Management
- Pattern 4: BFF Route Handler Shape
- Pattern 5: commercetools Helper Function Shape
- Pattern 6: Connection Health Check
- Checklist
Pattern 1: SDK Client Singleton
ClientBuilder singleton pattern, package installation, and the rule: one apiRoot module-level export in lib/ct/client.ts — never instantiate ClientBuilder inside a page, component, or Route Handler.Pattern 2: Environment Variables
.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
cartId or customerId in localStorage — accessible to XSS attacks. Or using server-side session storage — requires infrastructure.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;
}
| Field | Set when | Cleared when |
|---|---|---|
customerId | Login/register | Logout |
cartId | Cart created or login | Order placed |
country/currency/locale | Country selector | Never (persists) |
Pattern 4: BFF Route Handler Shape
lib/ct/* directly from a Client Component or SWR fetcher.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
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.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;
}
| File | Owns |
|---|---|
lib/ct/client.ts | apiRoot singleton |
lib/ct/auth.ts | signInCustomer, signUpCustomer, getCustomerById, updateCustomer |
lib/ct/cart.ts | All cart operations (create, addLineItem, removeLineItem, discounts, shipping) |
lib/ct/orders.ts | getOrderById, getCustomerOrders |
lib/ct/categories.ts | getCategoryBySlug, getCategoryTree |
lib/ct/search.ts | searchProducts, getProductBySku |
Pattern 6: Connection Health Check
// app/api/health/route.ts ← DELETE before deploying
import { NextResponse } from 'next/server';
import { apiRoot } from '@/lib/ct/client';
export async function GET() {
try {
const { body } = await apiRoot.get().execute();
return NextResponse.json({ ok: true, projectKey: body.key });
} catch (e) {
return NextResponse.json({ ok: false, error: String(e) }, { status: 500 });
}
}
curl http://localhost:8888/api/health
# → {"ok":true,"projectKey":"your-project-key"}
Checklist
- SDK singleton and env vars set up per sdk-setup.md
- All commercetools calls go through
apiRoot— no rawfetch()to commercetools REST endpoints -
SESSION_SECRETis at least 32 characters in production -
lib/session.tsexportsgetSession,createSessionToken,setSessionCookie,clearSessionCookie - Health check returns
{"ok":true}with your project key
Customer Authentication — Shared Foundation
customer-auth.md.Table of Contents
- Pattern 1: commercetools Login Endpoint
- Pattern 2: Route Handler Structure
- Pattern 3: useAccount SWR Hook
- Pattern 4: Logout — Session and SWR Cache Clearing
- Checklist
Pattern 1: commercetools Login Endpoint
apiRoot.customers().login() — this endpoint does not exist in commercetools SDK v2:// WRONG — SDK v2 does not have this endpoint
const { body } = await apiRoot.customers().login().post({ body: { email, password } }).execute();
apiRoot.login().post():// 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'scustomer-auth.mdshows the full handler with these additions.
Pattern 3: useAccount SWR Hook
customerId from localStorage or a cookie on the client — not reactive, not server-safe./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 };
}
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 useGET /api/auth/meand anAuthContextwrapper in addition to the hook — see B2Bcustomer-auth.mdfor the full pattern.
Pattern 4: Logout — Session and SWR Cache Clearing
// WRONG — stale cart/order data still in SWR cache
await fetch('/api/auth/logout', { method: 'POST' });
mutate(KEY_ACCOUNT, null, { revalidate: false });
// 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.tsusesapiRoot.login().post()— NOTapiRoot.customers().login() - Login Route Handler writes session cookie with at minimum
customerIdand customer name fields -
useAccounthook usesKEY_ACCOUNTas SWR key withrevalidateOnFocus: false - Logout Route Handler preserves
locale,currency,countryand clears user fields - Logout clears both
KEY_ACCOUNTandKEY_CARTfrom SWR cache
- B2C: see b2c/customer-auth.md for anonymous cart merge and protected layout
- B2B: see b2b/customer-auth.md for BU auto-selection, channel resolution, and
AuthContext
Data Loading
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
- Pattern 2: commercetools Type Boundary
- Pattern 3: BFF API Route Shape
- Pattern 4: Version Conflict
- Pattern 5: Server-Side Caching
- Checklist
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.
| Data | Pattern | Reason |
|---|---|---|
| Initial product list | Server Component | First paint, SEO, no spinner |
| Category tree | Server Component + TTL cache | Stable, needs SSR |
| Cart | SWR (useCartSWR) | Changes after add/remove actions |
| Account / orders | SWR | Changes after login |
| Search results | Server Component (via URL params) | SEO, shareable URLs |
Rules:
- All page components are
asyncby 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
sessionto commercetools functions rather than callinggetSession()inside each function
Pattern 2: commercetools Type Boundary
lib/ct/. Components import from @/lib/types — never from @commercetools/platform-sdk.lib/mappers/. Each file maps one commercetools resource to one app type:| File | Maps |
|---|---|
lib/mappers/product.ts | ProductProjection → Product |
lib/mappers/category.ts | commercetools Category → app Category |
lib/mappers/cart.ts | commercetools Cart → app Cart |
lib/mappers/order.ts | commercetools Order → app Order |
lib/mappers/line-item.ts | commercetools LineItem → app LineItem |
lib/mappers/customer.ts | commercetools Customer → app Account |
lib/mappers/money.ts | commercetools TypedMoney → app Money |
lib/mappers/facet.ts | commercetools facet results → FacetResult[] |
getLocalizedString(field, locale) resolves LocalizedString to a plain string — falls back to default locale then first available. Call it only inside lib/ct/ or lib/mappers/, never in components.Pattern 3: BFF API Route Shape
Route handlers have exactly three responsibilities — no more:
- Validate session
- Call
lib/ct/<namespace>.ts— never the commercetools SDK directly - Return JSON with the correct status
fetch('/api/*') directly in a component — put it in hooks/*Api.ts.Pattern 4: Version Conflict
version. When two requests arrive simultaneously one will be rejected with 409 ConcurrentModification. Re-fetch the entity's version before the action.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
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 }
);
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>.tsvia mappers -
getLocalizedStringcalled only inlib/ct/orlib/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 inhooks/*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
Image Config
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
- Pattern 2: Keep
unoptimized: true - Pattern 3: next/image Usage and LCP Priority
- Pattern 4: Suffix Pattern
- Pattern 5: CDN Hostname Replacement
- Pattern 6: Imgix and Cloudinary
- Pattern 7: Adding a New Context
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;
}
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.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
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
priority — they are below the fold and should lazy-load.Pattern 4: Suffix Pattern
// 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
// 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`;
}
// 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
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}
/>
image-config.ts means a single config change updates all instances.Recurring Orders
RecurringOrder resource does not carry its own line items, which surprises most implementers. Always expand originOrder or the subscription management UI will have nothing to display.RecurringOrder represents an active subscription. It references the originating order, a RecurrencePolicy (the schedule), and the customer or business unit that owns it. commercetools handles the actual re-ordering on schedule; the storefront manages the lifecycle (pause, resume, cancel) and displays the subscription status to the user.Table of Contents
- Pattern 1: Resources and SDK Accessors
- Pattern 2: Fetching Recurring Orders
- Pattern 3: State Transitions
- Pattern 4: Creating a Recurring Order
- Pattern 5: Recurrence Policies
- Pattern 6: API Routes
- Pattern 7: SWR and Cache
- Tips and Tricks
Pattern 1: Resources and SDK Accessors
| Resource | SDK Accessor | Notes |
|---|---|---|
RecurringOrder | apiRoot.recurringOrders() | Project-level — not under as-associate |
RecurrencePolicy | apiRoot.recurrencePolicies() | Project-level; defines schedule |
apiRoot. commercetools does not yet expose recurring orders under the as-associate endpoint; authorization is enforced in the app via scoped where clauses.Pattern 2: Fetching Recurring Orders
Scoping
where clause. Without scoping, an admin-credential client returns all recurring orders in the project.- B2C:
customer(id="${customerId}") - B2B:
businessUnit(key="${businessUnitKey}")
where clause.The line items problem
RecurringOrder does not carry its own lineItems array. Always expand originOrder:expand: ['originOrder']
Omitting the expand means the subscription UI has no items to display.
lineItems field on RecurringOrder is often empty even when the expand succeeds. Always read from originOrder.obj.lineItems as the authoritative source.Pattern 3: State Transitions
setRecurringOrderState update action with a read-then-write pattern:- Fetch the recurring order to get its current
version - POST with that
versionand thesetRecurringOrderStateaction
| Intent | recurringOrderState value |
|---|---|
| Pause | { type: 'paused' } |
| Resume | { type: 'active' } |
| Cancel | { type: 'canceled' } |
There is no optimistic locking retry in the standard implementation — a 409 version conflict surfaces to the user as an error.
skip, setSchedule in B2C).Pattern 4: Creating a Recurring Order
RecurringOrder per subscription line item — not one per order.createRecurringOrder fails, log the error but do not roll back the placed order. The customer completed their purchase; the subscription record can be re-created or investigated separately. Rolling back the order would be a worse outcome.recurrenceInfo attached to the cart's line items when they were added — it does not need to be re-specified on the draft body. See the context-specific files for the exact draft shape.Pattern 5: Recurrence Policies
Policies are defined in commercetools — never hardcode schedule options in the app. Fetch them project-level:
apiRoot.recurrencePolicies().get({ queryArgs: { limit: 20 } })
Two SWR hooks serve different consumers:
useRecurrencePolicies()— returnsMap<policyId, humanLabel>for inline display in cart items and mini cartuseRecurrencePoliciesList()— returns the fullRecurrencePolicy[]for the PDP selector and subscription pages
Both hooks must share the same SWR cache key so only one HTTP request is made.
formatInterval(schedule) helper converts { intervalUnit, value } to a human label (e.g. "Every 2 months"). It must handle both singular ('month') and plural ('months') forms of intervalUnit — commercetools data uses both.Pattern 6: API Routes
Standard route structure:
| Method | Path | Action | Notes |
|---|---|---|---|
GET | /api/[prefix] | List recurring orders | Scoped by owner; auth fields differ by context |
GET | /api/[prefix]/[id] | Fetch single order | Always expand: ['originOrder'] |
PUT or POST | /api/[prefix]/[id] | State transitions | See context-specific for route style |
GET | /api/recurrence-policies | List all policies | No owner scoping needed |
POST /api/[prefix] for creation — recurring orders are created from within the checkout route.Pattern 7: SWR and Cache
Use SWR for recurring order data — it changes only on explicit user action (pause, resume, cancel).
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.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.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.manage_recurring_orders scope separately from the admin/tools client. Do not assume admin scopes cover storefront calls.Recurring Prices
RecurrencePolicy. The policy reference is the link that connects a price to a schedule.Table of Contents
- Pattern 1: The Recurring Price Signal
- Pattern 2: BFF Mapper
- Pattern 3: PDP Separation and Gate
- Pattern 4: Policy Selector Component
- Pattern 5: Add to Cart with Recurrence
- Tips and Tricks
Pattern 1: The Recurring Price Signal
recurrencePolicy reference. A price without this reference is a one-time price. The reference holds only the policy id — the full policy name and schedule must be resolved separately from the recurrence policies list.variant.recurrencePrices[]. This is a first-class field in the commercetools platform SDK — available in both B2B and B2C, no cast required, no extra expand needed. The array comes back automatically in the standard product projection alongside variant.prices[].recurrencePrices has the same money shape as a regular price entry plus a recurrencePolicy: { id, typeId: 'recurrence-policy' } reference.Price type in app code should expose recurrencePolicy?: { id: string } so the rest of the app can check presence without knowing the commercetools field structure.Pattern 2: BFF Mapper
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.Price interface must include:recurrencePolicy?: { id: string } // present iff this is a recurring price
v.recurrencePrices in the variant mapper alongside v.prices. The recurrencePolicy reference is already typed in the SDK — no cast required. Preserve it as-is on each mapped price entry.Pattern 3: PDP Separation and Gate
recurrencePolicy:regularPrice— the first price withoutrecurrencePolicyrecurringPrices— all prices withrecurrencePolicy
Gate the subscription UI on two conditions:
- A context-specific eligibility check — see B2B/B2C files (e.g. login state, BU membership)
recurringPrices.length > 0for the selected variant
If either condition is false, render the standard Add to Cart button only.
PDPAddToCart → PDPActions → SubscribeAndSave / SubscribeAndSaveBoxregularPrice and recurringPrices should happen as high in the tree as possible (in PDPAddToCart) so that leaf components receive typed, already-partitioned data rather than raw arrays.Pattern 4: Policy Selector Component
The subscription selector lets users choose between a one-time purchase and one of the available recurring schedules.
oneTimePrice— the regular price for the one-time optionrecurringPrices— array of recurring prices, each with arecurrencePolicy.idpolicies— the fullRecurrencePolicy[]list for resolving names and schedulesvalue—'one-time'or arecurrencePolicyIdonChange— callback receiving'one-time'or the selected policy ID
price.recurrencePolicy.id → policies.find(p => p.id === id)?.name. Render the raw ID as a fallback if the policy is not in the list.availablePolicies to those that have a matching entry in recurringPrices. Showing all policies would let users select a schedule with no price defined for the current variant.Pattern 5: Add to Cart with Recurrence
When the user confirms their selection:
- If selected value is
'one-time': add the line item with norecurrenceInfo. Do not passrecurrencePolicyIdat all — do not pass it asundefinedornull. - If selected value is a policy ID: attach
recurrenceInfoto theaddLineItemaction.
recurrenceInfo shape:recurrenceInfo: {
recurrencePolicy: { typeId: 'recurrence-policy', id: recurrencePolicyId },
priceSelectionMode: 'Fixed',
}
priceSelectionMode: 'Fixed' tells commercetools to use the specific recurring price for this policy, not to run dynamic pricing.CartAddLineItemAction SDK type. Cast the action at the call site in lib/ct/cart.ts — not in route handlers or components.Tips and Tricks
recurringPrices may contain entries for multiple currencies and countries. Filter to the current locale's currency + country before passing them to the selector.formatInterval(policy.schedule) helper to convert { intervalUnit: 'Months', value: 2 } into "Every 2 months". Handle both singular and plural variants of intervalUnit (e.g. 'month' and 'months') — commercetools data uses both forms inconsistently.Map<id, label> for inline display in cart items and mini cart, and one returning the full RecurrencePolicy[] for the selector. Both must share the same SWR cache key so only one HTTP request is made.Performance
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
- Pattern 2: unstable_cache for Stable commercetools Data
- Pattern 3: SWR Fallback Injection from the Server
- Pattern 4: Image Optimization → see image-config.md
- Pattern 5: N+1 Anti-Patterns to Avoid
- Checklist
Pattern 1: Parallel Fetching in Server Components
// WRONG — sequential waterfall
const session = await getSession(); // 50 ms
const locale = await getLocale(); // 50 ms
const categories = await getCategoryTree(locale); // 200 ms
// Total: 300 ms
Promise.all for all independent fetches:// CORRECT — parallel, total ≈ longest individual fetch
const [session, locale, messages, validCountryConfig] = await Promise.all([
getSession(),
getLocale(),
getMessages(),
getValidCountryConfig(), // cached with unstable_cache
]);
// Total: ~50 ms (session/locale win, messages/validation cached)
// 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 inPromise.all. The most common violation is awaitinggetSession()before callinggetLocale()when neither needs the other.
Pattern 2: unstable_cache for Stable commercetools Data
// WRONG — fresh commercetools call on every render
export default async function RootLayout() {
const { countries, currencies } = await apiRoot.get().execute(); // called every request
}
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 }
);
unstable_cache:| Data | Cache TTL | Reason |
|---|---|---|
| commercetools project config (countries, currencies) | 300 s | Changes only on project reconfiguration |
| Category tree | 60 s | Rarely edited; high reuse across pages |
| Shipping methods | 60 s | Rarely edited; no per-user variation |
| Product prices | Do not cache | Can change on promotion rules; per-currency |
| Cart data | Do not cache | Per-session, changes frequently |
Never cache per-user or per-session data withunstable_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
// WRONG — SWR fetches from scratch on mount
export function CartProvider({ children }) {
const { data: cart } = useCartSWR(); // triggers /api/cart on mount
// ...
}
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 fallbackat the root layout level populates the SWR cache before any Client Component mounts.useCartSWR()anduseAccount()see the pre-fetched data immediately — no loading state, no extra round-trip.
initialUser from the session instead of fetching from commercetools: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
- Never use
<img>— alwaysnext/imageso below-fold images lazy-load automatically. priorityon one image per page — the PDP main carousel image or hero banner only. Product card images on listing pages must not havepriority.unoptimized: trueis intentional — do not remove it; the commercetools CDN rejects Next.js optimisation query params.
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
// WRONG — O(depth) commercetools calls
let current = category;
while (current.parent) {
current = await getCategoryById(current.parent.id, locale); // commercetools call per level
breadcrumb.unshift(current);
}
// CORRECT — 1 commercetools call for the whole tree, O(n) in-memory walk
const [category, categoryTree] = await Promise.all([
getCategoryBySlug(slug, locale),
getCategoryTree(locale), // fetches all categories (limit: 500)
]);
const flat = categoryTree.flat();
let current = category;
while (current.parent) {
const parent = flat.find((c) => c.id === current.parent?.id);
if (parent) { breadcrumb.unshift({ name: parent.name, slug: parent.slug }); current = parent; }
else break;
}
Product card prices — N+1 price fetches
// WRONG — 1 extra commercetools call per product
for (const product of products) {
product.price = await getVariantPrice(product.id, currency, country);
}
priceCurrency + priceCountry in the search query body:// CORRECT — commercetools resolves prices in the same search response
const body: ProductSearchRequest = {
productProjectionParameters: {
priceCurrency: currency,
priceCountry: country,
},
// ...
};
// Variants arrive with .price already set — no extra fetch
Account page — serial user + orders fetch
// WRONG — sequential
const customer = await getCustomerById(session.customerId);
const orders = await getCustomerOrders(session.customerId);
customerId from session:// CORRECT
const [customer, ordersResult, ...] = await Promise.all([
getCustomerById(session.customerId),
getCustomerOrders(session.customerId, 5), // last 5 for the dashboard
... // 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_cacheis never used for per-user or per-session data -
SWRConfig fallbackat root layout pre-populatesKEY_CARTandKEY_ACCOUNTfrom server -
initialUseris built from session fields — no extragetCustomerByIdcall in layout - PDP main image uses
priorityprop; product card images do not — see image-config.md Pattern 3 - Category breadcrumb walks the in-memory tree — not individual
getCategoryByIdcalls - Product search passes
priceCurrency/priceCountry— no post-query price fetches
Product Detail Page (PDP)
Shared patterns for PDP across B2C and B2B storefronts. Individual storefront references extend this.
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
getProductBy* helper must match the chosen identifier.PDP Page (Server Component)
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();
notFound() immediately when the product is null — don't render a fallback.Variant URL Strategy
[sku] URL segment — the Server Component re-runs automatically. No client-side fetch needed.- Product lookup always uses
skuorproductId, neverslug slugin the URL is the category slug — for breadcrumb only
Components
- Image gallery — images from the active variant
- Variant selector — lists all SKUs; clicking one updates the URL (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)
-
generateMetadatareturns title + description for SEO -
notFound()called when product doesn't resolve -
Promise.allfor 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
Search facets
Full-stack faceted search for a commercetools storefront — deriving facets from product-type attribute definitions, mapping commercetools attribute types to the correct ProductSearchFacetExpression shape, returning facet results from the search function, building a client-side filter panel that reads/writes URL params, and translating those selections back into a commercetools postFilter. Use this skill whenever you are adding facets to a commercetools Product Search request, building filter UI data, implementing getSearchableAttributes, wiring facet selections to the URL, or deciding whether an attribute should produce a distinct vs ranges facet expression.
What this skill covers
postFilter on the next search call.Source of truth: product type attribute definitions
isSearchable
flag on each AttributeDefinition. Read from this — facets then stay in sync with the catalog
schema without any manual configuration.Fetch all product types and flatten their attribute arrays. Deduplicate by attribute name (first occurrence wins) because multiple product types often share attribute names, and the field path in the search index is the same regardless of which type defined it.
AttributeDefinition[] directly from the SDK — it already contains everything needed
to derive both the facet expression shape and the postFilter field paths, so no parallel
intermediate type is needed.unstable_cache from next/cache.
Product type schemas change rarely; the savings on every search call are meaningful.Mapping attribute types to facet expressions
AttributeType.name values map almost directly to SearchFieldType. The only mechanical
transformation: for set types, prefix the inner type name with set_ (e.g. set_enum).reference and nested — they are not meaningfully facetable.number, money, date, datetime, time (and set_* variants){ from: 0 } is a valid starting point — it surfaces counts and can be
replaced with business-defined buckets later.boolean, enum, lenum, text, ltext (and set_* variants)The lenum and enum subfield constraint
lenum and enum attributes store their value as { key, label }. commercetools only allows querying by subfields —
point the facet field at variants.attributes.<name>.key and use enum for both as the effective
fieldType (not lenum), because the key is a plain enum key. Same rule for set_lenum and set_enum → field
at .key, fieldType: set_enum.This is the only type that requires a path suffix.
boolean, text, ltext) carries its own type name as fieldType — the enum
special-case does not generalise. Hardcoding 'enum' for all distinct types will cause commercetools to reject or
misinterpret fields whose actual type differs.Always-present facets
Always include these two regardless of attribute configuration, first in the array:
- Stock —
variants.availability.isOnStock,fieldType: boolean,distinct - Price —
variants.prices.centAmount,fieldType: number,ranges
Language on every facet expression
language on every distinct facet — both in the facet
expressions sent to commercetools and in every exact expression in the postFilter. commercetools needs it to
resolve localized bucket labels for ltext and localized enum labels. Passing it uniformly
to all fields (including non-localized ones like enum and boolean) is safe — commercetools ignores
it where it doesn't apply — and avoids per-type branching.ranges facets and range filter expressions do not carry a language field since numeric and
temporal values are locale-independent.Returning facet results from the search function
searchProducts should return both body.facets (the response facet - data ask commercetools-developer-tips about ProductSearchFacetResult) and searchRequest
(the full request object that was sent). The client needs the request to match each response
facet by name and determine whether it is a distinct or ranges type — that information
lives in the request expressions, not in the response.name field is the stable link between request and response. Use the attribute name
directly for attribute facets, and short descriptive names (isOnStock, price) for the
hardcoded fields.Building the postFilter from URL selections
field path, fieldType, and kind (distinct | ranges). This is built at the same
time as the expressions so no second pass over attributes is needed.Record<string, string> from f_* URL
params), translate them into a commercetools _SearchQuery for postFilter:- Distinct, single value —
exactexpression withfield,fieldType,language,value - Distinct, multiple values — wrap multiple
exactexpressions inor - Boolean — parse
"true"/"false"string to a boolean before putting it inexact - Ranges — parse the bucket key (format:
<from>-<to>,*for open-ended) into numericgte/ltebounds; useSearchNumberRangeExpressionfornumber/money,SearchLongRangeExpressionotherwise - Multiple active facets — combine all per-facet clauses with
and
postFilter (not query) so that facet counts reflect the full catalog while results are
filtered — this is the standard UX expectation for a filter sidebar.URL convention and client-side state
f_<name> URL param. This keeps selections shareable,
bookmarkable, and readable by the server page for the next render.- Distinct multi-select:
f_color=red,blue - Range:
f_price=1000-5000(matching the commercetools bucket key format) - Boolean:
f_isOnStock=true
offset to 0 — otherwise users land mid-paginated results.Client-side filter panel
'use client' component that receives facets and searchRequest as
serializable props from the server page. It:- Reads
f_*URL params withuseSearchParamsto reconstruct current selections - Builds a name→expression lookup from
searchRequest.facetsto know each facet's kind - Iterates the response facets, skipping any with no non-zero buckets (nothing to show)
- Dispatches to a
DistinctFacetorRangeFacetcomponent based on the request expression kind - Shows an
ActiveFiltersstrip at the top when any selections are active - Updates the URL with
router.pushon every selection change, preserving unrelated params
<Suspense> boundary on the server page — it reads useSearchParams
which requires Suspense in Next.js App Router.Distinct vs Range rendering
Checklist
- Never use any, unknown for type checking specially for facets. Always use types provided by @commercetools/platform-sdk
- Skip
referenceandnestedattribute types - Mapped
setattribute type toset_*inner type name
Shopping Lists
ShoppingList resource. The critical difference is the API chain used to access them: project-level vs. as-associate. Using the wrong chain is a security and scoping bug, not just a style issue.addLineItem, removeLineItem — so most patterns are identical once you pick the right API chain.Table of Contents
- Pattern 1: The Two API Chains
- Pattern 2: commercetools Helper Functions
- Pattern 3: Route Handlers
- Pattern 4: SWR Hook
- Pattern 5: Mapper
- Tips and Tricks
Pattern 1: The Two API Chains
| Context | commercetools chain | Ownership enforced by |
|---|---|---|
| B2C personal wishlist | apiRoot.shoppingLists() (project-level) | App code — verify list.customer.id === customerId after a by-ID fetch |
| B2B purchase list | apiRoot.asAssociate()...shoppingLists() (as-associate) | commercetools itself — non-members receive a 403 |
Never mix the chains. A wishlist fetched through the as-associate chain would inherit BU scoping it should not have. A purchase list fetched through the project-level chain would bypass associate-permission enforcement entirely.
Pattern 2: commercetools Helper Functions
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
nameand any context-specific fields (see B2B/B2C extensions) - Rename — single
changeNameupdate action - Add item —
addLineItemwithproductId,variantId, andquantity - Remove item —
removeLineItembylineItemId - Delete — by ID and version
version. Fetch it fresh before sending the update if the caller does not already hold it — stale versions cause 409 conflicts.ShoppingList responses must be mapped to an app type (Wishlist or PurchaseList) before leaving 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:
| Context | Required session fields |
|---|---|
| B2C wishlist | customerId |
| B2B purchase list | customerId + businessUnitKey |
Route structure is symmetric in both cases:
| Method | Path | Intent |
|---|---|---|
GET | /api/[resource] | List all for current owner |
POST | /api/[resource] | Create a new list |
GET | /api/[resource]/[id] | Fetch a single list by ID |
PUT | /api/[resource]/[id] | Rename the list |
DELETE | /api/[resource]/[id] | Delete the list |
POST | /api/[resource]/[id]/items | Add an item |
DELETE | /api/[resource]/[id]/items | Remove an item |
Pattern 4: SWR Hook
revalidateOnFocus: false.The SWR cache key must encode the ownership scope:
| Context | Cache key tuple |
|---|---|
| B2C wishlist | [KEY, customerId] |
| B2B purchase list | [KEY, businessUnitKey] |
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
ShoppingList to the app type. At minimum it should resolve:id,versionname— resolved fromLocalizedStringviagetLocalizedString(list.name, locale)lineItems— mapped to an array of{ lineItemId, productId, variantId, quantity, name, image, price }whereimageandpricecome from the expanded variant
expand: ['lineItems[*].variant'] on list fetches so the mapper has the variant data it needs.Tips and Tricks
list.customer?.id matches the session customerId in app code. Return a generic 404 — not a 403 — to avoid confirming that the ID exists for a different customer.expand on list fetches: Without expand: ['lineItems[*].variant'] the line items contain only IDs. Always include this expansion so image, price, and display name are available without a second round-trip.LocalizedString even for single-locale projects. This avoids a data migration later and is what commercetools expects on the create draft.Error Handling
Critical Gotcha: Never Wrap Navigation APIs in try-catch
redirect(), notFound(), forbidden(), and unauthorized() throw special internal errors. A catch block will swallow them and silently break navigation.'use server'
import { redirect, notFound } from 'next/navigation'
// Bad: catch block eats the redirect — navigation never happens
async function submitOrder(formData: FormData) {
try {
const order = await placeOrder(formData)
redirect(`/order-confirmation/${order.id}`) // throws internally
} catch (error) {
return { error: 'Failed' } // redirect is silently caught here
}
}
// Good: call redirect outside the try block
async function submitOrder(formData: FormData) {
let order
try {
order = await placeOrder(formData)
} catch (error) {
return { error: 'Failed to place order' }
}
redirect(`/order-confirmation/${order.id}`)
}
// Good alternative: re-throw with unstable_rethrow
import { unstable_rethrow } from 'next/navigation'
async function submitOrder(formData: FormData) {
try {
const order = await placeOrder(formData)
redirect(`/order-confirmation/${order.id}`)
} catch (error) {
unstable_rethrow(error) // re-throws redirect/notFound/forbidden/unauthorized
return { error: 'Failed to place order' }
}
}
Applies to all five navigation functions:
redirect()— 307 temporary redirectpermanentRedirect()— 308 permanent redirectnotFound()— rendersnot-found.tsxforbidden()— rendersforbidden.tsxunauthorized()— rendersunauthorized.tsx
error.tsx — Route Segment Error Boundary
Catches errors thrown in a route segment and all its children. Must be a Client Component.
// app/[locale]/shop/error.tsx
'use client'
export default function Error({
error,
reset,
}: {
error: Error & { digest?: string }
reset: () => void
}) {
return (
<div>
<h2>Something went wrong</h2>
<button onClick={() => reset()}>Try again</button>
</div>
)
}
reset() re-renders the segment without a full page reload.global-error.tsx — Root Layout Error Boundary
<html> and <body> tags because the layout is unavailable.// app/global-error.tsx
'use client'
export default function GlobalError({
error,
reset,
}: {
error: Error & { digest?: string }
reset: () => void
}) {
return (
<html>
<body>
<h2>Something went wrong</h2>
<button onClick={() => reset()}>Try again</button>
</body>
</html>
)
}
not-found.tsx — 404 Pages
Triggering Not Found
notFound() when a resource doesn't exist:// app/[locale]/products/[slug]/page.tsx
import { notFound } from 'next/navigation'
export default async function ProductPage({
params,
}: {
params: Promise<{ slug: string; locale: string }>
}) {
const { slug, locale } = await params
const product = await getProduct(slug, locale)
if (!product) {
notFound() // renders nearest not-found.tsx
}
return <ProductDetail product={product} />
}
Scoped not-found.tsx
// app/[locale]/products/not-found.tsx
export default function ProductNotFound() {
return (
<div>
<h2>Product not found</h2>
<p>The product you're looking for doesn't exist or has been removed.</p>
</div>
)
}
not-found.tsx next to the route segment it covers. Errors bubble up to the nearest ancestor that has one.Auth Error Pages
Trigger and render auth-specific error pages:
// Server Component or Server Action
import { forbidden, unauthorized } from 'next/navigation'
async function Page() {
const session = await getSession()
if (!session) {
unauthorized() // renders unauthorized.tsx (401)
}
if (!session.hasAdminAccess) {
forbidden() // renders forbidden.tsx (403)
}
return <AdminDashboard />
}
// app/[locale]/forbidden.tsx
export default function Forbidden() {
return <div>You don't have access to this resource.</div>
}
// app/[locale]/unauthorized.tsx
export default function Unauthorized() {
return <div>Please log in to continue.</div>
}
Error Hierarchy
Errors bubble up to the nearest boundary:
app/
├── global-error.tsx # Catches errors in root layout.tsx
├── [locale]/
│ ├── error.tsx # Catches errors across all locale routes
│ ├── not-found.tsx # 404 for all locale routes
│ ├── forbidden.tsx # 403 for all locale routes
│ ├── unauthorized.tsx # 401 for all locale routes
│ └── products/
│ ├── error.tsx # Catches errors in /products/* only
│ ├── not-found.tsx # 404 for /products/* only
│ └── [slug]/
│ └── page.tsx
error.tsx does not catch errors thrown in the same segment's layout.tsx — those bubble up to the parent's error boundary.Redirects
import { redirect, permanentRedirect } from 'next/navigation'
// 307 Temporary — use for most cases (login required, form success)
redirect('/login')
// 308 Permanent — use for URL migrations (browsers and search engines cache this)
permanentRedirect('/new-product-url')
router.push() there.Image Optimization
Project Rule: unoptimized: true Is Intentional
site/next.config.ts sets images.unoptimized: true. Do not remove it.403/400 when Next.js appends ?url=...&w=...&q=... optimization params. Sizing is handled explicitly in site/lib/ct/image-config.ts transform functions.// site/next.config.ts — do not change
const nextConfig: NextConfig = {
images: { unoptimized: true },
};
Product Images: Always Go Through image-config.ts
image-config.ts:import { transformListingImageUrl } from '@/lib/ct/image-config'
import Image from 'next/image'
// Listing / search result card
<Image
src={transformListingImageUrl(item.imageUrl)}
alt={item.name}
width={400}
height={500}
/>
// PDP main carousel image
<Image
src={transformDetailImageUrl(product.imageUrl)}
alt={product.name}
fill
sizes="(max-width: 768px) 100vw, 50vw"
priority
/>
// PDP thumbnail strip
<Image
src={transformThumbnailImageUrl(product.imageUrl)}
alt={product.name}
width={80}
height={100}
/>
image-config.ts updates all instances at once.Always Use next/image — Never <img>
unoptimized: true, next/image still prevents layout shift, lazy-loads below-fold images, and enforces explicit dimensions.// Bad
<img src={url} alt="Product" />
// Good
import Image from 'next/image'
<Image src={url} alt="Product" width={400} height={500} />
Non-Product Images (CMS banners, avatars, icons)
remotePatterns in next.config.ts:// site/next.config.ts
images: {
unoptimized: true,
remotePatterns: [
{
protocol: 'https',
hostname: 'assets.example.com',
pathname: '/media/**',
},
],
},
fill + sizes
fill with sizes or Next.js downloads the largest variant:// Bad: missing sizes — downloads the biggest image
<Image src={url} alt="Banner" fill />
// Good
<Image src={url} alt="Banner" fill sizes="100vw" />
// Good: responsive grid
<Image src={url} alt="Card" fill sizes="(max-width: 768px) 100vw, 33vw" />
position: relative and an explicit height.Priority for LCP Images
priority to the first visible image (hero, PDP carousel):// Hero banner — renders immediately, no lazy-load
<Image src={url} alt="Hero" fill sizes="100vw" priority />
// Product cards below the fold — omit priority (lazy-loaded by default)
<Image src={url} alt="Card" width={400} height={500} />
Blur Placeholder (non-product images)
// Local static image — blur hash inferred automatically
import heroImage from './hero.png'
<Image src={heroImage} alt="Hero" placeholder="blur" />
// Remote image — provide blurDataURL or use a background color
<Image
src="https://assets.example.com/banner.jpg"
alt="Banner"
width={1200}
height={400}
placeholder="blur"
blurDataURL="data:image/jpeg;base64,/9j/4AAQSkZJRg..."
/>
Common Mistakes
// Bad: native img — no lazy-load, no dimension enforcement
<img src={url} alt="Product" />
// Bad: fill without sizes — downloads largest image
<Image src={url} alt="Hero" fill />
// Bad: inline URL transform — bypasses image-config.ts
<Image src={`${url}?w=400`} alt="Product" width={400} height={500} />
// Bad: wrong dimensions (aspect ratio only, not display size)
<Image src={url} alt="Hero" width={16} height={9} />
// Good: actual display size or fill + sizes
<Image src={url} alt="Hero" fill sizes="100vw" style={{ objectFit: 'cover' }} />
Metadata
Rule: Metadata Lives in Server Components Only
metadata and generateMetadata cannot be used in Client Components. If a page has 'use client', either remove it (move client logic to child components and files) or extract metadata to a parent layout.Static Metadata
// app/[locale]/my-page/page.tsx
import type { Metadata } from 'next'
export const metadata: Metadata = {
title: 'My Page', // root layout template appends '| Home'
description: 'Page description for SEO',
}
title values are automatically formatted as My Page | Home.Dynamic Metadata
generateMetadata when the title or description depends on fetched data (PDP, category pages, blog posts):// app/[locale]/products/[slug]/page.tsx
import type { Metadata } from 'next'
interface Props {
params: Promise<{ slug: string; locale: string }>
}
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { slug, locale } = await params
const product = await getProduct(slug, locale).catch(() => null)
if (!product) return { title: 'Not Found' }
return {
title: product.name,
description: product.description,
}
}
params is a Promise in Next.js 15+ — always await it.Avoid Duplicate Fetches
generateMetadata and the page component often need the same data. Wrap the fetch in React cache() so it runs only once per request:// lib/ct/products.ts
import { cache } from 'react'
export const getProduct = cache(async (slug: string, locale: string) => {
return await apiRoot.products().withSlug(slug).get({ /* ... */ }).execute()
})
// app/[locale]/products/[slug]/page.tsx
import { getProduct } from '@/lib/ct/products'
import { notFound } from 'next/navigation'
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { slug, locale } = await params
const product = await getProduct(slug, locale)
if (!product) return { title: 'Not Found' }
return { title: product.name, description: product.description }
}
export default async function ProductPage({ params }: Props) {
const { slug, locale } = await params
const product = await getProduct(slug, locale) // same call — deduplicated by cache()
if (!product) notFound()
return <ProductDetail product={product} />
}
OG Images
opengraph-image.png (static) or opengraph-image.tsx (dynamic) in the route segment:// app/[locale]/products/[slug]/opengraph-image.tsx
import { ImageResponse } from 'next/og'
export const alt = 'Product'
export const size = { width: 1200, height: 630 }
export const contentType = 'image/png'
type Props = { params: Promise<{ slug: string; locale: string }> }
export default async function Image({ params }: Props) {
const { slug, locale } = await params
const product = await getProduct(slug, locale)
return new ImageResponse(
(
<div
style={{
background: '#fff',
width: '100%',
height: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: 64,
fontWeight: 'bold',
}}
>
{product?.name ?? 'Product'}
</div>
),
{ ...size }
)
}
- Use
next/og, not@vercel/og - No
searchParamsaccess — use routeparamsonly - Do not set
export const runtime = 'edge'— default Node.js runtime works fine ImageResponseuses Flexbox; CSS Grid is not supported; all styles must be inline objects
opengraph-image.png at the root covers both Open Graph and Twitter (Twitter falls back to OG).Metadata File Conventions
app/ (or any route segment) are picked up automatically — no code needed:| File | Purpose |
|---|---|
favicon.ico | Browser tab icon |
opengraph-image.png / .tsx | OG + Twitter card image |
sitemap.ts | Sitemap (use generateSitemaps for large catalogs) |
robots.ts | Crawl directives |
Server Component Boundaries
onChange, onClick, onSubmit) is illegal on a Server Component's JSX. This applies to all interactive elements including — but not limited to — <select>, <input>, and <button>.onChange handler directly on a <select> inside a Server Component.'use client' component and pass only plain data props (strings, arrays, objects) to it from the Server Component.// WRONG — Server Component
export default async function LocaleSwitcher() {
const locales = await getLocales();
return <select onChange={(e) => switchLocale(e.target.value)}>...</select>;
}
// CORRECT — split into two files
// LocaleSwitcherClient.tsx
'use client';
export default function LocaleSwitcherClient({ locales }: { locales: string[] }) {
return <select onChange={(e) => switchLocale(e.target.value)}>...</select>;
}
// LocaleSwitcher.tsx (Server Component)
import LocaleSwitcherClient from './LocaleSwitcherClient';
export default async function LocaleSwitcher() {
const locales = await getLocales();
return <LocaleSwitcherClient locales={locales} />;
}
The boundary rule: a Server Component can render a Client Component, but cannot pass functions as props across that boundary. Pass data down; let the Client Component own all event handling.