All posts
Ray Iyer
Ray Iyer
Co-founder, Anglera

Adding Product JSON-LD on SAP Commerce Cloud — and keeping it in sync

How to add schema.org Product JSON-LD on SAP Commerce Cloud, which fields matter most (gtin, sku, offers, aggregateRating), and how to keep it in sync.

Adding Product JSON-LD on SAP Commerce Cloud — and keeping it in sync

Getting Product structured data onto an SAP Commerce Cloud storefront is mostly a mapping problem: the JSON-LD has to describe the same product the shopper (and any AI agent reading the page) actually sees, using data that already lives in your product model. This is a field-by-field walkthrough for distributors and manufacturers running either the composable storefront (Spartacus) or a legacy Accelerator (JSP) storefront.

Where structured data lives in SAP Commerce Cloud

If you're on the composable storefront, JSON-LD generation is already built in, not something you have to bolt on. Spartacus ships a StructuredDataModule (imported by SeoModule) that renders schema.org markup as part of server-side rendering. That matters because Google, and most AI crawlers, only reliably read structured data present in the initial HTML response, not markup injected client-side after hydration. The actual schema builders live in JsonLdBuilderModule, with a ProductSchemaBuilder assembling the Product schema out of pluggable sub-builders: one for base product data, one for the Offer (price/stock), one for Rating/Review. SAP registers them against the JSONLD_PRODUCT_BUILDER injection token, so a custom builder for a distributor-specific identifier can be added without touching the base builder.

If you're still on the older JSP-based Accelerator storefront, common on longer-running B2B implementations that haven't migrated to Spartacus, there's no equivalent built-in module. JSON-LD has to be hand-rolled as a <script type="application/ld+json"> block inside the Product Details Page JSP (productDetailsPage.jsp in the stock Accelerator, under WEB-INF/views/pages, though a customized storefront may rename it), populated from the same ProductModel fields Spartacus would use, and registered in the appropriate Content Slot on the Product Details Page template, ideally via an addon so it survives storefront upgrades.

The fields that matter

Seven properties do most of the work for merchant listing rich results (Google requires name, image, and offers with a valid price/priceCurrency; the rest are recommended) and for giving an AI shopping agent something unambiguous to parse:

  • name: the product's display title. Pull it from the same localized ProductModel name field the PDP title renders from. Don't hand-author a separate SEO title, or the two will drift apart within a quarter.
  • image: at least one high-resolution product image URL. Spartacus wires this up out of the box from the product's primary image, so it's usually free; on an Accelerator storefront it has to be added by hand alongside the other fields.
  • brand: schema.org (and Google) want this as a nested Brand object with a name, but Spartacus's own default builder emits it as a plain string (the ProductModel.manufacturer value). Getting the object form, or sourcing brand from a Brand category or classification attribute instead of the free-text manufacturer field, takes a small custom builder, not a config toggle.
  • gtin (or gtin8/gtin12/gtin13/gtin14, depending on identifier length): Google recommends including this because it's widely used for cross-referencing a listing against a manufacturer's own data. Distributors carrying manufacturer catalogs frequently have GTIN/EAN/UPC sitting in a classification attribute already; map it as-is instead of reformatting it. It isn't a field on the out-of-box product model on either storefront, so it has to be read out of that classification attribute explicitly.
  • sku: your own product identifier, typically the ProductModel.code. It doesn't need to be globally unique, just unique in your catalog.
  • offers: a nested Offer with price, priceCurrency (ISO 4217), availability (an ItemAvailability value), and url. Most likely block to go stale, since it's driven by the same price row and stock level data that powers the buy box.
  • aggregateRating (and optionally review): ratingValue and reviewCount/ratingCount. If you're using SAP Commerce's native ratings/reviews feature or an integrated ratings provider, these values already exist on the product. The schema builder just needs to read from the same source the visible star rating widget reads from.

Mapping product model fields to the schema

On the composable storefront, this mapping happens in TypeScript, not markup. The out-of-box Product model has no gtin/mpn fields, and the default builder emits brand as a plain string, so a distributor-specific builder typically does two things: pull GTIN and MPN out of the classification system, and re-shape brand into the nested object Google expects. A minimal custom addition to the product builder chain looks like this:

import { Injectable } from '@angular/core';
import { JsonLdBuilder } from '@spartacus/storefront';
import { Classification, Product } from '@spartacus/core';
import { Observable, of } from 'rxjs';

// Class and feature codes here are placeholders — confirm the actual
// codes your classification system (or PIM) uses for GTIN/MPN.
function featureValue(
  classifications: Classification[] = [],
  classCode: string,
  featureCode: string
): string | undefined {
  const classification = classifications.find((c) => c.code === classCode);
  const feature = classification?.features?.find((f) => f.code === featureCode);
  return feature?.featureValues?.[0]?.value;
}

