MODRACXKENNETH D'SILVA

← Archive & Insights

Structured Data Implementation for E-commerce SEO

Review stars vanished from 11,400 product pages overnight and nobody had deployed anything. Here is what Google actually requires from Product and Offer markup, and the four separate ways a rich result gets taken away.

By Kenneth D'SilvaReading Time: 29 min readCategory: SEO & Marketing

1. The Wednesday the Stars Vanished

A camping equipment retailer I work with lost review stars on 11,400 product pages between one Tuesday and the following Wednesday. Nobody deployed anything. Search Console's Merchant listings report went from 11,400 valid items to 11,400 items flagged "Invalid object type for field 'review'", and the average click-through rate on their branded-plus-model queries fell from 4.9% to 3.1% over nine days. On a catalogue that turns over roughly £340,000 a month through organic search, that is not a rounding error.

The cause was a plugin update. A reviews app had changed the shape of the JSON it wrote, nesting a review array under an object rather than emitting it as an array of Review nodes. Google had been tolerating a slightly wrong structure for two years and stopped tolerating it. The markup still validated as JSON. It still validated as Schema.org — Schema.org validates almost anything. It failed only against Google's own rich results requirements, which are stricter, narrower, and documented in a different place from the vocabulary itself.

That gap is the single most useful thing to understand about product structured data. There are three separate rulebooks in play: the Schema.org vocabulary, which tells you what properties exist; Google's structured data documentation, which tells you which subset earns a rich result; and Google's search and merchant policies, which tell you when a rich result gets taken away regardless of whether the markup is correct. Most implementations satisfy the first and assume the other two follow. They do not.

This article is about the product page specifically — Product, Offer, AggregateRating, Review, and the merchant listing extensions that sit on top of them. The site-wide furniture — breadcrumbs, organisation identity, sitewide entity graphs, FAQ and HowTo, validation pipelines — lives in its companion piece on technical SEO and site-wide structured data, and I will point at it rather than duplicating it here. If you are trying to work out where your BreadcrumbList should live or how to stop your template emitting two copies of the same block, start there. If your problem is a product page that will not produce a price, a rating, or a Merchant Center listing, stay here.

2. What Google Actually Reads on a Product Page

When Googlebot renders a product URL, several extraction systems run over the result and they do not all want the same thing.

The rich results pipeline wants a Product node it can use to decorate the blue link — price, availability, rating, sometimes shipping. The Merchant Center feed reconciliation system wants to match what it finds on the page against the item in your product feed, and it will suppress or disapprove the feed item if they disagree. The Shopping graph — the thing behind product panels and the "Prices" tab — wants an identifier stable enough to merge your listing with the same item sold by four other retailers. And the ordinary indexing pipeline reads the visible HTML and quietly compares it with what your JSON-LD claims.

Those four systems have different tolerances. Rich results will forgive a missing GTIN. Merchant Center will not forgive a price that disagrees with the feed by more than a few pence. The Shopping graph will merge your product with a competitor's if the GTIN matches and will treat it as a distinct item if it does not, which is sometimes what you want and sometimes catastrophic.

So "is my structured data valid?" is the wrong question. The useful question is "which of the four systems am I trying to satisfy, and what does each of them need?" A brand selling only its own manufactured goods with no Merchant Center account has a genuinely simpler job than a distributor listing 40,000 third-party SKUs against a competitive Shopping graph. I have seen the second case spend six weeks on GTIN reconciliation and the first case ship a correct implementation in a day.

The visible-content rule

Every one of Google's structured data policies contains a version of the same sentence: the marked-up content must be visible to the user on the page. This is not decorative legalese. It is the rule most commonly broken, usually accidentally, and it is the rule under which rich results get revoked without a manual action ever appearing.

Concretely: if your JSON-LD carries an aggregateRating of 4.7 from 231 reviews and the page renders reviews in a lazily-loaded tab that never fires on a bare fetch, you are marking up content that is not on the page as far as an extraction system is concerned. If your price reflects the logged-in trade price and anonymous visitors see a higher number, same problem. If availability says InStock and the buy button says "Notify me", same problem, and this one also puts your Merchant Center account at risk.

3. The Minimum Product Node That Actually Earns a Rich Result

Here is what I ship as the floor on a straightforward single-variant product. Everything in it is required or near-required; nothing is speculative.

{
  "@context": "https://schema.org",
  "@type": "Product",
  "@id": "https://shop.example.com/products/oak-lamp-classic#product",
  "name": "Classic Oak Table Lamp",
  "description": "Turned solid oak base with a natural linen shade. 42cm tall.",
  "sku": "LMP-OAK-CL-42",
  "gtin13": "5060337821094",
  "mpn": "OAK-CL-42",
  "brand": { "@type": "Brand", "name": "Alderwood" },
  "image": [
    "https://shop.example.com/media/lmp-oak-cl-42-1x1.jpg",
    "https://shop.example.com/media/lmp-oak-cl-42-4x3.jpg",
    "https://shop.example.com/media/lmp-oak-cl-42-16x9.jpg"
  ],
  "offers": {
    "@type": "Offer",
    "url": "https://shop.example.com/products/oak-lamp-classic",
    "priceCurrency": "GBP",
    "price": "89.00",
    "priceValidUntil": "2027-03-31",
    "availability": "https://schema.org/InStock",
    "itemCondition": "https://schema.org/NewCondition",
    "seller": { "@type": "Organization", "name": "Alderwood Home" }
  }
}

A few decisions in there are deliberate and worth defending.

