Salesforce Commerce Cloud + Next.js 14: The Service & Mapper Pattern for Headless OCAPI Integration
How a two-layer integration pattern — a typed service class and a schema mapper — decouples your Next.js storefront from Salesforce Commerce Cloud's verbose OCAPI responses.
When our team set out to build a headless storefront on Salesforce Commerce Cloud (SFCC), we ran into a familiar problem: the Open Commerce API (OCAPI) returns detailed, deeply nested payloads shaped around the Demandware data model — perfect for the platform, but verbose and awkward to consume directly in React components.
Product objects arrive with variationAttributes, variants, imageGroups keyed by viewType, and price books buried inside productPromotions. Basket responses nest shipping methods inside shipments inside the basket object. Passing that raw shape into a dozen components is a maintenance liability. So we didn't.
Instead, we built a two-layer integration pattern — a service class (SFCCClient) and a schema mapper (ShopMapper) — sitting between Next.js and Salesforce Commerce Cloud. Here's what we learned.
The Core Problem with Raw OCAPI Responses
If you've worked with SFCC OCAPI, you know responses like this:
{
"id": "asics-gel-kayano-30-blue-10",
"master": { "masterId": "asics-gel-kayano-30", "orderable": true },
"variationAttributes": [
{
"id": "color",
"name": "Color",
"values": [
{ "value": "BLUE", "name": "Midnight Blue", "orderable": true,
"image": { "link": "/on/demandware.static/Sites-asics-Site/-/default/...jpg" } }
]
},
{
"id": "size",
"name": "Size",
"values": [{ "value": "10", "name": "10", "orderable": true }]
}
],
"imageGroups": [
{ "viewType": "large", "images": [{ "link": "/on/demandware.static/..." }] },
{ "viewType": "small", "images": [{ "link": "/on/demandware.static/..." }] }
],
"price": 160.00,
"pricePerTransactionUnit": 160.00,
"inventory": { "id": "inventory-asics", "ats": 12, "orderable": true, "stockLevel": 12 }
}
Now imagine 15 components each reaching into variationAttributes.find(a => a.id === 'color').values to render a colour swatch. One OCAPI schema update, and you're chasing it through the entire codebase.
The fix: transform once, consume everywhere.
The Architecture: Three Layers, One Clean Contract
flowchart TD
subgraph Browser["Browser / Client"]
UI["React Client Components\nCart · Variant Selector · Wishlist"]
end
subgraph NextJS["Next.js 14 — App Router"]
direction TB
SC["Server Components\nPDP · PLP · Search · Breadcrumb"]
AR["API Route Handlers\n/api/basket · /api/auth · /api/search"]
subgraph IntegrationLayer["Integration Layer"]
direction LR
SFCC["SFCCClient\nService Class"]
SM["ShopMapper\nPure Functions"]
SFCC -->|"raw OCAPI JSON"| SM
end
SC -->|"await sfcc.getProduct()"| IntegrationLayer
AR -->|"await sfcc.addToBasket()"| IntegrationLayer
end
subgraph SFCC_Cloud["Salesforce Commerce Cloud"]
direction TB
OCAPI["OCAPI — Shop API\n/s/{site}/dw/shop/v23_2"]
subgraph Endpoints["Core Endpoints"]
PROD["/products/{id}"]
SEARCH["/product_search"]
BASKET["/baskets/{id}"]
AUTH["/customers/auth"]
CAT["/categories/{id}"]
end
OCAPI --> Endpoints
end
subgraph Schema["Normalized Schema"]
NS["productId · name · price[]\nstock · images[] · variants[]\nbreadcrumbs · promotions[]"]
end
UI -->|"fetch /api/basket"| AR
SM -->|"normalized schema"| Schema
Schema -->|"typed props"| SC
Schema -->|"JSON response"| AR
IntegrationLayer <-->|"HTTPS + JWT Bearer"| OCAPI
The frontend never sees raw OCAPI data. Components receive a predictable, frontend-friendly schema regardless of what Salesforce changes in their platform.
Layer 1: SFCCClient — The Service Class
The SFCCClient class is a single-responsibility service that owns every OCAPI call. It reads config from environment variables, manages JWT and SLAS token flows, and wraps every fetch in a consistent error envelope.
export default class SFCCClient {
constructor() {
this.config = {
clientId: process.env.SFCC_CLIENT_ID,
clientSecret: process.env.SFCC_CLIENT_SECRET,
instanceUrl: process.env.SFCC_INSTANCE_URL,
siteId: process.env.SFCC_SITE_ID,
apiVersion: process.env.SFCC_API_VERSION || 'v23_2',
};
}
get baseUrl() {
return `${this.config.instanceUrl}/s/${this.config.siteId}/dw/shop/${this.config.apiVersion}`;
}
getProduct = async ({ productId, expand = 'images,prices,availability,variations' }) => {
try {
const response = await fetch(
`${this.baseUrl}/products/${productId}?expand=${expand}&client_id=${this.config.clientId}`,
{
headers: { 'Content-Type': 'application/json' },
next: {
revalidate: configuration.PDPProductCacheTime,
tags: [fetchTags.fetchProductDetails],
},
}
).then((res) => res.json());
const normalized = await makeProductResponse(response);
return { status: response.fault ? 400 : 200, response: normalized };
} catch (ex) {
return { status: 400, response: ex.message || errorMsg.errorInFetch };
}
};
}
Three things to notice:
-
Consistent envelope. Every method returns
{ status, response }. SFCC OCAPI surfaces errors as afaultobject rather than an HTTP error code in some cases — the envelope pattern absorbs that inconsistency so calling code never has to. -
expandparameters are explicit. OCAPI uses an expand query parameter to control payload size. Declaring these at the service layer keeps components from requesting more data than they need. -
Server-only. This class is instantiated in Server Components and API Route handlers only — the
clientIdandclientSecretnever reach the browser.
Layer 2: ShopMapper — The Transformation Layer
The mapper absorbs SFCC-specific payload complexity. imageGroups keyed by viewType, flat price fields that don't distinguish sale from list, and variation attributes as arrays — all of that gets resolved here so components never deal with it.
export const makeProductResponse = async (item) => {
if (!item.id) return item;
// Resolve images from viewType-keyed imageGroups
const largeImages = item.imageGroups?.find(g => g.viewType === 'large')?.images ?? [];
const images = largeImages.map(img => ({
url: img.link?.startsWith('http') ? img.link : configuration.baseImageURL + img.link,
altText: img.alt ?? item.name,
title: img.title ?? '',
}));
// Flatten variationAttributes into keyed maps for easy lookup
const colorAttr = item.variationAttributes?.find(a => a.id === 'color');
const sizeAttr = item.variationAttributes?.find(a => a.id === 'size');
const colorVariants = (colorAttr?.values ?? []).map(v => ({
name: 'color',
value: v.value,
label: v.name,
orderable: v.orderable,
swatchUrl: v.image?.link
? configuration.baseImageURL + v.image.link
: null,
}));
const sizeVariants = (sizeAttr?.values ?? []).map(v => ({
name: 'size',
value: v.value,
label: v.name,
orderable: v.orderable,
}));
// Normalize price — SFCC may return price or priceMax for ranges
const price = normalizePrice(item);
// Normalize inventory
const stock = normalizeStock(item.inventory);
return {
productId: item.id,
masterId: item.master?.masterId ?? item.id,
name: item.name,
description: item.shortDescription ?? '',
longDescription: item.longDescription ?? '',
brand: item.brand ?? '',
price,
stock,
images,
variants: [...colorVariants, ...sizeVariants],
promotions: item.productPromotions ?? [],
categories: item.categories ?? [],
breadcrumbs: await makeBreadcrumbResponse(item.primaryCategoryId),
};
};
The normalized schema is the contract between your backend integration and your UI. When Salesforce updates their OCAPI schema — or you migrate from OCAPI to the newer SCAPI — only the mapper changes, not the components consuming it.
The Price Normalization Problem (and Its Lesson)
OCAPI's price representation is inconsistent across product types. Simple products have a flat price field. Master products expose price as the lowest variant price and priceMax as the highest. Promotional prices sit inside productPromotions. We found price-extraction logic scattered across six components before extracting it into one place:
// utils/price.js
export const normalizePrice = (item) => {
const listPrice = item.price ?? 0;
const maxPrice = item.priceMax ?? listPrice;
const promoPrice = item.productPromotions
?.find(p => p.promotionalPrice != null)
?.promotionalPrice ?? null;
return {
list: { value: listPrice, currency: item.currency ?? 'USD' },
max: { value: maxPrice, currency: item.currency ?? 'USD' },
sale: promoPrice != null
? { value: promoPrice, currency: item.currency ?? 'USD' }
: null,
isRange: maxPrice > listPrice,
isOnSale: promoPrice != null && promoPrice < listPrice,
};
};
Rule of thumb: if a field requires conditional logic to interpret, that logic belongs in the mapper — not in the render function.
Authentication: JWT Shop API and SLAS
SFCC OCAPI authentication has two distinct flows depending on the context.
sequenceDiagram
autonumber
participant B as Browser
participant NX as Next.js API Route
participant SC as SFCCClient Service
participant SFCC as Salesforce Commerce Cloud
rect rgb(234, 244, 251)
Note over B,SFCC: Guest / Anonymous Flow (OCAPI JWT)
B->>NX: POST /api/session/init
NX->>SC: createGuestSession()
SC->>SFCC: POST /customers/auth?client_id={id}\nbody: { type: "guest" }
SFCC-->>SC: JWT token in Authorization header
SC->>SFCC: POST /baskets (Authorization: Bearer {jwt})
SFCC-->>SC: { basketId, currency, shipments[] }
SC-->>NX: { status: 200, response: basket }
NX-->>B: Set-Cookie: sfcc_jwt + basketId (HTTP-only)
end
rect rgb(234, 243, 222)
Note over B,SFCC: Authenticated User Flow (SLAS)
B->>NX: POST /api/auth/login (email, password)
NX->>SC: loginCustomer(email, password)
SC->>SFCC: POST /customers/auth\nbody: { type: "credentials" }
SFCC-->>SC: user-scoped JWT + customer object
NX->>SC: mergeBasket(guestBasketId, userJwt)
SC->>SFCC: POST /baskets/{guestId}/merge\n(Authorization: Bearer {userJwt})
SFCC-->>SC: merged basket
SC-->>NX: { status: 200, response: mergedBasket }
NX-->>B: Set-Cookie: sfcc_user_jwt + basketId (HTTP-only)
end
The guest session JWT is returned in the Authorization response header — not the body — which catches developers off guard. Extract it explicitly:
createGuestSession = async () => {
const res = await fetch(`${this.baseUrl}/customers/auth?client_id=${this.config.clientId}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ type: 'guest' }),
cache: 'no-store',
});
// JWT is in the Authorization header, not the response body
const jwt = res.headers.get('Authorization');
const customer = await res.json();
return {
status: customer.fault ? 400 : 200,
response: { jwt, customer },
};
};
Next.js 14 Integration: Where Components Call OCAPI
Server Components fetch product data directly
// app/products/[productId]/page.jsx
import SFCCClient from '@/lib/sfcc/SFCCClient';
export default async function ProductPage({ params }) {
const sfcc = new SFCCClient();
const { status, response: product } = await sfcc.getProduct({
productId: params.productId,
});
if (status !== 200) return <ProductErrorState />;
return <ProductDetailClient product={product} />;
}
No useEffect. No client-side loading state for the primary product data. The page arrives fully rendered — important for a performance brand like Asics where Core Web Vitals directly affect organic search rankings.
Client Components use API Routes for basket operations
// app/api/basket/entries/route.js
import SFCCClient from '@/lib/sfcc/SFCCClient';
import { cookies } from 'next/headers';
export async function POST(request) {
const { productId, quantity, variantId } = await request.json();
const cookieStore = cookies();
const basketId = cookieStore.get('basketId')?.value;
const jwt = cookieStore.get('sfcc_jwt')?.value;
const sfcc = new SFCCClient();
const result = await sfcc.addProductToBasket({
basketId, jwt, productId, quantity, variantId,
});
return Response.json(result, { status: result.status });
}
The client component POSTs to /api/basket/entries. The route handler reads tokens from HTTP-only cookies, calls SFCCClient server-side, and returns the result. Credentials and JWTs never leave the server.
Caching Strategy
SFCC OCAPI data maps onto Next.js 14 caching modes the same way SAP OCC does — the key is being explicit about freshness requirements at the fetch call site:
flowchart LR
subgraph Static["Static — Build Time"]
HP["Homepage\nSSG"]
end
subgraph ISR["ISR — Tag Revalidated"]
PDP["Product Detail Page\nrevalidate: 3600\ntag: fetchProductDetails"]
PLP["Category / Search Results\nrevalidate: 1800\ntag: fetchCategoryProducts"]
end
subgraph SSR["SSR — Per Request"]
PS["Personalised\nRecommendations"]
end
subgraph NoCache["No Cache — Always Fresh"]
BASKET["Basket Operations\ncache: no-store"]
AUTH["Auth / JWT\ncache: no-store"]
PROMO["Flash Promotions\ncache: no-store"]
end
subgraph Invalidation["On-Demand Invalidation"]
WH["SFCC Business Manager\nWebhook / Custom Job"]
RT["revalidateTag()\nfetchProductDetails\nfetchCategoryProducts"]
WH -->|"product / price updated"| RT
end
RT -.->|"purges"| PDP
RT -.->|"purges"| PLP
One SFCC-specific consideration: promotional prices in OCAPI are returned inline with the product response. If you cache a product with isOnSale: true and the promotion ends, you need a webhook-triggered revalidateTag to ensure the cached response is purged. Stale promotional pricing on a cached PDP is a customer trust issue, not just a data accuracy issue.
Lessons from Production
1. The JWT is in the response header, not the body.
SFCC OCAPI returns the session JWT in the Authorization response header on POST /customers/auth. If you only parse res.json(), you'll miss it. Always extract it with res.headers.get('Authorization') before parsing the body.
2. OCAPI fault objects don't always map to HTTP error codes.
Some SFCC error conditions return HTTP 200 with a fault object in the body — for example, when a product is not found in a specific locale. Always check for response.fault in addition to response.status before treating a response as successful.
3. imageGroups is an array, not a map.
OCAPI returns image groups as an array of objects with a viewType string field. It is tempting to reach into imageGroups[0] assuming large images come first — they don't always. Always use .find(g => g.viewType === 'large') explicitly. Doing this in the mapper means the component just receives images[] and never knows the original structure.
4. Variant availability comes from the master product, not the variant.
Individual variant products returned from /products/{variantId} don't always include full variationAttributes with availability per variant. Fetch the master product with expand=variations,availability and derive variant availability from the master response. Discovering this after building variant selection logic in the component layer costs days.
5. Basket merging requires the guest JWT, not the user JWT.
The POST /baskets/{guestBasketId}/merge endpoint must be called with the guest JWT in the Authorization header, while passing the registered customer token in the request body. Reversing these silently creates a new empty basket instead of merging. Store both tokens through the login flow and pass them explicitly.
The Pattern in Summary
The service + mapper pattern gives you:
- Frontend isolation — components depend on your normalized schema, not OCAPI's Demandware-shaped payloads
- Single update point — OCAPI schema changes or a migration to SCAPI requires updating one mapper, not every component
- Consistent error handling — the
{ status, response }envelope normalizes SFCC's mixed HTTP-code-plus-fault-object error surface - Testability — mappers are pure functions; unit tests don't require a Salesforce sandbox
- Next.js cache alignment — caching decisions live at the fetch call site, making freshness requirements visible and auditable
Headless commerce on Salesforce Commerce Cloud gives you a powerful, proven backend. The service + mapper pattern is what makes it feel like a clean API rather than a legacy data model — and it's what lets your frontend team move at their own pace while the backend team manages promotions, pricing rules, and catalog changes on their own release schedule.
Have you integrated SFCC OCAPI or migrated to SCAPI in a headless setup? I'd love to hear how you handled the authentication token handoff and variant availability in the comments.
Tags: #ComposableCommerce #SalesforceCommerceCloud #OCAPI #SCAPI #NextJS #Headless #Ecommerce #FrontendArchitecture #ReactJS #WebDevelopment #MACHArchitecture #JWT #SLAS #Performance