Structured data mastery

Product schema for Salesforce Commerce Cloud

SFRA, the composable storefront, OCAPI vs SCAPI, and the cartridge pattern for adding JSON-LD to a B2C Commerce deployment without forking the reference architecture.

11 min read Updated May 31, 2026

Salesforce B2C Commerce (the platform formerly known as Demandware, sold today as part of Commerce Cloud and the Agentforce Commerce stack) was designed for tier-one retail brands long before “structured data” was a feature anyone shipped. It shows in the defaults: the Storefront Reference Architecture (SFRA) is a clean server-rendered MVC stack, but rich JSON-LD isn’t a built-in concern — it’s something the implementation team adds during a build.

This guide walks through the B2C Commerce catalog model, the storefront architectures (SFRA and the composable PWA Kit storefront), and the cartridge pattern for injecting Schema.org JSON-LD without modifying the reference code directly.

If you’re auditing an existing SFCC deployment instead of building on it, the Salesforce Commerce Cloud AI readiness audit guide is the companion piece.

The B2C Commerce catalog model

A B2C Commerce catalog is organized around a small set of entity types, each with its own implications for how product schema gets rendered.

EntityRoleSchema implication
Master productThe “parent” — holds variation attributes (color, size) and shared contentRender as Product with embedded hasVariant array or as ProductGroup
VariantA buyable SKU under a master, with its own GTIN, price, and inventoryEach variant has its own Product shape with isVariantOf pointing at the master
Product setA merchandising bundle that links related sellable products together (a “look”)Render as a top-level Product with isRelatedTo rather than hasVariant
BundleA fixed-composition kit sold as one SKURender as a single Product; the components are not separately offered
Standard productA single sellable SKU with no variantsA single Product with offers

The master/variant pattern is the one that bites implementations most often. SFCC’s variation attributes are flexible enough that a team can model the same catalog in multiple ways — and only one of those ways renders cleanly to AI agents. See the variant handling in product schema guide for the modeling decision; this guide focuses on the rendering side.

Custom attributes

Beyond the system attributes (name, id, brand, price, availability), almost every SFCC catalog uses custom attributes to carry the data AI agents care about: material, fit, country of origin, ingredient list, age range, compatibility, certifications.

These live on the product entity as system or custom attribute groups, configured in Business Manager. The script API surfaces them via product.custom.<attributeId>. The implication for schema: the data is in the catalog, it just isn’t surfaced in the default product detail page output. The cartridge pattern below is the place to bridge it.

SFRA — the server-rendered storefront

SFRA is the reference storefront architecture B2C Commerce ships today. It succeeded the older SiteGenesis architecture — Salesforce has not formally retired SiteGenesis, but SFRA is the recommended starting point for new builds — and uses an MVC pattern:

The architectural detail to internalize: you never edit the app_storefront_base cartridge directly. Site-specific code lives in a separate cartridge (commonly app_<brand>_storefront), which is placed earlier on the cartridge path so its templates and decorators win.

For structured data, this means the JSON-LD injection lives in a site-specific cartridge that overrides the head template or extends the product detail template — never in the base.

The default SFRA output

The honest answer: verify what your SFRA build ships before assuming. The reference architecture has evolved across releases, and which structured-data primitives appear in the default templates depends on which version your team built on and what the implementation partner customized.

The reliable assumption is that anything beyond minimal Product markup — variants modelled as hasVariant, offers with real inventory state, aggregateRating and review, brand as an Organization, GTIN family identifiers — is something your team added. If you can’t find it in the rendered HTML for a production PDP, treat it as missing rather than assumed.

The composable storefront — PWA Kit and beyond

For headless deployments, Salesforce ships the Composable Storefront (PWA Kit) — a React-based storefront that consumes data from B2C Commerce via the Shopper API (SCAPI). The relationship between the two:

For structured data, the difference is the rendering surface:

The implication for a headless build: if the team is rendering JSON-LD with next/head or equivalent on the server, you’re fine. If they’re injecting it via React effects on the client, the crawlers and agents that don’t execute JavaScript will miss it entirely. The SSR-vs-CSR distinction is a foundational AI readiness concern on every headless storefront; the principle is the same whether the back end is SFCC, Adobe Commerce, or Shopify.

OCAPI vs SCAPI

For completeness, the API distinction matters for any team building a headless storefront or a downstream system that needs to read catalog data:

For a structured-data project, the API choice doesn’t change what needs to be rendered — Schema.org doesn’t care how the data was fetched. It changes who renders it. If a headless storefront is hydrating from SCAPI, the JSON-LD render lives in the storefront code, not in SFCC.

The cartridge pattern for adding JSON-LD (SFRA)

The canonical pattern for adding rich product schema to an SFRA storefront:

Step 1 — pick a cartridge