Three images in three aspect ratios. Google asks for 1x1, 4x3 and 16x9 and will crop if you supply one. Supplying all three means you control the crop, which on a lamp with a narrow base is the difference between a usable thumbnail and a picture of a shade. This costs you one line in a template and I have never regretted it.

price as a string, not a number. Schema.org accepts both. JSON numbers with trailing zeros get normalised by serialisers — 89.00 becomes 89 in most JSON encoders, and 1099.90 can become 1099.9. Neither is wrong, but the string form is what your feed contains and matching matters more than elegance. Never include a currency symbol or a thousands separator inside price; "£1,099.00" is invalid and Google will drop the offer.

@id with a fragment. That anchor is how every other node on the page refers to this one without repeating it. It matters enormously once you have breadcrumbs, an organisation node, and a webpage node to wire together, which is the subject of the companion article.

priceValidUntil. Optional and frequently misunderstood. It does not mean the price expires; it means Google may treat the offer as stale after that date and drop the price from the rich result. Set it far enough forward that a forgotten cron job does not silently kill your price display. I use "today plus twelve months" computed at render time rather than a hardcoded date, because hardcoded dates always outlive the developer who typed them.

4. Availability Is a Promise, Not a Label

Of all the fields on an Offer, availability is the one that gets people into actual trouble, because it is the one Merchant Center enforces hardest and the one most likely to drift from reality.

The enumeration is longer than most implementations use:

ValueMeansWhen I use it
InStockAvailable now, ships from held stockDefault for anything with positive sellable quantity
OutOfStockNot purchasable at allZero stock, no backorder path
BackOrderPurchasable now, ships when restockedBuy button live, dispatch date in future
PreOrderPurchasable before general releaseNeeds a release date to be honest
LimitedAvailabilityLow stock, may sell outRarely — it buys nothing over InStock
SoldOutPermanently gone, not restockingDiscontinued lines still worth keeping indexed
InStoreOnlyPurchasable in a physical shop onlyClick-and-collect-only lines
OnlineOnlyNot available in physical storesAlmost never worth stating

The distinction that costs money is OutOfStock versus BackOrder. If a customer can complete checkout, the item is not out of stock, and marking it OutOfStock hides it from Shopping surfaces you could be selling on. If a customer cannot complete checkout, marking it InStock is a policy violation that, repeated across a catalogue, is how accounts get suspended for misrepresentation.

The mechanical problem is latency. Your JSON-LD is rendered with the page; your page is cached; your stock levels change every few minutes. On a full-page-cached Magento install with a two-hour TTL, a product that sells out at 09:05 will keep announcing InStock until something invalidates the cache. Google recrawls popular product pages often enough that this is not theoretical.

Three fixes, in ascending order of how much I like them:

Punch a hole in the cache and fill availability from the same private-content request that fills your cart and price blocks. This is correct and it is also the one that most often ends up shipping structured data that is invisible to a crawler, because crawlers do not execute your private-content fetch reliably. I have shipped this and regretted it.

Invalidate the page on stock transitions rather than on every stock movement. You do not need to re-render because quantity went from 40 to 39. You need to re-render when it crosses a threshold that changes the availability value. On the homeware client this reduced cache invalidations by about 94% compared with the naive approach.

Or accept a short, bounded lie and shorten the TTL on product pages specifically. If you sell furniture with four-week lead times, a two-hour stale availability flag harms nobody. If you sell limited-run trainers, it will get you complaints. Know which business you are in.

5. Price Accuracy, and the Traps Around It

Price is the field customers see and the field Merchant Center reconciles most aggressively. A mismatch between your feed price, your marked-up price, and your displayed price is the most common cause of item disapproval I encounter, and the causes are boringly consistent.

Tax

In the UK and EU, consumer-facing prices include VAT and your markup should carry the VAT-inclusive figure, because that is what the customer pays. In the US, prices exclude sales tax. On a store that serves both from one codebase — and most do — the price rendered into JSON-LD must follow the same tax display rules as the visible price for that store view. I have seen a multi-store Magento setup emit the ex-VAT price into the schema on every store view because the block called getPrice() instead of getFinalPrice() through the tax helper. Every UK listing was 20% cheaper in search than at checkout. That is a bait-and-switch as far as policy is concerned, regardless of intent.

Customer-group pricing

Trade customers see one price, retail another. Your markup must reflect what an anonymous visitor sees, because that is who the crawler is. If your template renders the current session's price, a logged-in editor previewing the page can bake a trade price into a cached variant. Render structured data from the guest price path explicitly, not from whatever the current customer context happens to hold.

Multi-currency

priceCurrency must be a three-letter ISO 4217 code and it must match the currency the offer is actually transactable in for that URL. If you serve GBP and EUR from the same URL by geo-switching, you have a bigger problem than schema: you have one URL with two prices, which no structured data can honestly express. Separate URLs per currency, with correct hreflang, or a single canonical currency. I would pick separate URLs almost every time.

Ranges and "from" prices

A configurable product with variants from £89 to £149 should not emit a single Offer with price: "89.00". Use AggregateOffer:

{
  "@type": "Product",
  "name": "Classic Oak Table Lamp",
  "offers": {
    "@type": "AggregateOffer",
    "priceCurrency": "GBP",
    "lowPrice": "89.00",
    "highPrice": "149.00",
    "offerCount": 6,
    "availability": "https://schema.org/InStock",
    "offers": [
      { "@type": "Offer", "sku": "LMP-OAK-CL-42", "price": "89.00",
        "priceCurrency": "GBP", "availability": "https://schema.org/InStock",
        "url": "https://shop.example.com/products/oak-lamp-classic?v=42" },
      { "@type": "Offer", "sku": "LMP-OAK-CL-60", "price": "149.00",
        "priceCurrency": "GBP", "availability": "https://schema.org/OutOfStock",
        "url": "https://shop.example.com/products/oak-lamp-classic?v=60" }
    ]
  }
}

