Merchant Listing
On this page. Each variant: name, image, price, currency, availability, condition.
One product sold in several colors and sizes: a ProductGroup with a Product and Offer per variant, linked to the store policies and loyalty tiers defined on the homepage.
Use on a page where a shopper can buy the product. That makes it eligible for merchant listings, and the same markup covers product snippets.
When the product comes in variants, describe the parent as a ProductGroup and give every sellable variant its own Product and Offer. Return and shipping terms come from the store-wide policies on the homepage, so they are not repeated here.
Each card opens the framework for that feature, with its own rules and property reference. Cards marked "Linked by @id" are defined on the homepage and only referenced here.
On this page. Each variant: name, image, price, currency, availability, condition.
On this page. ProductGroup with productGroupID, variesBy and hasVariant.
On this page. Covered by the same Product nodes, plus the aggregate rating.
On this page. The trail shown on the page, with the product as the last crumb.
Linked by @id. Member prices point at a tier @id from the homepage.
Linked by @id. Store-wide policy from the homepage applies to every offer.
Linked by @id. Store-wide shipping from the homepage applies to every offer.
Linked by @id. Seller of every offer, by @id.
Every node has a stable @id. A node is written out once, where it belongs, and everywhere else it is referenced by that @id alone. This is the map for this page, generated from the output below.
| @id | Defined on | Type | Referenced by |
|---|---|---|---|
/jackets/ridgeline-rain-shell/#webpage | This page | ItemPage | Top-level node |
/jackets/ridgeline-rain-shell/#breadcrumb | This page | BreadcrumbList | ItemPage.breadcrumb |
/jackets/ridgeline-rain-shell/#product-group | This page | ProductGroup | ItemPage.mainEntity, Product.isVariantOf |
/jackets/ridgeline-rain-shell/#sku-RRS-24-SPR-M | This page | Product | Nested in ProductGroup.hasVariant |
/jackets/ridgeline-rain-shell/#sku-RRS-24-SPR-L | This page | Product | Nested in ProductGroup.hasVariant |
/jackets/ridgeline-rain-shell/#sku-RRS-24-RST-M | This page | Product | Nested in ProductGroup.hasVariant |
/#organization | Homepage | OnlineStore | Product.offers.seller |
/#tier-member | Homepage | MemberProgramTier | Product.offers.priceSpecification.validForMemberTier |
/#website | Homepage | WebSite | ItemPage.isPartOf |
One function turns the page's data into the whole graph. It drops anything Google would reject instead of shipping it half built. The input below includes rows it is meant to drop, so you can see the logic work.
// Product page schema: one product with variants, sold on this page.
// ProductGroup with a Product per variant (each with its own Offer), the
// page's WebPage and BreadcrumbList, and @id links to the Organization,
// return policy and loyalty tiers defined once on the homepage.
const CURRENCY = /^[A-Z]{3}$/;
const GTIN = /^(\d{8}|\d{12}|\d{13}|\d{14})$/;
const clean = (v) => (typeof v === 'string' ? v.trim() : v);
const isUrl = (v) => typeof v === 'string' && /^https:\/\/[^\s]+$/.test(v);
const compact = (obj) => {
if (Array.isArray(obj)) {
const out = obj.map(compact).filter((v) => v !== undefined);
return out.length ? out : undefined;
}
if (obj && typeof obj === 'object') {
const out = {};
for (const [k, v] of Object.entries(obj)) {
const c = compact(v);
if (c !== undefined) out[k] = c;
}
return Object.keys(out).length ? out : undefined;
}
return obj === null || obj === '' ? undefined : obj;
};
const AVAILABILITY = {
in_stock: 'https://schema.org/InStock',
out_of_stock: 'https://schema.org/OutOfStock',
preorder: 'https://schema.org/PreOrder',
backorder: 'https://schema.org/BackOrder',
};
const CONDITION = {
new: 'https://schema.org/NewCondition',
refurbished: 'https://schema.org/RefurbishedCondition',
used: 'https://schema.org/UsedCondition',
};
const VARIES_BY = { color: 'https://schema.org/color', size: 'https://schema.org/size', material: 'https://schema.org/material' };
function breadcrumb(trail, id) {
const items = (trail || []).filter((c) => c.name && isUrl(c.url));
if (items.length < 2) return undefined;
return {
'@type': 'BreadcrumbList',
'@id': id,
itemListElement: items.map((c, i) => ({ '@type': 'ListItem', position: i + 1, name: clean(c.name), item: c.url })),
};
}
// An Offer only exists for a variant with a real price and currency. Member
// pricing references a loyalty tier by @id from the homepage graph.
function offer(v, ctx) {
if (!(Number.isFinite(v.price) && v.price > 0) || !CURRENCY.test(ctx.currency)) return undefined;
const memberPrices = (v.memberPrices || [])
.filter((m) => m.tier && Number.isFinite(m.price) && m.price > 0 && m.price < v.price)
.map((m) => ({
'@type': 'UnitPriceSpecification',
price: m.price,
priceCurrency: ctx.currency,
validForMemberTier: { '@id': `${ctx.root}/#tier-${m.tier}` },
}));
return compact({
'@type': 'Offer',
url: ctx.variantUrl(v),
price: v.price,
priceCurrency: ctx.currency,
priceSpecification: memberPrices.length ? memberPrices : undefined,
availability: AVAILABILITY[v.availability] || 'https://schema.org/InStock',
itemCondition: CONDITION[v.condition] || CONDITION.new,
seller: { '@id': `${ctx.root}/#organization` },
// Org-level return policy applies by default; a product with different
// terms points at its own policy here instead.
hasMerchantReturnPolicy: v.returnPolicyId ? { '@id': v.returnPolicyId } : undefined,
});
}
function buildProductPageSchema(source) {
const site = source.site || {};
const page = source.page || {};
const p = source.product || {};
if (!isUrl(site.url) || !isUrl(page.url) || !p.name || !p.groupId) return null;
const root = site.url.replace(/\/+$/, '');
const pageUrl = page.url;
const groupId = `${pageUrl}#product-group`;
const variesBy = (p.variesBy || []).filter((k) => VARIES_BY[k]);
const ctx = {
root,
currency: clean(p.currency),
variantUrl: (v) => {
const params = variesBy.filter((k) => v[k]).map((k) => `${k}=${encodeURIComponent(String(v[k]).toLowerCase())}`);
return params.length ? `${pageUrl}?${params.join('&')}` : pageUrl;
},
};
// A variant needs a unique SKU, a name, an image and a sellable offer;
// anything less is dropped rather than shipped half built.
const seen = new Set();
const variants = (p.variants || [])
.filter((v) => v.sku && !seen.has(v.sku) && seen.add(v.sku))
.map((v) => {
const o = offer(v, ctx);
const image = (v.images || p.images || []).filter(isUrl);
if (!o || !image.length) return undefined;
return compact({
'@type': 'Product',
'@id': `${pageUrl}#sku-${v.sku}`,
sku: v.sku,
gtin: GTIN.test(String(v.gtin || '')) ? String(v.gtin) : undefined,
name: [p.name, ...variesBy.map((k) => v[k])].filter(Boolean).join(', '),
image,
color: variesBy.includes('color') ? clean(v.color) : undefined,
size: variesBy.includes('size') ? clean(v.size) : undefined,
material: variesBy.includes('material') ? clean(v.material) : undefined,
url: o.url,
inProductGroupWithID: p.groupId,
isVariantOf: { '@id': groupId },
offers: o,
});
})
.filter(Boolean);
if (!variants.length) return null;
// Ratings only when there are real reviews behind them.
const rating = p.rating && p.rating.count > 0 && p.rating.value >= 1 && p.rating.value <= 5
? { '@type': 'AggregateRating', ratingValue: p.rating.value, reviewCount: p.rating.count, bestRating: 5, worstRating: 1 }
: undefined;
const group = compact({
'@type': 'ProductGroup',
'@id': groupId,
name: clean(p.name),
description: clean(p.description),
url: pageUrl,
brand: p.brand ? { '@type': 'Brand', name: clean(p.brand) } : undefined,
productGroupID: p.groupId,
variesBy: variesBy.map((k) => VARIES_BY[k]),
aggregateRating: rating,
hasVariant: variants,
});
const crumbs = breadcrumb(page.breadcrumbs, `${pageUrl}#breadcrumb`);
const webpage = compact({
'@type': 'ItemPage',
'@id': `${pageUrl}#webpage`,
url: pageUrl,
name: clean(page.title) || clean(p.name),
isPartOf: { '@id': `${root}/#website` },
breadcrumb: crumbs ? { '@id': crumbs['@id'] } : undefined,
mainEntity: { '@id': groupId },
});
return { '@context': 'https://schema.org', '@graph': [webpage, crumbs, group].filter(Boolean) };
}
{
"site": {
"url": "https://www.example.com"
},
"page": {
"url": "https://www.example.com/jackets/ridgeline-rain-shell/",
"title": "Ridgeline Rain Shell | Harbor & Pine",
"breadcrumbs": [
{
"name": "Home",
"url": "https://www.example.com/"
},
{
"name": "Jackets",
"url": "https://www.example.com/jackets/"
},
{
"name": "Ridgeline Rain Shell",
"url": "https://www.example.com/jackets/ridgeline-rain-shell/"
}
]
},
"product": {
"groupId": "RRS-24",
"name": "Ridgeline Rain Shell",
"description": "A packable three-layer rain jacket with taped seams, pit zips and an adjustable hood.",
"brand": "Harbor & Pine",
"currency": "USD",
"variesBy": [
"color",
"size"
],
"images": [
"https://www.example.com/img/ridgeline-shell-1x1.jpg",
"https://www.example.com/img/ridgeline-shell-4x3.jpg",
"https://www.example.com/img/ridgeline-shell-16x9.jpg"
],
"rating": {
"value": 4.6,
"count": 128
},
"variants": [
{
"sku": "RRS-24-SPR-M",
"gtin": "00812345000017",
"color": "Spruce",
"size": "M",
"price": 189,
"availability": "in_stock",
"images": [
"https://www.example.com/img/ridgeline-spruce.jpg"
],
"memberPrices": [
{
"tier": "member",
"price": 169
}
]
},
{
"sku": "RRS-24-SPR-L",
"gtin": "00812345000024",
"color": "Spruce",
"size": "L",
"price": 189,
"availability": "in_stock",
"images": [
"https://www.example.com/img/ridgeline-spruce.jpg"
],
"memberPrices": [
{
"tier": "member",
"price": 169
}
]
},
{
"sku": "RRS-24-RST-M",
"gtin": "00812345000031",
"color": "Rust",
"size": "M",
"price": 189,
"availability": "out_of_stock",
"images": [
"https://www.example.com/img/ridgeline-rust.jpg"
]
},
{
"sku": "RRS-24-RST-M",
"color": "Rust",
"size": "M",
"price": 189,
"note": "duplicate row from the feed, dropped"
},
{
"sku": "RRS-24-RST-XL",
"color": "Rust",
"size": "XL",
"price": 0,
"note": "no price yet, dropped"
}
]
}
}
{
"@context": "https://schema.org",
"@graph": [
{
"@type": "ItemPage",
"@id": "https://www.example.com/jackets/ridgeline-rain-shell/#webpage",
"url": "https://www.example.com/jackets/ridgeline-rain-shell/",
"name": "Ridgeline Rain Shell | Harbor & Pine",
"isPartOf": {
"@id": "https://www.example.com/#website"
},
"breadcrumb": {
"@id": "https://www.example.com/jackets/ridgeline-rain-shell/#breadcrumb"
},
"mainEntity": {
"@id": "https://www.example.com/jackets/ridgeline-rain-shell/#product-group"
}
},
{
"@type": "BreadcrumbList",
"@id": "https://www.example.com/jackets/ridgeline-rain-shell/#breadcrumb",
"itemListElement": [
{
"@type": "ListItem",
"position": 1,
"name": "Home",
"item": "https://www.example.com/"
},
{
"@type": "ListItem",
"position": 2,
"name": "Jackets",
"item": "https://www.example.com/jackets/"
},
{
"@type": "ListItem",
"position": 3,
"name": "Ridgeline Rain Shell",
"item": "https://www.example.com/jackets/ridgeline-rain-shell/"
}
]
},
{
"@type": "ProductGroup",
"@id": "https://www.example.com/jackets/ridgeline-rain-shell/#product-group",
"name": "Ridgeline Rain Shell",
"description": "A packable three-layer rain jacket with taped seams, pit zips and an adjustable hood.",
"url": "https://www.example.com/jackets/ridgeline-rain-shell/",
"brand": {
"@type": "Brand",
"name": "Harbor & Pine"
},
"productGroupID": "RRS-24",
"variesBy": [
"https://schema.org/color",
"https://schema.org/size"
],
"aggregateRating": {
"@type": "AggregateRating",
"ratingValue": 4.6,
"reviewCount": 128,
"bestRating": 5,
"worstRating": 1
},
"hasVariant": [
{
"@type": "Product",
"@id": "https://www.example.com/jackets/ridgeline-rain-shell/#sku-RRS-24-SPR-M",
"sku": "RRS-24-SPR-M",
"gtin": "00812345000017",
"name": "Ridgeline Rain Shell, Spruce, M",
"image": [
"https://www.example.com/img/ridgeline-spruce.jpg"
],
"color": "Spruce",
"size": "M",
"url": "https://www.example.com/jackets/ridgeline-rain-shell/?color=spruce&size=m",
"inProductGroupWithID": "RRS-24",
"isVariantOf": {
"@id": "https://www.example.com/jackets/ridgeline-rain-shell/#product-group"
},
"offers": {
"@type": "Offer",
"url": "https://www.example.com/jackets/ridgeline-rain-shell/?color=spruce&size=m",
"price": 189,
"priceCurrency": "USD",
"priceSpecification": [
{
"@type": "UnitPriceSpecification",
"price": 169,
"priceCurrency": "USD",
"validForMemberTier": {
"@id": "https://www.example.com/#tier-member"
}
}
],
"availability": "https://schema.org/InStock",
"itemCondition": "https://schema.org/NewCondition",
"seller": {
"@id": "https://www.example.com/#organization"
}
}
},
{
"@type": "Product",
"@id": "https://www.example.com/jackets/ridgeline-rain-shell/#sku-RRS-24-SPR-L",
"sku": "RRS-24-SPR-L",
"gtin": "00812345000024",
"name": "Ridgeline Rain Shell, Spruce, L",
"image": [
"https://www.example.com/img/ridgeline-spruce.jpg"
],
"color": "Spruce",
"size": "L",
"url": "https://www.example.com/jackets/ridgeline-rain-shell/?color=spruce&size=l",
"inProductGroupWithID": "RRS-24",
"isVariantOf": {
"@id": "https://www.example.com/jackets/ridgeline-rain-shell/#product-group"
},
"offers": {
"@type": "Offer",
"url": "https://www.example.com/jackets/ridgeline-rain-shell/?color=spruce&size=l",
"price": 189,
"priceCurrency": "USD",
"priceSpecification": [
{
"@type": "UnitPriceSpecification",
"price": 169,
"priceCurrency": "USD",
"validForMemberTier": {
"@id": "https://www.example.com/#tier-member"
}
}
],
"availability": "https://schema.org/InStock",
"itemCondition": "https://schema.org/NewCondition",
"seller": {
"@id": "https://www.example.com/#organization"
}
}
},
{
"@type": "Product",
"@id": "https://www.example.com/jackets/ridgeline-rain-shell/#sku-RRS-24-RST-M",
"sku": "RRS-24-RST-M",
"gtin": "00812345000031",
"name": "Ridgeline Rain Shell, Rust, M",
"image": [
"https://www.example.com/img/ridgeline-rust.jpg"
],
"color": "Rust",
"size": "M",
"url": "https://www.example.com/jackets/ridgeline-rain-shell/?color=rust&size=m",
"inProductGroupWithID": "RRS-24",
"isVariantOf": {
"@id": "https://www.example.com/jackets/ridgeline-rain-shell/#product-group"
},
"offers": {
"@type": "Offer",
"url": "https://www.example.com/jackets/ridgeline-rain-shell/?color=rust&size=m",
"price": 189,
"priceCurrency": "USD",
"availability": "https://schema.org/OutOfStock",
"itemCondition": "https://schema.org/NewCondition",
"seller": {
"@id": "https://www.example.com/#organization"
}
}
}
]
}
]
}
The output above was run through the SchemaCDN validator, which checks every rule in Google's documentation for each feature. It was validated together with the homepage graph, so the @id links resolve. Result: no errors, and eligible for:
The anchor every other page type points at: Organization, site name and store-wide policies.
Article with a real Person author, the Organization as publisher, video and a paywalled section.
One store location with address, geo and hours, linked to its parent brand.
Recipe with timed steps, nutrition, ratings and a video whose key moments match the steps.
One open role with pay, closing date and work location, linked to the employer and the store.
An in-store workshop with times, a rescheduled date, the store as venue and a free ticket.
A community thread that becomes Q&A or a discussion posting, depending on the thread.