Magento 2 Rich Snippets: How to Set Up Product Schema That Validates
Magento 2 rich snippets, done by hand: Google's required Product properties, a JSON-LD block mapped to Magento data, and fixes for duplicate microdata.

Magento 2 rich snippets come from structured data Google can read on the product page: a Product with an Offer, ideally written in JSON-LD. Valid markup makes the page eligible. Google decides whether the stars, price and stock line actually appear. Luma and Hyva stores already ship part of this markup through the theme, and a Product block added by an extension arrives with values maintained by different code. When the two disagree, for example a theme price printed with tax while the JSON-LD carries the price before tax, one page states two prices for one product.
Disclosure: OCM Labs builds SEO Suite, a Magento module that outputs this markup. Everything below works without it.
What does Google require: product snippets or merchant listings?
Build to the merchant listing requirements. Google splits Product markup into two experiences: product snippets, for pages that describe or review a product, and merchant listings, for pages where the shopper can buy it. A Magento product page is a merchant listing page. Meeting the merchant listing requirements can also make the same page eligible for product snippets, so one well-built block covers both.
The table follows Google's merchant listing documentation and its product snippet counterpart. "Not required" means Google does not list the property as required for that experience.
| PRODUCT SNIPPETS | MERCHANT LISTINGS | SEO SUITE EMITS | |
|---|---|---|---|
| NAME | Required | Required | Yes |
| IMAGE | Not required | Required | Yes |
| OFFERS | Required unless review or aggregateRating is present | Required, as Offer (not AggregateOffer) | Yes, Offer |
| OFFERS.PRICE | Required when offers is used | Required, greater than zero | Yes |
| OFFERS.PRICECURRENCY | Recommended | Required, ISO 4217 three-letter code | Yes |
| REVIEW | Required unless offers or aggregateRating is present | Recommended | Yes |
| AGGREGATERATING | Required unless offers or review is present | Recommended | Yes |
| BRAND, GTIN, MPN, SKU, DESCRIPTION | Not required | Recommended | Yes (gtin and mpn once mapped) |
| COLOR, SIZE | Not required | Recommended | No |
| OFFERS.AVAILABILITY | Recommended | Recommended | Yes |
| OFFERS.URL | Not required | Recommended | Yes |
| OFFERS.HASMERCHANTRETURNPOLICY | Not required | Recommended | No |
| OFFERS.ITEMCONDITION | Not required | Recommended | No |
| OFFERS.PRICEVALIDUNTIL | Recommended | Recommended | No |
| OFFERS.SHIPPINGDETAILS | Not required | Recommended | No |
Four rules in that table trip up Magento stores. Only Offer, not AggregateOffer, qualifies for merchant listings and for the price-drop enhancement. A zero price disqualifies the page, so a free sample product cannot be a merchant listing. A priceValidUntil date in the past may suppress the snippet. And category and listing pages are not supported for Product rich results at all, so Product markup on your category template earns nothing.
What does Magento already output on a product page?
On Luma, the core product page layout sets itemscope and an itemtype pointing at Product on the <body> element, and itemprop attributes sit on the name, SKU, price and similar fields further down the page. Hyva's product templates carry Product microdata too, and Hyva also renders its own BreadcrumbList JSON-LD. What neither theme can give you is a GTIN or an MPN, because Magento has no standard attribute for either.
So Magento structured data on a stock install is real but partial. The price itemprop prints whatever the price renderer outputs for display, which can include tax, so it will not always match a JSON-LD price computed somewhere else. None of that is wrong on its own. The problem arrives when someone adds a JSON-LD Product on top: the page now describes one product twice, and two different codebases maintain the two descriptions.
Site-level types are a separate job. BreadcrumbList belongs on product and category pages, and category pages can carry breadcrumbs even though they cannot earn Product rich results. Organization and WebSite belong on the home page. The Magento SEO guide covers the on-page work that sits around this markup.
How do you add Magento 2 rich snippets as a JSON-LD block?
Use one script with one @graph, holding the Product and its BreadcrumbList side by side. The values below are illustrative, an invented jacket on example.com, but every property name is real and every format is the one Google checks: price as a plain number string with no currency symbol, an ISO 4217 currency code, availability as a full schema.org URL, and absolute image URLs.
{
"@context": "https://schema.org",
"@graph": [
{
"@type": "Product",
"@id": "https://www.example.com/trail-running-jacket.html#product",
"name": "Trail Running Jacket",
"sku": "TRJ-100",
"description": "Lightweight waterproof shell with taped seams and a packable hood.",
"image": [
"https://www.example.com/media/catalog/product/t/r/trj-100-front.jpg",
"https://www.example.com/media/catalog/product/t/r/trj-100-back.jpg"
],
"brand": {
"@type": "Brand",
"name": "Example Outdoor"
},
"gtin": "0123456789012",
"mpn": "EO-TRJ-100",
"offers": {
"@type": "Offer",
"price": "89.00",
"priceCurrency": "USD",
"availability": "https://schema.org/InStock",
"url": "https://www.example.com/trail-running-jacket.html"
},
"aggregateRating": {
"@type": "AggregateRating",
"ratingValue": "4.5",
"bestRating": "5",
"worstRating": "1",
"reviewCount": 12
},
"review": [
{
"@type": "Review",
"name": "Dry on a long wet run",
"author": {
"@type": "Person",
"name": "Dana R."
},
"reviewRating": {
"@type": "Rating",
"ratingValue": "5",
"bestRating": "5",
"worstRating": "1"
},
"datePublished": "2026-09-14",
"reviewBody": "Held up through steady rain and packs into its own pocket."
}
]
},
{
"@type": "BreadcrumbList",
"itemListElement": [
{
"@type": "ListItem",
"position": 1,
"name": "Home",
"item": "https://www.example.com/"
},
{
"@type": "ListItem",
"position": 2,
"name": "Jackets",
"item": "https://www.example.com/jackets.html"
},
{
"@type": "ListItem",
"position": 3,
"name": "Trail Running Jacket",
"item": "https://www.example.com/trail-running-jacket.html"
}
]
}
]
}Render it on the server, in the page head, as part of the HTML Magento sends. Google advises putting Product markup in the initial HTML rather than injecting it with JavaScript, and on Magento a server-rendered block is also cached with the page.
Every value in the block has a home in Magento:
| MAGENTO SOURCE | NOTE | |
|---|---|---|
| NAME | Product name attribute | Match the visible product title |
| SKU | Product sku | |
| DESCRIPTION | description attribute, falling back to short_description | Strip the HTML first |
| IMAGE | Base image, then gallery images | Absolute media URLs only |
| BRAND | An attribute you choose, usually manufacturer | Output as a Brand with a name |
| GTIN | A custom attribute you create | Magento has no standard GTIN attribute |
| MPN | A custom attribute you create | No standard MPN attribute either |
| OFFERS.PRICE | The final price; for configurable, grouped and bundle products, the minimal final price | Must match the price the shopper sees |
| OFFERS.PRICECURRENCY | The store view's current currency code | |
| OFFERS.AVAILABILITY | Whether the product is salable | InStock or OutOfStock at minimum |
| OFFERS.URL | The product URL | |
| AGGREGATERATING | Approved Magento_Review ratings in that store view | Magento stores ratings as a percent; divide by 20 for a 1-5 value |
| REVIEW | The newest approved reviews, nickname as the Person author | Only reviews visible on the page |
| BREADCRUMBLIST | The category path Magento resolved for the visible breadcrumbs | Home, categories, then the product |
Reviews are where hand-built blocks drift out of line with Google's rules. Google requires markup to reflect content the shopper can see on the page, so a rating hidden with display:none, or reviews pulled from another store view, do not belong in the block. Decide what to do with reviews that have no rating vote or no nickname; skipping them keeps every Review object complete. And if a product has no valid offer, no rating and no review, output no Product at all, because Google rejects a Product carrying none of the three.
To build it in your own theme:
- Create
gtinandmpnproduct attributes, and decide which attribute holds the brand. - Write a template that prints the
@graphabove, filling each property from the source in the table. - Add that template as a block in
head.additional, in your theme'sMagento_Catalog/layout/catalog_product_view.xml. - Run a live product URL through the Rich Results Test and clear every error before you look at the warnings.
Merchant listing extras: where does Magento keep them?
Magento has a native source for one of the four recommended Offer extras. The other three need a custom attribute, configuration-derived logic or a Merchant Center feed:
priceValidUntilcomes from the special price end date,special_to_date. Output it only while a special price with an end date is active, and never output a past date, since Google says an expiredpriceValidUntilmay suppress the snippet.itemConditionhas no native Magento attribute. Create one, or hardcodehttps://schema.org/NewConditionif the store sells only new goods.shippingDetailshas no product-level native field. You derive it from your shipping configuration, or you supply shipping through a Merchant Center feed.hasMerchantReturnPolicyhas no native field either. Google recommends nesting the return policy under Organization instead of repeating it on every product, and Merchant Center feeds take precedence over markup.
If you already run a Merchant Center feed, put shipping and returns there and leave them out of the product page markup. The objection is that markup costs nothing extra once the template exists. It does cost something: shipping rules change, and a template that computes shipping from configuration is one more thing to keep in step with checkout, for a value the feed overrides anyway.
Price and availability carry more risk than any of the extras. The marked-up price has to be the final price the shopper sees, after any special price or catalog price rule, in the currency the page displays. A product that is not salable should say OutOfStock, not keep its last known state from a cached template.
How should configurable products and variants be marked up?
Google supports variant markup through ProductGroup for both product snippets and merchant listings. The configurable becomes a ProductGroup with a productGroupID, variesBy names the attributes that differ (such as https://schema.org/color and https://schema.org/size), and hasVariant lists one Product per child, each with its own sku, gtin and Offer. Google's product variant documentation covers the single-page and multi-page cases. Do not use AggregateOffer to describe variants.
Build the ProductGroup when the children differ in something Google can show: price, stock or GTIN. If every child of a configurable shares the same price and none carries its own GTIN, one Product with one Offer describes the page truthfully. The case against bothering is the render cost of loading every child's data into the parent page, which is real on a configurable with hundreds of children. Variant markup is still how Google's documentation tells a red medium apart from a blue large, so a catalog that sells by size and color pays that cost.
SEO Suite does not output ProductGroup, hasVariant or variesBy. A configurable product gets one Offer at its lowest final price. If variant-level detail matters for your catalog, that part is a template you write yourself.
How do you fix duplicate Product markup from theme microdata and extension JSON-LD?
When Magento schema markup comes from two sources, the Rich Results Test lists two Product items for one URL.
Detect. View the source of a product page and search for schema.org/Product, which finds the microdata itemtype, then for application/ld+json, and open each script to look for "@type": "Product". This command counts Product microdata scopes and every JSON-LD script on one URL, so a JSON-LD count above one still needs each script opened to see which hold a Product; run it in a loop over a file of URLs to check a sample of the catalog:
curl -sL https://www.example.com/trail-running-jacket.html \
| grep -o -e 'itemtype="[^"]*schema.org/Product"' -e 'application/ld[+]json' \
| sort | uniq -cThen run the URL through the Rich Results Test, which lists each item it detects. Two Product entries under merchant listings means two descriptions.
Decide. Google reads microdata and JSON-LD on the same page, so two descriptions with matching values are not the emergency. Disagreement is, and so is a second JSON-LD emitter.
| WHAT THE RICH RESULTS TEST SHOWS | ACTION | |
|---|---|---|
| THEME MICRODATA AND ONE JSON-LD BLOCK CARRY THE SAME NAME, PRICE AND CURRENCY | Two valid Product items with matching values | Leave both in place |
| THE PRICES DISAGREE: ONE SIDE INCLUDES TAX AND THE OTHER DOES NOT, OR A JSON-LD BLOCK FROM A SECOND EXTENSION CARRIES A STALE OR BASE-CURRENCY PRICE | Two Product items with different price or priceCurrency values | Fix the JSON-LD template so it prints the price and currency the shopper sees |
| A JSON-LD PRODUCT HAS NO PRICECURRENCY | A merchant listings error on that item | Fix the template that prints it |
| THE MICRODATA PRODUCT LACKS A REQUIRED PROPERTY SUCH AS IMAGE | A separate Product item with errors | Strip the theme microdata, as below |
| TWO EXTENSIONS EACH PRINT A JSON-LD PRODUCT | Two JSON-LD Product items, even with matching values | Keep one emitter and turn Product output off in the other |
Fix. Run exactly one JSON-LD emitter: turn off Product output in one extension, or remove it. Leave the theme microdata alone unless the Rich Results Test shows it as a separate item with errors, because a template override is code you then carry through every upgrade.
When you do strip it on Luma, the body-attribute removal below is the whole fix, because removing the Product scope dissolves the item. Leftover itemprop attributes come from layout arguments in core's catalog_product_view.xml (name, SKU and description), from price/amount/default.phtml, from the product gallery image markup in product/view/gallery.phtml, and from the review summary and review list microdata in Magento_Review's helper/summary.phtml and product/view/list.phtml. You only need to override those if you want the attributes gone entirely. On Hyva, check breadcrumbs as well, since Hyva's own BreadcrumbList JSON-LD plus another one is the same problem one type over.
On Luma, core's catalog_product_view.xml sets the Product scope on <body>, and an empty redeclaration removes it. Redeclare the body attributes itemtype and itemscope with an empty value, which Magento's page config treats as a removal. In your theme's Magento_Catalog/layout/catalog_product_view.xml:
<?xml version="1.0"?>
<page xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="urn:magento:framework:View/Layout/etc/page_configuration.xsd">
<body>
<attribute name="itemtype" value=""/>
<attribute name="itemscope" value=""/>
</body>
</page>Deploy it, flush the cache and view the source again: the <body> tag should carry no itemscope or itemtype.
Does any of this change between Luma and Hyva, or across Magento versions?
On Hyva the layout mechanism is the same as on Luma, but Hyva already outputs BreadcrumbList JSON-LD, so a second breadcrumb emitter means switching one off. SEO Suite handles that through its Hyva companion package, ocmlabs/module-seo-hyva, which turns Hyva's breadcrumb JSON-LD off while SEO Suite's structured data is on and hands it back when that output is off.
Google's property requirements do not depend on your Magento version. The statements about SEO Suite apply to the module's supported range: Magento Open Source and Adobe Commerce on versions 2.4.7 to 2.4.9, PHP 8.2 to 8.4, with Adobe Commerce support through ocmlabs/module-seo-commerce. The SEO Suite installation docs list both companion packages.
How do you validate and monitor product rich results?
Test one product of each type, simple, configurable and bundle, on each theme and store view you run. Then work through four checks:
- Run the live URL through the Rich Results Test. Fix errors first, then the warnings on recommended properties you can actually fill. The test checks syntax and required properties; it cannot tell you whether the markup breaks Google's guidelines, such as rating markup for reviews nobody can see.
- Use URL Inspection in Search Console to confirm Google can fetch the page and sees the markup.
- Watch the Merchant listings and Product snippets reports in Search Console over the following days, since they show valid and invalid items across the whole site.
- Check the Unparsable structured data report, which catches syntax errors such as a template that printed an empty value and left a stray comma.
Allow days, not hours, for Google to crawl and reprocess the pages. Valid markup makes a page eligible for a rich result. Whether one shows is Google's decision, and no tool or module changes that. Structured data is one area of a full store audit, and the Magento SEO checklist covers the rest.
How SEO Suite outputs this markup
SEO Suite writes one application/ld+json script into the head of every page, as a single @graph under the schema.org context. It is rendered on the server and stored in the full page cache with the rest of the HTML, so it is in the initial HTML Google fetches. On a product page the graph holds a Product and a BreadcrumbList shaped like the block above; category pages get the BreadcrumbList; the home page gets Organization (name, URL, logo and profile URLs) and a WebSite with a SearchAction pointing at the catalog search. That SearchAction describes the site search and nothing more.
Brand, GTIN and MPN come from attributes you map by attribute code, not label, under Stores > Configuration > OCM Labs > SEO > Structured Data. Brand defaults to manufacturer, and GTIN and MPN stay out of the block until you map them. Changes to those settings need a full page cache flush. A product with no valid offer, rating or review gets no Product node, and review data covers up to the five newest approved reviews.
The 58-second demo shows the Product and BreadcrumbList output on a product page, with brand, GTIN, MPN, offer and review data, then Organization and WebSite on the home page.
What the module does not do:
- It does not remove theme microdata. Luma's and Hyva's Product microdata stays alongside the JSON-LD.
- It does not output
ProductGroup,hasVariantorvariesBy, and it never usesAggregateOffer. - It does not output
shippingDetails,hasMerchantReturnPolicy,itemConditionorpriceValidUntil. - It sets availability to
InStockorOutOfStockonly, neverBackOrderorPreOrder. - It does not output FAQPage markup, which comes from the OCM Labs FAQ module (FAQ module docs).
- It shows no Search Console data inside Magento.
- It cannot make Google show a rich result, and it predicts nothing about rankings.
If another extension already outputs Product or Organization JSON-LD, turn off Enable JSON-LD in one of them, or retire the other, before running both. When you are weighing which one to keep, the guide to what to check in a Magento SEO extension covers the questions to ask. The full settings table is in the structured data documentation.
A hand-built block is one more template to keep in step with your theme, your price rules and every Magento upgrade. If you would rather not maintain it, SEO Suite outputs the Product, Offer, review and BreadcrumbList markup shown here, without the variant and merchant listing extras. It costs $250 for Magento Open Source and $500 for Adobe Commerce.
✦ FAQ
What is the difference between schema markup and structured data?+–
What is an example of product schema markup?+–
Does Magento add product structured data by default?+–
Why is Google not showing rich snippets when my markup is valid?+–
Should I use JSON-LD or microdata on Magento?+–
Do rich snippets help SEO or rankings?+–

Shane Blandford
Founder of Orange Collar Media, a Denver ecommerce agency behind 800+ Magento and Shopify builds. In Magento since version 1.x - building, rescuing, and supporting revenue-critical stores since 2008.
✦ KEEP READING
Related from the Journal.
Magento SEO Checklist for 2.4.x: Severity, Admin Path and a Verify Step for Every Item
A Magento SEO checklist for 2.4.x stores: 50 items, each with a severity, the admin path or bin/magento command, and a way to verify it passes.

What a Magento 2 SEO Extension Should Do, and How to Test One
What a Magento SEO extension should do as you edit: a live 0-100 score, an 18-check list with fix hints, a Google preview, and a buyer's test for demos.

Magento SEO: The Technical Setup Guide for 2.4.7 to 2.4.9
Magento SEO, set up path by path: canonical and robots settings, layered navigation rules, hreflang, JSON-LD and a Search Console index-bloat check.