AggregateOffer gets you a price range in the rich result and is honest about what the page sells. What it does not get you is a Merchant Center listing per variant, which is the next problem.

6. Variants: ProductGroup, ProductModel and the SKU Question

Variant modelling is where product schema stops being a fifteen-minute job. Google introduced ProductGroup to express the relationship properly, and it is genuinely better than the AggregateOffer workaround, but only if your URL structure supports it.

The model is: a ProductGroup node representing the family, with hasVariant pointing at individual Product nodes, and variesBy naming the axes of variation.

{
  "@context": "https://schema.org",
  "@type": "ProductGroup",
  "@id": "https://shop.example.com/products/linen-shirt#group",
  "name": "Washed Linen Shirt",
  "description": "Garment-washed European linen, relaxed fit.",
  "brand": { "@type": "Brand", "name": "Alderwood" },
  "productGroupID": "SHIRT-LIN-WASH",
  "variesBy": ["https://schema.org/color", "https://schema.org/size"],
  "hasVariant": [
    {
      "@type": "Product",
      "sku": "SHIRT-LIN-WASH-ECRU-M",
      "gtin13": "5060337822091",
      "name": "Washed Linen Shirt, Ecru, Medium",
      "color": "Ecru",
      "size": "M",
      "image": "https://shop.example.com/media/shirt-ecru-1x1.jpg",
      "offers": {
        "@type": "Offer",
        "url": "https://shop.example.com/products/linen-shirt?colour=ecru&size=m",
        "priceCurrency": "GBP",
        "price": "78.00",
        "availability": "https://schema.org/InStock",
        "itemCondition": "https://schema.org/NewCondition"
      }
    }
  ]
}

Two things make or break this. First, productGroupID must be stable and must match the item group ID in your Merchant Center feed. If they disagree, feed and page do not reconcile and you get the worst of both. Second, every variant needs a distinct, crawlable url. If your variant selector is pure client-side state with no URL change, there is no distinct offer URL to give, and ProductGroup buys you very little over AggregateOffer.

My rule: if variants have distinct URLs, model them as ProductGroup with hasVariant. If they do not, either fix the URLs — which is worth doing for reasons that have nothing to do with schema — or use AggregateOffer and stop pretending.

Identifiers, and when they help

The identifier trio is gtin (or the length-specific gtin8/gtin12/gtin13/gtin14), mpn, and brand. Google's guidance is that you supply the identifiers your product actually has. That last clause matters. Inventing a GTIN, reusing a supplier's GTIN for a bundle you assembled, or padding a UPC to thirteen digits incorrectly are all worse than omitting the field.

Reasoning about whether you want a GTIN match at all is a commercial decision more than a technical one. If you resell a widely stocked item and your price is competitive, matching into the Shopping graph puts you in a price comparison you can win. If your price is 15% above the market, that same match puts you at the bottom of a list. I have advised a specialist retailer to keep supplying accurate GTINs anyway, because suppressing them to hide from comparison is both futile and against policy — but I have also watched them lose click share for eight months while they fixed their buying. Structured data does not change your commercial position; it makes it legible.

7. AggregateRating: The Rules Nobody Reads

Review stars are the most visually valuable rich result on a product page and the most heavily policed. The markup is trivial. The policy is not.

{
  "@type": "Product",
  "name": "Classic Oak Table Lamp",
  "aggregateRating": {
    "@type": "AggregateRating",
    "ratingValue": "4.7",
    "bestRating": "5",
    "worstRating": "1",
    "ratingCount": 231,
    "reviewCount": 188
  }
}

ratingCount is how many people gave a score. reviewCount is how many wrote something. They are usually different and conflating them is the most common structural error I find. You need at least one of the two; supplying both when you have both is more honest and I have never seen it hurt.

Now the rules that actually get enforced.

The rating must be visible on the page. Not in a tab that loads via XHR after interaction. Not in an iframe from your reviews vendor. If a crawler fetching the URL with JavaScript disabled cannot find "4.7" and "231", you are on thin ice. Several major review platforms default to an iframe widget and then supply JSON-LD via their own script — that combination has been the direct cause of at least three "stars disappeared" investigations I have run.

Self-serving reviews are not eligible. Google's policy is explicit: reviews about the seller, collected or hosted by the seller about themselves, do not qualify for the Product review snippet. A five-star "great service, fast delivery" review attached to a lamp is a review of you, not the lamp. Aggregating your Trustpilot merchant score onto every product page is the single most common deliberate abuse in this space, and it is exactly what the "Invalid object type" wave tends to catch.

Ratings must not be aggregated across unrelated products. Rolling a category-level average onto every SKU in the category is not permitted. Neither is showing the parent's rating on a variant that has none — though the reverse, showing an aggregate of all variants on the parent, is fine and correct.

You need a real minimum. There is no published numeric threshold, but a product with one review displaying a full five stars looks like manipulation and behaves like it. I set a floor: no aggregateRating emitted below three reviews. The stars you lose on long-tail SKUs are worth the reduced risk of a sitewide review-snippet demotion, which is what happens when the pattern looks systematic.

Individual reviews

If you emit individual Review nodes, each needs an author that is a Person or Organization with a name — not a string, and not "Anonymous". reviewRating needs a ratingValue, and bestRating if your scale is not out of five.