Put the code in a site-specific cartridge that already exists (e.g. app_<brand>_storefront). If structured data is a larger project that will span multiple brands or sites, create a dedicated int_structured_data cartridge and place it on the cartridge path ahead of the site cartridges.

Step 2 — build a model

Don’t render schema directly from ISML. Build a model that takes a product script API object and returns a pure JSON object matching the Schema.org shape you want:

// cartridges/int_structured_data/cartridge/models/productSchema.js
'use strict';

const URLUtils = require('dw/web/URLUtils');

function ProductSchemaModel(product, options) {
    const isVariant = product.variant;
    const master = isVariant ? product.variationModel.master : product;

    this['@context'] = 'https://schema.org';
    this['@type'] = 'Product';
    this.name = product.name;
    this.sku = product.ID;
    this.description = product.shortDescription
        ? product.shortDescription.markup
        : '';
    this.image = collectImages(product);

    if (product.brand) {
        this.brand = {
            '@type': 'Brand',
            name: product.brand,
        };
    }

    if (product.UPC) {
        this.gtin = product.UPC;
    }

    if (product.manufacturerSKU) {
        this.mpn = product.manufacturerSKU;
    }

    this.offers = buildOffers(product, options);

    if (isVariant) {
        this.isVariantOf = {
            '@type': 'ProductGroup',
            productGroupID: master.ID,
            name: master.name,
        };
    }
}

module.exports = ProductSchemaModel;

(Helpers — collectImages, buildOffers — read from product.getImages('large'), product.priceModel, and product.availabilityModel respectively. Keep them in the same module so the rendering boundary stays tight.)

Step 3 — extend the PDP template

Override or extend product/productDetails.isml from the site-specific cartridge to include the JSON-LD block:

<isscript>
    var ProductSchemaModel = require('*/cartridge/models/productSchema');
    var schema = new ProductSchemaModel(pdict.product, {
        url: URLUtils.https('Product-Show', 'pid', pdict.product.id).toString()
    });
</isscript>

<script type="application/ld+json">
    ${JSON.stringify(schema)}
</script>

The */cartridge/... prefix on the require lets the cartridge path resolve to whichever cartridge defines the model — important when you want to share the model across SFRA and a custom cartridge layer.

Step 4 — guard against the empty case

JSON.stringify(undefined) returns undefined, which renders as the literal string undefined in the script tag. Always check the model returned a populated object before emitting the script tag, and skip the tag entirely on error rather than ship broken JSON.

Page Designer and structured data

If your team is building content pages with Page Designer (the visual page builder), the structured data implications are different:

The component-level pattern: define a JSON-LD-emitting Page Designer component that authors can drop into a page, and have it read from the component’s configured fields. Don’t try to make authoring concerns (which page lives where) leak into the schema shape.

Headless and the composable storefront

For a Composable Storefront build, the same model can be written in TypeScript and consumed in the server-side render path of the framework (Next.js’s App Router, Remix, etc.). Two rules that port from the SFRA pattern:

  1. Emit at server-side render time, not after hydration. AI crawlers don’t reliably execute JavaScript. JSON-LD injected client-side is invisible to a meaningful share of the agents you care about.
  2. One model, many rendering surfaces. The schema-building logic lives in a shared TypeScript module. The SSR layer calls it; the build-time prerender (if you have one) calls it; if a future RSC or edge layer enters, that calls it too. The shape of the schema is independent of the runtime.

Common implementation traps

TrapWhy it bites
Embedding JSON-LD with hand-written ISML string concatenationOne escape failure (a quote in a product name, a backslash in a description) and the JSON is invalid. Always use JSON.stringify on a built object.
Pulling localized strings from the wrong localeSFCC has request.locale, the customer’s locale, and the catalog’s locale. Schema should always render in the customer-facing locale of the rendered page.
Hardcoding availability: InStockInventory state changes by the minute on a busy store. Read it from product.availabilityModel.availabilityStatus and map every state, including NOT_AVAILABLE (OutOfStock) and BACKORDER (PreOrder or BackOrder depending on policy).
Forgetting B2B/B2C overlap on a Commerce Cloud installIf the same install serves both B2C and B2B traffic with different pricing logic, the schema’s priceCurrency and price need to render from the right pricebook for the visiting customer’s segment.
Variant URL driftSFCC variant URLs are constructed from variation attribute selections. Make sure the url in the schema points at the canonical variant URL, not a redirect or a query-string-stripped master URL.
Page Designer pages shipping no schema at allIf a category landing is built in Page Designer, the default page template does not emit category-level schema. Add it explicitly.

Validation

Same as every other platform: render a real PDP, view source, copy the JSON-LD into Google’s Rich Results Test and Schema.org’s validator, and fix what breaks. The validating structured data guide has the full workflow.

If you want a continuous read across the full catalog rather than a one-off check, that’s what Lumio does — every product, every release, every change.

Cluster — structured data on SFCC