@Injectable({ providedIn: 'root' })
export class DistributorGtinBrandBuilder implements JsonLdBuilder<Product> {
  build(product: Product): Observable<Record<string, unknown>> {
    return of({
      gtin13: featureValue(product.classifications, 'GTIN_CLASS', 'gtin13'),
      mpn: featureValue(product.classifications, 'GTIN_CLASS', 'mpn'),
      brand: product.manufacturer
        ? { '@type': 'Brand', name: product.manufacturer }
        : undefined,
    });
  }
}
import { NgModule } from '@angular/core';
import { JSONLD_PRODUCT_BUILDER } from '@spartacus/storefront';
import { DistributorGtinBrandBuilder } from './distributor-gtin-brand.builder';

@NgModule({
  providers: [
    { provide: JSONLD_PRODUCT_BUILDER, useClass: DistributorGtinBrandBuilder, multi: true },
  ],
})
export class DistributorStructuredDataModule {}

The multi: true provider lets your builder run alongside SAP's default JsonLdBaseProductBuilder, JsonLdProductOfferBuilder, and JsonLdProductReviewBuilder instead of replacing them; ProductSchemaBuilder collects every registered JSONLD_PRODUCT_BUILDER and merges their output into one object, with later-registered builders' keys winning on overlap (how the brand override above takes effect). Spartacus then writes that merged object straight into a single <script id="json-ld" type="application/ld+json"> element appended to the document during SSR. That's a different mechanism from the [cxJsonLd] template directive, which is for hand-adding supplementary JSON-LD elsewhere on a page and isn't part of this automatic pipeline.

A real example

Here's what the merged output should resemble for a distributor-carried industrial part:

{
  "@context": "https://schema.org/",
  "@type": "Product",
  "name": "1/2 HP Bronze Circulator Pump, Threaded",
  "sku": "CP-0500-BR-T",
  "gtin13": "0785123456781",
  "mpn": "179-002",
  "brand": {
    "@type": "Brand",
    "name": "Taco Comfort Solutions"
  },
  "offers": {
    "@type": "Offer",
    "url": "https://www.example-distributor.com/p/CP-0500-BR-T",
    "priceCurrency": "USD",
    "price": "214.99",
    "availability": "https://schema.org/InStock",
    "itemCondition": "https://schema.org/NewCondition"
  },
  "aggregateRating": {
    "@type": "AggregateRating",
    "ratingValue": "4.6",
    "reviewCount": "38"
  }
}

Keeping it in sync with the visible page

Drift is the recurring failure mode here, not a missing field. Four places it shows up in SAP Commerce Cloud implementations:

  • Price and stock caching. SAP Commerce Cloud's CMS cache and CDN layer cache pages more aggressively than they cache the price/stock service that feeds the buy box. If the Offer block is built from a different cache region or refresh cadence than the visible price, the two can disagree for minutes at a time during a price change.
  • Availability values that don't cover the real states. Spartacus's default offer builder only distinguishes in-stock vs. everything else, as plain InStock/OutOfStock strings rather than full https://schema.org/... URLs. Backorder, preorder, or discontinued states in your buy box will silently flatten into "out of stock" in the schema unless a custom builder maps the full set of states your stock service returns.
  • Variant vs. base product mismatch. Multi-variant PDPs (size, color, pack size) need the JSON-LD to describe the exact variant the shopper landed on, with a URL canonical to that variant rather than the base product, or the price and GTIN in the schema won't match what's rendered.
  • Review widget vs. schema source. If ratings render from a third-party widget (client-side) but the schema pulls averageRating/numberOfReviews from the ProductModel, the two numbers can quietly diverge after a widget migration.

The fix in every case is the same: point the schema builder and the visible component at the same underlying service call, not two independent reads of "similar" data.

How to validate

  • View-source vs. rendered DOM: because Spartacus renders JSON-LD during SSR, curl (or "view page source") should already show the full <script type="application/ld+json"> block — if it only shows up in the browser's rendered DOM inspector, structured data is being generated client-side and search engines won't reliably see it.
  • curl -s https://yoursite.com/p/SKU | grep -A 30 'application/ld+json' confirms the block is present server-side and lets you diff it against the visible price/availability on the page.
  • Run the URL through Google's Rich Results Test to confirm the Product markup parses and is eligible for merchant listing / product snippet features, and check the Merchant Listing structured data reference if a required property is flagged as missing.

Verified as of July 2026 against SAP's composable storefront documentation and Spartacus's structured data module; menu paths and class names for JSP-based Accelerator storefronts will vary by version, so confirm against your own codebase before shipping.

None of this works if the underlying attributes (GTIN, brand, spec-driven use cases, normalized identifiers) aren't populated and current in the first place. That's the half of the problem Anglera handles: it enriches product data continuously inside the PIM or commerce platform you already run, so the JSON-LD builder above always has a complete, accurate record to map from instead of blank fields to work around.

Ray Iyer

About the author

Ray IyerCo-founder, Anglera

Ray is a co-founder of Anglera, building the product-data infrastructure for agentic commerce — turning messy catalogs into structured, AI-readable data that buyers and answer engines can find. Previously product at Uber; Stanford CS.

See it on your own SKUs.

A 30-minute walkthrough on your categories and your supplier data.

Book a demo