{
  "@type": "Review",
  "author": { "@type": "Person", "name": "Priya N." },
  "datePublished": "2026-06-14",
  "reviewBody": "Warmer light than I expected from the photos. The oak grain is lovely.",
  "reviewRating": {
    "@type": "Rating",
    "ratingValue": "4",
    "bestRating": "5",
    "worstRating": "1"
  }
}

Emit a handful, not all 231. There is no ranking benefit to a 400KB JSON-LD block and there is a real render-cost penalty. I cap it at the reviews visible above the fold of the reviews section — typically five.

8. Merchant Listing Requirements Beyond the Basics

"Merchant listing" is Google's term for a product result that is eligible for the free listing surfaces — the Shopping tab, the popular products carousel, the price-and-shipping annotations under a blue link. It requires more than a valid Product, and the extras are where most sites leave value on the table.

Shipping details

{
  "@type": "Offer",
  "price": "89.00",
  "priceCurrency": "GBP",
  "shippingDetails": {
    "@type": "OfferShippingDetails",
    "shippingRate": {
      "@type": "MonetaryAmount",
      "value": "4.95",
      "currency": "GBP"
    },
    "shippingDestination": {
      "@type": "DefinedRegion",
      "addressCountry": "GB"
    },
    "deliveryTime": {
      "@type": "ShippingDeliveryTime",
      "handlingTime": {
        "@type": "QuantitativeValue",
        "minValue": 0, "maxValue": 1, "unitCode": "DAY"
      },
      "transitTime": {
        "@type": "QuantitativeValue",
        "minValue": 1, "maxValue": 3, "unitCode": "DAY"
      }
    }
  }
}

handlingTime is the time from order to dispatch; transitTime is carrier time. Splitting them is not pedantry — Google composes the "arrives by" estimate from the pair, and a store that quotes a combined 1–3 days when it actually holds orders for 24 hours before dispatch will show optimistic delivery dates and then miss them.

If your shipping is genuinely free above a threshold, express that with two shipping detail entries rather than one, or accept the simpler and more common approach: express the standard paid rate in schema and configure the free-shipping threshold in Merchant Center, where it belongs. Shipping configuration in Merchant Center overrides markup anyway, so if you run a Merchant Center account, this markup is mostly useful for the organic blue-link annotations rather than for Shopping.

Return policy

{
  "@type": "Offer",
  "hasMerchantReturnPolicy": {
    "@type": "MerchantReturnPolicy",
    "applicableCountry": "GB",
    "returnPolicyCategory": "https://schema.org/MerchantReturnFiniteReturnWindow",
    "merchantReturnDays": 30,
    "returnMethod": "https://schema.org/ReturnByMail",
    "returnFees": "https://schema.org/FreeReturn"
  }
}

This one is worth the twenty minutes. A "free 30-day returns" annotation under a search result measurably moves click-through on considered purchases, and the markup is static per store — you can hardcode it in a template and forget about it. Get the category right: MerchantReturnFiniteReturnWindow with a day count, MerchantReturnNotPermitted for final-sale items, MerchantReturnUnlimitedWindow if you genuinely accept returns forever, which almost nobody does.

The mistake I see is a single global return policy applied to items it does not cover — personalised goods, perishables, clearance lines. If your policy varies by product type, the markup must vary too, and that means driving it from an attribute rather than a template constant.

Price drops and strike-through pricing

Google can annotate a listing as a price drop, but it derives that from observed price history and feed data, not from markup. There is no honest way to declare "was £120, now £89" in Offer that produces a strike-through in search. priceSpecification with a ListPrice exists in the vocabulary and is used by Merchant Center for some promotion features, but treating it as a lever for a discount badge in organic results is wishful. If someone sells you an implementation on that promise, ask them to show you the SERP.

9. How Rich Results Actually Get Revoked

Rich results are not a permanent grant. They are a continuously re-evaluated privilege, and there are four distinct mechanisms by which they go away. Knowing which one you are experiencing determines what you do about it, and people routinely misdiagnose this.

Mechanism one: the markup broke

A deploy, a plugin update, a CDN transform. Search Console's rich result reports flip from valid to error within a few days of recrawl. The fix is code, and recovery after the fix is usually one to three weeks depending on recrawl rate. This is the good case: it is visible, attributable, and fully recoverable.

Mechanism two: the markup is fine but ineligible

Everything validates, nothing errors, and the rich result simply does not appear. This is Google deciding your result does not warrant enhancement for that query, or the page failing a requirement that the validator does not test — content not visible, a rating below whatever internal threshold applies, a page Google considers thin. Search Console will show items as "Valid" while the SERP shows nothing. Maddening, and the correct response is usually to improve the page rather than the markup.

Mechanism three: a manual action

"Structured data issue" appears in the Manual actions report. This is a human at Google deciding your markup is spam — typically self-serving reviews, marked-up content that is not visible, or ratings that do not correspond to anything on the page. Rich results are suppressed sitewide, not per page. Recovery requires fixing the cause and filing a reconsideration request, and turnaround has run from four days to five weeks in my experience. This is the expensive one.

Mechanism four: algorithmic demotion of the feature

Google changes what it shows. FAQ rich results in 2023 are the canonical example — sites did nothing wrong and lost the feature anyway. Nothing appears in Search Console because nothing is broken. The lesson people should take from that episode is not "don't use structured data"; it is "don't build a revenue forecast on a SERP feature Google can withdraw on a Tuesday." I cover what happened there, and what remains safe to invest in, in the site-wide structured data article.

Telling them apart

Check in this order. Manual actions report first, because it is binary and it explains everything if present. Then the specific enhancement report — Merchant listings, Product snippets, Review snippets are separate reports and they fail independently. Then run the URL through the Rich Results Test live, not on the cached version, because the test fetches fresh. Then check whether the feature still exists for anyone by searching for a competitor. Four checks, ten minutes, and it saves you from rewriting markup that was never the problem.

