Schema.org JSON-LD tells search engines and answer engines what a page is about in a form they don't have to guess. A Crystallize product already has the facts: variants with their own SKU, GTIN, price and stock, typed specifications with units, brands, topics and relations. This page maps them to JSON-LD, and covers images for structured data and link previews.
Emit one <script type="application/ld+json"> per page with an @graph, and give every entity an @id (the page URL plus a fragment). A product page, its brand page and the organisation then describe the same entities, linked by @id, instead of repeating them.
// `<` is escaped, so content from the catalogue can't close the script tag.
export function JsonLd({ data }: { data: unknown }) {
return <script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(data).replace(/</g, '\\u003c') }} />;
}// `<` is escaped, so content from the catalogue can't close the script tag.
export function JsonLd({ data }: { data: unknown }) {
return <script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(data).replace(/</g, '\\u003c') }} />;
}A Crystallize product with several variants is a ProductGroup, with one Product per variant in hasVariant. Each variant has its own sku, gtin, image and Offer. A product with one variant can be a plain Product.
{
"@context": "https://schema.org",
"@graph": [
{
"@type": "ProductGroup",
"@id": "https://your-store.example/en/drills/cordless-drill-driver-18v#product",
"productGroupID": "ITEM_ID",
"name": "Cordless drill driver 18V",
"url": "https://your-store.example/en/drills/cordless-drill-driver-18v",
"description": "Brushless two-speed drill driver with 60 Nm of torque.",
"brand": { "@id": "https://your-store.example/en/brands/example-brand#brand" },
"hasVariant": [
{
"@type": "Product",
"@id": "https://your-store.example/en/drills/cordless-drill-driver-18v#DD18-BARE",
"name": "Cordless drill driver 18V (bare tool)",
"sku": "DD18-BARE",
"gtin13": "7000000000001",
"image": ["https://media.crystallize.com/your-tenant/…/@768/drill-driver.jpeg"],
"isVariantOf": { "@id": "https://your-store.example/en/drills/cordless-drill-driver-18v#product" },
"offers": {
"@type": "Offer",
"sku": "DD18-BARE",
"url": "https://your-store.example/en/drills/cordless-drill-driver-18v",
"price": 149,
"priceCurrency": "EUR",
"priceSpecification": [
{ "@type": "UnitPriceSpecification", "price": 149, "priceCurrency": "EUR", "valueAddedTaxIncluded": true },
{ "@type": "UnitPriceSpecification", "price": 123.14, "priceCurrency": "EUR", "valueAddedTaxIncluded": false }
],
"availability": "https://schema.org/InStock",
"inventoryLevel": { "@type": "QuantitativeValue", "value": 12, "unitCode": "C62" },
"itemCondition": "https://schema.org/NewCondition",
"seller": { "@id": "https://your-store.example/#organization" }
}
}
]
}
]
}{
"@context": "https://schema.org",
"@graph": [
{
"@type": "ProductGroup",
"@id": "https://your-store.example/en/drills/cordless-drill-driver-18v#product",
"productGroupID": "ITEM_ID",
"name": "Cordless drill driver 18V",
"url": "https://your-store.example/en/drills/cordless-drill-driver-18v",
"description": "Brushless two-speed drill driver with 60 Nm of torque.",
"brand": { "@id": "https://your-store.example/en/brands/example-brand#brand" },
"hasVariant": [
{
"@type": "Product",
"@id": "https://your-store.example/en/drills/cordless-drill-driver-18v#DD18-BARE",
"name": "Cordless drill driver 18V (bare tool)",
"sku": "DD18-BARE",
"gtin13": "7000000000001",
"image": ["https://media.crystallize.com/your-tenant/…/@768/drill-driver.jpeg"],
"isVariantOf": { "@id": "https://your-store.example/en/drills/cordless-drill-driver-18v#product" },
"offers": {
"@type": "Offer",
"sku": "DD18-BARE",
"url": "https://your-store.example/en/drills/cordless-drill-driver-18v",
"price": 149,
"priceCurrency": "EUR",
"priceSpecification": [
{ "@type": "UnitPriceSpecification", "price": 149, "priceCurrency": "EUR", "valueAddedTaxIncluded": true },
{ "@type": "UnitPriceSpecification", "price": 123.14, "priceCurrency": "EUR", "valueAddedTaxIncluded": false }
],
"availability": "https://schema.org/InStock",
"inventoryLevel": { "@type": "QuantitativeValue", "value": 12, "unitCode": "C62" },
"itemCondition": "https://schema.org/NewCondition",
"seller": { "@id": "https://your-store.example/#organization" }
}
}
]
}
]
}priceSpecification with valueAddedTaxIncluded, so a trade buyer's agent can read the net price too.<priceVariant>PriceTiers) are more UnitPriceSpecification entries with eligibleQuantity: { "@type": "QuantitativeValue", "minValue": 10, "unitCode": "C62" }.defaultStock or a location's stock): InStock above zero, OutOfStock at zero. Leave it out if the tenant has no stock data./en, /no), with its own currency and VAT. Say which region an offer is for with eligibleRegion.schema.org, Google Merchant Center and GS1 treat sku, gtin13 (or gtin8, gtin12, gtin14) and mpn as separate facts. Keep the merchant SKU in Crystallize's SKU, and the GTIN in its own non-translatable field on the variant. National trade numbers become identifier entries: { "@type": "PropertyValue", "propertyID": "NOBB", "value": "…" }.
Specifications modelled as typed components (numeric with units, boolean, selection) map one to one onto additionalProperty. A label/value table can't, because nothing in it says which part is a number or a unit.
{
"@type": "PropertyValue",
"name": "Torque, hard joint",
"propertyID": "EF002842",
"value": 60,
"unitText": "Nm",
"unitCode": "NU"
}{
"@type": "PropertyValue",
"name": "Torque, hard joint",
"propertyID": "EF002842",
"value": 60,
"unitText": "Nm",
"unitCode": "NU"
}VLT for volts, AMH for ampere-hours, NU for newton-metres, KGM for kilograms, MMT for millimetres, RPM for revolutions per minute (C94 for a rate per minute, such as impacts). Crystallize's unit list has no slot for the code, so keep a map in the storefront, and pick unit strings that map one to one.maxDrillingDiameter is also maxValue, and a minimum is minValue.valueReference to a DefinedTerm (name, termCode), so an option key and its translated label travel together.false.EF…, for example).Simple physical facts have their own properties: weight, width, height and depth as QuantitativeValue.
CategoryCode when the topic carries a classification code in its meta (ETIM EC…, GS1 GPC, UNSPSC): { "@type": "CategoryCode", "codeValue": "EC001391", "inCodeSet": { "@type": "CategoryCodeSet", "name": "ETIM" } }.@id reference to a Brand defined on the brand page.isAccessoryOrSparePartFor, isConsumableFor or isRelatedTo, pointing at the related product's @id.includesObject on the Offer: TypeAndQuantityNode with amountOfThisGood and typeOfGood.warranty on the Offer: a WarrantyPromise with durationOfWarranty in years (unitCode: "ANN").businessFunction: "http://purl.org/goodrelations/v1#LeaseOut" and leaseLength, with depots as availableAtOrFrom Places.CollectionPage with an ItemList of the products shown, each ListItem with position and url.breadcrumbs, which end with the item itself.Organization (or OnlineStore) with @id, logo and areaServed, and a WebSite with a SearchAction to your search URL.og:image and JSON-LD image.url.og:image:width, og:image:height).// Prefer the original format, then WebP, never AVIF; largest first.
const rank = (url: string) => (/\.avif(\?|$)/i.test(url) ? 2 : /\.webp(\?|$)/i.test(url) ? 1 : 0);
export function bestImage(img?: { url: string; variants?: { url: string; width: number }[] | null } | null) {
const v = [...(img?.variants ?? [])].filter((x) => rank(x.url) < 2).sort((a, b) => rank(a.url) - rank(b.url) || b.width - a.width)[0];
return v?.url ?? img?.url;
}// Prefer the original format, then WebP, never AVIF; largest first.
const rank = (url: string) => (/\.avif(\?|$)/i.test(url) ? 2 : /\.webp(\?|$)/i.test(url) ? 1 : 0);
export function bestImage(img?: { url: string; variants?: { url: string; width: number }[] | null } | null) {
const v = [...(img?.variants ?? [])].filter((x) => rank(x.url) < 2).sort((a, b) => rank(a.url) - rank(b.url) || b.width - a.width)[0];
return v?.url ?? img?.url;
}Build the JSON-LD, the visible spec sheet and the Markdown version from the same typed rows, read once from Discovery. Then they can't disagree, and an answer engine that compares them finds the same value everywhere. See llms.txt and Markdown.