22 Sept 2026 · 6 min read
Structured data that survives a Hyva theme
Emit Product, Offer, Breadcrumb and Review markup from Hyva view models and validate it in CI
Part 3 of the Storefront Conversion Engineering series — rich results need structured markup, not luck.
The Hyva migration that erased rich results
A store migrated from Luma to Hyva and celebrated faster load times. Two weeks later, product rich results vanished from search. The old theme had emitted JSON-LD from a layout block nobody documented; the new Hyva templates never included it. No error was logged, no test failed, because nothing had ever asserted the markup existed. Visibility quietly degraded while performance dashboards glowed green.
This failure mode is the norm, not the exception. Hand-added JSON-LD snippets pasted into templates are invisible to tests and the first casualty of any theme migration. The fix is architectural: emit structured data from view models, where it is typed, tested, and independent of any single template.
Emit from view models, not templates
Hyva replaces Magento's block-and-template frontend with view models and Tailwind-based templates. That separation is an opportunity. A dedicated structured-data view model builds the schema graph server-side from the same product repository the page renders, so markup and visible content can never drift apart silently.
The view model exposes one method returning the complete JSON-LD graph: Product with its name, SKU, description, image, and brand; nested Offer with price, currency, availability, and URL; BreadcrumbList reflecting the rendered breadcrumb trail; and Review/AggregateRating only when reviews are actually displayed. The template does exactly one thing with it:
<script type="application/ld+json"><?= $escaper->escapeHtml(json_encode($viewModel->getSchemaGraph(), JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)) ?></script>
No markup logic in the template, no duplicated price formatting, no second source of truth. When the design changes, the template changes; the schema graph does not move.
A complete product example looks like this:
{
"@context": "https://schema.org",
"@graph": [
{
"@type": "Product",
"name": "Trail Runner GTX",
"sku": "TR-GTX-42",
"description": "Waterproof trail running shoe with Gore-Tex lining.",
"image": ["https://www.example.com/media/catalog/product/t/r/trail-runner-gtx.jpg"],
"brand": { "@type": "Brand", "name": "Example Outdoors" },
"aggregateRating": { "@type": "AggregateRating", "ratingValue": "4.6", "reviewCount": "128" },
"offers": { "@id": "#offer" }
},
{
"@type": "Offer",
"@id": "#offer",
"url": "https://www.example.com/trail-runner-gtx.html",
"priceCurrency": "USD",
"price": "149.00",
"availability": "https://schema.org/InStock",
"itemCondition": "https://schema.org/NewCondition"
},
{
"@type": "BreadcrumbList",
"itemListElement": [
{ "@type": "ListItem", "position": 1, "name": "Home", "item": "https://www.example.com/" },
{ "@type": "ListItem", "position": 2, "name": "Shoes", "item": "https://www.example.com/shoes.html" },
{ "@type": "ListItem", "position": 3, "name": "Trail Runner GTX" }
]
}
]
}
Note the invariants this example encodes: the offer URL matches the canonical product URL, the price uses the final rendered price with currency, availability uses the schema.org enum (not a store-specific label), and the breadcrumb mirrors the visible trail exactly.
Price and availability must match the render
Google treats mismatches between structured data and visible content as spam signals. The two most common Magento violations are stale promo prices and optimistic availability.
Prices. The view model must read the same price index the page displays: final price after catalog rules, promotions, customer-group pricing, and tax configuration. A frequent bug is emitting the regular price while the template shows the special price, or vice versa after reindex lag. Resolve it by sourcing both from one price resolver, and by reindexing prices before cache flush on promotion changes so markup and HTML update atomically.
Availability. Map stock state explicitly: in stock maps to InStock, out of stock with no backorders to OutOfStock, backorderable to BackOrder, preorder to PreOrder. Never emit InStock for a product the shopper cannot add to cart. Configurable products need per-variant offers with each variant's own availability, since a single aggregate status misrepresents partial stock.
Reviews without visible reviews is a penalty
Google's product review policy is strict: review markup must reflect reviews visible on the page. Emitting AggregateRating for a product with zero displayed reviews, importing third-party reviews without showing them, or syndicating the same review text across products risks a manual action that removes all rich results.
The rule in code: the view model emits Review and AggregateRating nodes only when the review collection is non-empty and the template renders them. Gate it with a single condition both paths share, so markup and display cannot diverge. If the design hides reviews behind a tab, that still counts as visible as long as the content is in the HTML; if reviews load only after interaction, the markup must wait too or be omitted.
One emitter per entity
Duplicated entities are the Hyva-era classic: the theme emits Product markup from its view model while a legacy SEO module still injects its own block, producing two Product nodes with slightly different prices. Validators flag the conflict, and Google may trust neither.
Audit every layout handle that renders JSON-LD: product view, category, CMS, and any module injecting global Organization or WebSite nodes. Assign exactly one emitter per entity type and disable the rest. The Organization and WebSite nodes belong on every page from a single global provider; Product and Offer belong only on product pages from the product view model. Verify uniqueness by parsing the rendered HTML in a test and counting @type occurrences per page type.
Validate in CI and Search Console
Structured data needs the same CI treatment as code:
- Unit test the view model. Assert required properties exist, prices match the resolver output, availability enums are valid, and review nodes appear only with visible reviews.
- Integration test the rendered page. Render product, category, and CMS fixtures headlessly, extract every
application/ld+jsonblock, parse the JSON, and validate against the schema.org shapes the store uses. Fail the build on invalid JSON, missing required fields, or duplicate entities. - Monitor Search Console. The Merchant Listings and Product snippets reports show valid versus invalid items over time. A deploy that breaks markup appears here within days. Track the valid-item trend as a release health metric alongside error rates.
- Spot-check with the Rich Results Test. Run changed page types through the Rich Results Test before release, especially after price logic or theme changes. It catches enum typos and URL mismatches that schema validation alone misses.
Pitfalls
- Review markup with no visible reviews on the page. Gate review nodes on the same condition that renders reviews. No display, no markup.
- Offer prices stale after promotion reindex. Source markup and template from one price resolver, and order reindex before cache flush.
- Duplicated entities from theme and module both emitting. One emitter per entity type; audit every layout handle and count
@typeoccurrences in tests. - Absolute URL drift. Schema URLs must be absolute and canonical. Relative image paths or staging domains leaking into production markup invalidate the graph.
- Testing markup only once at launch. Prices, stock logic, and themes change continuously. CI validation on every build is the only durable guarantee.
Closing
Structured data done right is boring: one view model, one emitter per entity, prices that match the render, reviews that match the display, and CI that refuses to ship breakage. That boring reliability is what earns and keeps rich results through theme migrations. Next, we extend correctness across borders with hreflang for multi-store catalogs.
Next in this series:
magento2-hreflang-multistore-seo