10. Implementing It in Magento 2

Magento's built-in structured data is minimal and mostly microdata in the theme. I replace it rather than extend it: one block that assembles the whole product graph, one template that prints it, and an explicit removal of the legacy microdata so you are not emitting two conflicting descriptions of the same product.

<?php
declare(strict_types=1);

namespace Modracx\Schema\Block;

use Magento\Catalog\Model\Product;
use Magento\Framework\View\Element\Template;
use Magento\Framework\View\Element\Template\Context;
use Magento\Framework\Registry;
use Magento\Framework\Pricing\PriceCurrencyInterface;
use Magento\Catalog\Helper\Image as ImageHelper;
use Magento\CatalogInventory\Api\StockRegistryInterface;
use Magento\Framework\Serialize\Serializer\Json;

class ProductSchema extends Template
{
    public function __construct(
        Context $context,
        private Registry $registry,
        private ImageHelper $imageHelper,
        private StockRegistryInterface $stockRegistry,
        private PriceCurrencyInterface $priceCurrency,
        private Json $json,
        array $data = []
    ) {
        parent::__construct($context, $data);
    }

    public function getProduct(): ?Product
    {
        return $this->registry->registry('current_product');
    }

    public function getSchemaJson(): string
    {
        $product = $this->getProduct();
        if (!$product) {
            return '';
        }

        $url  = $product->getProductUrl();
        // Final price includes catalogue and special price rules but NOT the
        // current customer group's tier price — which is what we want, because
        // the crawler is an anonymous visitor.
        $price = $product->getPriceInfo()
            ->getPrice('final_price')
            ->getAmount()
            ->getValue();

        $stockItem = $this->stockRegistry->getStockItem($product->getId());
        $salable   = $product->isSalable() && $stockItem->getIsInStock();
        $backorder = (int) $stockItem->getBackorders() > 0;

        if ($salable) {
            $availability = 'https://schema.org/InStock';
        } elseif ($backorder) {
            $availability = 'https://schema.org/BackOrder';
        } else {
            $availability = 'https://schema.org/OutOfStock';
        }

        $node = [
            '@context' => 'https://schema.org',
            '@type'    => 'Product',
            '@id'      => $url . '#product',
            'name'     => $product->getName(),
            'sku'      => $product->getSku(),
            'image'    => $this->imageSet($product),
            'offers'   => [
                '@type'         => 'Offer',
                'url'           => $url,
                'priceCurrency' => $this->priceCurrency->getCurrency()->getCurrencyCode(),
                'price'         => number_format((float) $price, 2, '.', ''),
                'availability'  => $availability,
                'itemCondition' => 'https://schema.org/NewCondition',
                'priceValidUntil' => date('Y-m-d', strtotime('+1 year')),
            ],
        ];

        // Only emit optional identifiers when the attribute is genuinely set.
        foreach (['gtin13' => 'gtin13', 'mpn' => 'mpn'] as $key => $attr) {
            $value = $product->getData($attr);
            if ($value !== null && $value !== '') {
                $node[$key] = (string) $value;
            }
        }

        if ($brand = $product->getAttributeText('manufacturer')) {
            $node['brand'] = ['@type' => 'Brand', 'name' => (string) $brand];
        }

        return $this->json->serialize($node);
    }

    private function imageSet(Product $product): array
    {
        $out = [];
        foreach (['schema_1x1', 'schema_4x3', 'schema_16x9'] as $id) {
            $out[] = $this->imageHelper->init($product, $id)->getUrl();
        }
        return array_values(array_unique($out));
    }
}

The image roles referenced there need declaring in view.xml so the three crops actually exist rather than falling back to one URL repeated three times:

<image id="schema_1x1" type="image">
    <width>1200</width><height>1200</height>
</image>
<image id="schema_4x3" type="image">
    <width>1200</width><height>900</height>
</image>
<image id="schema_16x9" type="image">
    <width>1200</width><height>675</height>
</image>

Two Magento-specific warnings. First, isSalable() is expensive on configurables with many children because it walks the child stock; cache the result per request or you will add tens of milliseconds to every product view. Second, if you run Magento's full page cache — and you should — this block is cached with the page, so everything I said about availability staleness applies directly. Do not solve it by making the block private; solve it with targeted invalidation.

11. Implementing It in Shopify Liquid

Shopify gives you the data in the template, which makes this easier, and it gives you product.selected_or_first_available_variant, which makes it easy to be subtly wrong.

{%- liquid
  assign v = product.selected_or_first_available_variant
  assign base = shop.url | append: product.url
-%}
<script type="application/ld+json">
{
  "@context": "https://schema.org",
  "@type": "Product",
  "@id": {{ base | json }},
  "name": {{ product.title | json }},
  "description": {{ product.description | strip_html | truncate: 400 | json }},
  "sku": {{ v.sku | json }},
  "brand": { "@type": "Brand", "name": {{ product.vendor | json }} },
  "image": [
    {%- for img in product.images limit: 5 -%}
      {{ img | image_url: width: 1200 | prepend: "https:" | json }}
      {%- unless forloop.last -%},{%- endunless -%}
    {%- endfor -%}
  ],
  "offers": {
    "@type": "AggregateOffer",
    "priceCurrency": {{ cart.currency.iso_code | json }},
    "lowPrice": {{ product.price_min | divided_by: 100.0 | json }},
    "highPrice": {{ product.price_max | divided_by: 100.0 | json }},
    "offerCount": {{ product.variants.size }},
    "offers": [
      {%- for variant in product.variants -%}
      {
        "@type": "Offer",
        "sku": {{ variant.sku | json }},
        "url": {{ base | append: '?variant=' | append: variant.id | json }},
        "priceCurrency": {{ cart.currency.iso_code | json }},
        "price": {{ variant.price | divided_by: 100.0 | json }},
        "availability": {%- if variant.available -%}
          "https://schema.org/InStock"
        {%- else -%}
          "https://schema.org/OutOfStock"
        {%- endif -%},
        "itemCondition": "https://schema.org/NewCondition"
      }{%- unless forloop.last -%},{%- endunless -%}
      {%- endfor -%}
    ]
  }
}
</script>

The | json filter on every string is not optional. Product titles contain quotes and apostrophes and em dashes, descriptions contain everything, and one unescaped double quote invalidates the whole block silently. I have debugged a "schema stopped working" report that turned out to be a single product called 18" Brass Wall Light.

The divided_by: 100.0 matters too — the float, not 100. Integer division in Liquid will turn 7800 pence into 78 and 7850 into 78, which is a quiet 50p price mismatch that Merchant Center will find before you do.

The other Shopify trap is duplication. Most themes, including Dawn, already emit a Product JSON-LD block. Add yours without removing theirs and you have two Product nodes describing the same thing with different availability logic. Google will pick one, and it will not tell you which. Search the theme for application/ld+json before you write a line.

12. Headless: Where the Schema Should Be Built

On a Hydrogen, Next.js, or Nuxt storefront the question becomes: which layer owns the graph? I have seen three answers and only one of them is good.

Client-side injection. A React component that writes a script tag on mount. Google does render JavaScript, so this sometimes works, and it fails often enough — render budget, deferred hydration, a component that only mounts after an intersection observer fires — that I would not ship it on a page whose revenue depends on the result. Availability especially.

Per-page assembly in the route. Each product route builds its own JSON. Works, and drifts. Six months in, the product route and the collection route disagree about how to format prices, and nobody notices because both are valid JSON.

A single serialiser module. One function, product data in, JSON-LD out, called from the server-rendered route. This is what I build now.

// lib/schema/product.ts
type Money = { amount: string; currencyCode: string };

interface VariantInput {
  sku: string;
  url: string;
  price: Money;
  available: boolean;
  gtin13?: string;
}

interface ProductInput {
  id: string;
  url: string;
  title: string;
  description: string;
  vendor: string;
  images: string[];
  variants: VariantInput[];
  rating?: { value: number; ratingCount: number; reviewCount: number };
}

const AVAILABILITY = {
  inStock: 'https://schema.org/InStock',
  outOfStock: 'https://schema.org/OutOfStock',
} as const;

export function productSchema(p: ProductInput) {
  const offers = p.variants.map((v) => ({
    '@type': 'Offer',
    sku: v.sku,
    url: v.url,
    priceCurrency: v.price.currencyCode,
    // Force two decimals: Shopify and most commerce APIs return "78.0"
    price: Number(v.price.amount).toFixed(2),
    availability: v.available ? AVAILABILITY.inStock : AVAILABILITY.outOfStock,
    itemCondition: 'https://schema.org/NewCondition',
    ...(v.gtin13 ? { gtin13: v.gtin13 } : {}),
  }));

  const node: Record<string, unknown> = {
    '@context': 'https://schema.org',
    '@type': 'Product',
    '@id': `${p.url}#product`,
    name: p.title,
    description: p.description.slice(0, 400),
    brand: { '@type': 'Brand', name: p.vendor },
    image: p.images.slice(0, 3),
    offers: offers.length === 1 ? offers[0] : {
      '@type': 'AggregateOffer',
      priceCurrency: offers[0].priceCurrency,
      lowPrice: min(offers),
      highPrice: max(offers),
      offerCount: offers.length,
      offers,
    },
  };

  // The three-review floor: below it, no stars rather than fragile stars.
  if (p.rating && p.rating.ratingCount >= 3) {
    node.aggregateRating = {
      '@type': 'AggregateRating',
      ratingValue: p.rating.value.toFixed(1),
      bestRating: '5',
      worstRating: '1',
      ratingCount: p.rating.ratingCount,
      reviewCount: p.rating.reviewCount,
    };
  }

  return node;
}

const min = (o: { price: string }[]) =>
  o.reduce((a, b) => (Number(b.price) < Number(a) ? b.price : a), o[0].price);
const max = (o: { price: string }[]) =>
  o.reduce((a, b) => (Number(b.price) > Number(a) ? b.price : a), o[0].price);

One module, one place to fix a bug, one place to unit-test. And it makes the next step possible: snapshot tests that assert the emitted JSON for a fixture product, so the plugin-update failure I opened with becomes a red build rather than a nine-day CTR decline. The mechanics of wiring that into CI, and of getting the serialiser output into the document without duplicating it, are the subject of the companion article on site-wide implementation.

13. Testing, and What Each Tool Will Not Tell You

There are four tools and they answer four different questions. Using the wrong one is why people say "it validates" while the SERP disagrees.

ToolAnswersDoes not answer
Schema.org validatorIs this valid vocabulary?Whether Google will use any of it
Rich Results TestIs this eligible for a Google feature?Whether it will actually be shown
Search Console reportsWhat did Google see at last crawl, at scale?Anything about a page crawled yesterday
Merchant Center diagnosticsDoes the page agree with the feed?Organic rich result eligibility

The Rich Results Test is the one to reach for during development, and use the live URL mode rather than pasting code, because pasting code tests markup in a vacuum and hides exactly the failures that come from your CDN, your consent script, or your cache. I have seen markup pass the paste test and fail live because a cookie-consent wrapper deferred the whole script tag.

Search Console's Merchant listings and Product snippets reports are the ones to watch continuously. They lag by days and they sample, so treat a change in the error count as a signal to investigate rather than a precise measurement. What they are genuinely good at is showing you the shape of a failure: 11,400 items failing identically is a code change, 40 items failing is a data problem in 40 products.

A cheap regression check I put on every project — it catches the "someone removed the block" class of failure within minutes rather than weeks:

#!/usr/bin/env bash
set -euo pipefail

check_product() {
  local url="$1"
  local json
  json=$(curl -sL "$url" \
    | python3 -c '
import sys, json, re
html = sys.stdin.read()
blocks = re.findall(r"<script[^>]*application/ld\+json[^>]*>(.*?)</script>", html, re.S)
for b in blocks:
    try:
        d = json.loads(b)
    except Exception:
        continue
    nodes = d if isinstance(d, list) else [d]
    for n in nodes:
        if n.get("@type") == "Product":
            print(json.dumps(n))
            sys.exit(0)
sys.exit(1)
')
  echo "$json" | python3 - "$url" <<'PY'
import json, sys
node = json.loads(sys.stdin.read())
offers = node.get("offers", {})
offers = offers[0] if isinstance(offers, list) else offers
assert offers.get("price"), "no price"
assert offers.get("priceCurrency"), "no currency"
assert offers.get("availability", "").startswith("https://schema.org/"), "bad availability"
assert node.get("name"), "no name"
print(f"ok {sys.argv[1]}: {offers['price']} {offers['priceCurrency']}")
PY
}

check_product https://shop.example.com/products/oak-lamp-classic
check_product https://shop.example.com/products/linen-shirt

Run it against production after every deploy and against a handful of representative SKUs — one simple, one configurable, one out of stock, one on sale. Four URLs catch most of what goes wrong.

14. Reconciling With Merchant Center

If you run paid Shopping or free listings, your feed and your page are two claims about the same product and Google compares them. Disagreements produce item disapprovals, and the messages are terse enough that people fix the wrong side.

The reconciliation rules that matter, in the order they bite:

Price must match to the currency's minor unit. A feed price of 89.00 GBP against a page price of 89.00 GBP inclusive of VAT is fine; against a page showing 74.17 ex-VAT is a mismatch even though both numbers are "correct". The page is the arbiter, so fix the feed to match the page, not the reverse.

Availability must match, and the automatic item updates feature will read your structured data to correct the feed if you let it. Enable it. It converts an availability mismatch from a disapproval into a silent correction. It only works if your markup is accurate, which is the whole argument for getting availability right.

Item group ID must equal productGroupID. This is the one people skip and then wonder why variants compete with each other in Shopping.

Identifiers must agree. A GTIN in the feed and no GTIN on the page is tolerated; different GTINs is not, and it is a fast route to a suspended account if it looks systematic.

The thing I would tell my past self: Merchant Center's "Diagnostics" page groups issues by item count, and the count is a much better prioritisation signal than the severity label. An "informational" issue affecting 30,000 items is worth more attention than an "error" affecting six.

15. A Worked Rebuild, With Numbers

The camping equipment retailer from the opening. Magento 2.4.7, 11,400 simple and configurable SKUs, about £4.1m annual revenue, roughly 61% of sessions from organic search. Here is what actually happened, including the parts that did not go well.

Baseline, week zero. Merchant listings report: 11,400 items, all with the review error, zero valid. Product snippets: valid but no rating. Average CTR on product-page-intent queries: 3.1%, down from 4.9% before the plugin update. Merchant Center: 1,340 disapproved items, mostly "Mismatched value (price)". Nobody had opened Search Console in eleven weeks, which is how a nine-day decline became a nine-week one.

Week one — stop the bleeding. We disabled the reviews app's JSON-LD output entirely rather than trying to fix its shape, because we did not control its code and could not test its next update. Stars stayed gone; errors went to zero. This is worth stating plainly because it looks like a step backwards: we removed markup and the numbers did not improve. The point was to get to a known state.

Weeks two and three — rebuild. The block above, essentially. One serialiser, product-level review aggregation read from the app's API at index time and stored on a product attribute rather than fetched at render, three image roles, availability derived from salability and backorders. Two decisions were arguments. I wanted the three-review floor; the client's marketing lead wanted stars on everything. We compromised at three and I would hold that line again. I also wanted to drop priceValidUntil as noise and was talked out of it, correctly — Google dropped prices from the rich result on a test batch that omitted it.

Week four — the price mismatch. The 1,340 disapprovals were not a schema bug at all. Their feed was generated from a nightly export that ran before the special-price cron, so any product going on promotion had one day of feed price disagreeing with page price. The schema work made this visible; it did not cause it. We moved the export to run after the price rules and 1,290 of the 1,340 cleared within four days.

What went wrong. Two things. We shipped the availability change on a Thursday and did not notice that isSalable() on their 900 configurables with 20+ children each added around 180ms to product page TTFB. Their LCP moved from 2.1s to 2.6s and stayed there for six days before anyone connected the two. Caching the salability check per request brought it back to 2.15s. The second: we did not tell the merchandising team that out-of-stock products would now be marked OutOfStock rather than silently InStock, and they had been relying on those pages continuing to receive traffic. They were, correctly, unhappy. That conversation should have happened in week one.

Results at week twelve. Merchant listings: 10,860 valid, 540 invalid (products with genuinely missing GTINs where the supplier does not provide one — we left them rather than inventing values). Review snippets returned on 7,200 products, the ones clearing the three-review floor. CTR on product-intent queries: 5.4%, above the pre-incident 4.9%, which I attribute mostly to the return-policy and shipping annotations rather than the stars. Merchant Center disapprovals: 51. Organic revenue for the quarter up 14% year on year against a category that was roughly flat.

Effort. About nine engineering days across five weeks, plus perhaps three days of my time on diagnosis and argument. The diagnosis was two days of it. Fixing markup is fast; working out which of the four revocation mechanisms you are in is the expensive part.

16. The Failures I See Most Often

A list I have effectively memorised from audits, roughly ordered by how often it appears.

Two Product nodes on one page. A theme's built-in block plus an app plus a bespoke block. Everybody adds; nobody removes. Fix: grep for application/ld+json, delete all but one.

Price without currency, or currency without price. Both are required together and the offer is dropped silently if either is absent.

Availability as a bare string. "availability": "InStock" instead of the full https://schema.org/InStock URL. Google has become more tolerant of the short form but Merchant Center reconciliation has not been reliably so. Use the URL.

Review author as a string. "author": "Priya N." rather than a Person node. Invalidates the review.

Category-page products marked up as Product. A collection page listing 24 items is an ItemList, not 24 Product nodes. Marking them up as products creates ambiguity about which page should rank for the product and is a common cause of the wrong URL appearing in results — the same ambiguity that shows up as URL flipping in Search Console, which I cover as a diagnostic in the article on mapping keyword clusters to page types.

Truncated descriptions with an HTML tag left open. Strip HTML before truncating, not after. Otherwise you eventually emit <strong>Hand-finish into a JSON string, and while that is technically valid JSON, it is a description no human wrote.

Marked-up ratings on pages with no reviews section. Usually the result of a default value in a template. It is the fastest route to a manual action of anything on this list.

17. Questions I Get Asked

"Does structured data improve rankings?" No, not directly, and anyone who tells you otherwise is selling something. What it does is change how your existing ranking is presented, and presentation changes click-through, and click-through on the same position is real revenue. On the homeware rebuild, positions barely moved and revenue moved 14%. That is the mechanism.

"Should we use microdata or JSON-LD?" JSON-LD. Google has said it prefers it for years, it separates data from presentation so a theme change cannot break your markup, and it is testable in isolation. The only argument for microdata is a legacy template you cannot touch, and even then I would add JSON-LD and remove the microdata rather than maintain both.

"Our competitor has stars and we do not, and our markup is identical." Then the difference is not the markup. Usually it is review volume, page quality, or that they qualify for a feature you do not. Occasionally it is that they are breaching policy and have not been caught yet. Copying a competitor's markup byte for byte is a strategy I have watched fail three times.

"Can we mark up the sale price as if the RRP were ours?" No. Inflating a strike-through reference price is a consumer protection problem before it is an SEO one, and in the UK the CMA has been explicit about it. The schema question is downstream of the legal one.

"How long until we see the rich result after fixing it?" Recrawl plus evaluation. On a well-crawled catalogue, days for popular URLs and weeks for the long tail. Submitting a handful of URLs for indexing accelerates the sample so you can confirm the fix works; it does not accelerate the catalogue. If nothing has changed in a month on frequently crawled URLs, the fix was not the problem.

"Do we need structured data if we are already in Merchant Center?" Yes, for two reasons. Automatic item updates read it to keep your feed honest, and organic blue-link annotations — price, availability, returns — come from the page rather than the feed. The feed and the page do different jobs.

"Should we mark up products that are out of stock?" Yes, accurately. Removing the markup does not help and marking it InStock is a violation. If a product is permanently gone, use SoldOut and decide separately whether the URL should stay indexed, redirect, or 410 — that is a merchandising decision, not a schema one.

"Can we add FAQ markup to product pages for extra SERP space?" Not usefully any more, and the reasons are worth understanding properly; they are covered in the site-wide article. Short version: the feature was withdrawn for almost all sites and the markup now buys you nothing on a commercial storefront.

18. What I Would Do First

If you inherited a catalogue tomorrow and had a week, this order:

One. Open Search Console and read the Manual actions report. Thirty seconds, and if there is something in it, everything else waits.

Two. Fetch three product URLs — a simple in-stock item, a configurable, and something out of stock — and count the application/ld+json blocks. If there is more than one Product node, you have found your first bug before writing any code.

Three. Compare the marked-up price against the displayed price on all three, logged out, with a cold cache. Then compare both against the feed if you have one. Price mismatches are the highest-cost, lowest-effort fix available.

Four. Check that availability is derived from whether a customer can actually buy the thing, not from a stock quantity field that has drifted. Buy something out of stock in a staging environment and see what the markup says.

Five. Audit the rating source. Where do the numbers come from, are they about the product or about you, are they visible in the raw HTML, and is there a minimum review count? If the answer to the last is no, add one before you add anything else.

Six. Add hasMerchantReturnPolicy and shippingDetails. Static, cheap, and the annotations they produce are the most under-used click-through lever on the product page.

Seven. Put the smoke test in the deploy pipeline. Not because it is elegant, but because the failure that opened this article was invisible for nine days and a build that goes red is not.

Everything above the product page — the breadcrumbs that tell Google where this item sits, the organisation node that connects the catalogue to a real business, the machinery for emitting all of it exactly once from a template — is a separate discipline with its own failure modes. That is the other half of the job, and doing the product node beautifully while the site around it is silent about who you are leaves most of the value on the table.

Suggested & Related Reading

Explore related engineering guides from Kenneth D'Silva: