Hyvä Theme’s Product Schema Gap: Real Microdata, No Product Entity

Structured Data · Magento 2 · Hyvä

Hyvä Theme’s Product Schema Gap: Real Microdata, No Product Entity

Hyvä’s product template already renders price, currency, and availability as structured data. What it never renders is a Product to attach that data to — which weakens how reliably any machine-readable client can extract a coherent product from the page.

Verified directly against the hyva-themes/magento2-default-theme GitHub repository (branch main), the official Hyvä changelog, and hyva.io — checked July 2026. Theme code changes over time; re-verify against your installed version before shipping.

Diagram showing a Hyvä product page with real Offer microdata (price, availability) in the server-rendered HTML, but a callout flagging the missing schema.org/Product wrapper
Hyvä’s price template emits real Offer microdata — but nothing wraps it in a Product.

TL;DR

  • Hyvä’s price.phtml emits real Offer microdata — price, priceCurrency, availability (live from stock status), priceValidUntil — and the gallery emits itemprop="image".
  • None of it sits inside a schema.org/Product item. A repository-wide search on the current main branch finds no such wrapper anywhere in the default theme.
  • Under the microdata spec, an itemprop only means something inside an ancestor itemscope. Without one, this is a standalone Offer item plus Product-related properties with no parent scope to belong to — not a connected Product graph.
  • Hyvä already uses JSON-LD elsewhere — breadcrumbs get a proper BreadcrumbList block. It just hasn’t extended that pattern to Product.
  • A small server-side JSON-LD addition closes the gap. Pattern below.

What’s genuinely in the code

Hyvä launched in 2021 (Willem Wigman, with Vinai Kopp joining early), built on Tailwind CSS and Alpine.js in place of Luma’s KnockoutJS/RequireJS/jQuery stack. In November 2025, on its fifth anniversary, the default theme was relicensed under dual OSL 3.0 / AFL 3.0 — the same model Magento Open Source uses — making it free to use commercially.

The theme is server-rendered PHTML, which matters for indexing: product copy, headings, and breadcrumbs arrive as HTML in the initial response rather than being assembled client-side. Checking Magento_Catalog/templates/product/view/price.phtml directly in the theme repository shows this isn’t just a performance story — the template already does real structured-data work:

$availability = $product->getIsSalable()
    ? 'http://schema.org/InStock'
    : 'http://schema.org/OutOfStock';
...
<div class="final-price"
     itemprop="offers"
     itemscope
     itemtype="https://schema.org/Offer">
    <meta itemprop="price" content="...">
    <meta itemprop="priceCurrency" content="...">
    <meta itemprop="priceValidUntil" content="..."> <!-- if set -->
    <link itemprop="availability" href="...">
</div>

Source: hyva-themes/magento2-default-theme, branch main. The availability field was added in release 1.4.4 (2026-03-03, issue #680). The gallery template adds itemprop="image" on the main product image the same way.

The gap: an Offer with nothing to attach to

There is no itemtype="https://schema.org/Product" anywhere in the default theme — a repository-wide search on the current main branch turns up nothing. Not on the product wrapper, not on the gallery container, not on the title.

<div itemprop="offers"
     itemscope
     itemtype="https://schema.org/Offer">

No ancestor itemscope of type Product exists on this page. Per the microdata spec, itemprop declares a property of the nearest ancestor item — with none present, this Offer is self-contained, not attached to anything.

This is what makes the gap easy to miss: the Offer item is internally valid, so a tool checking it in isolation won’t flag an error. What it can’t do is tell a parser “this price and this availability belong to a Product named X, with SKU Y” — because that connection was never declared.

Expected graph

Product
 ├─ name
 ├─ sku
 ├─ brand
 ├─ image
 └─ offers
     ├─ price
     ├─ availability
     └─ priceCurrency

What Hyvä renders today

Offer            (standalone)
 ├─ price
 ├─ availability
 └─ priceCurrency

image             (standalone,
                    itemprop with
                    no parent scope)

Product           — absent
Comparison diagram: the expected schema.org Product graph with name, sku, brand, image, and offers, versus what Hyvä actually renders — a standalone Offer and image with no Product entity
Shareable version of the graph above, with the verified price.phtml snippet.

Why the disconnect matters for parsers

Search and shopping surfaces that assemble a product answer — from classic rich-results eligibility to newer shopping-oriented indexes — generally need one connected entity: a Product with an identity, tied to an Offer with price and availability. A page where price/availability data sits near Product content without a formal link asks the consumer to infer the connection instead of reading it directly. That removes an explicit Product→Offer relationship and makes reliable entity extraction harder; it doesn’t guarantee any specific system will fail to use the page, but it’s a weaker signal than a declared graph.

JSON-LD avoids the problem by declaring the whole graph in one block, independent of DOM nesting. Hyvä already applies this pattern to breadcrumbs: since release 1.3.15, the theme renders a BreadcrumbList JSON-LD script server-side. It just hasn’t been extended to Product.

A related trap: schema added through GTM

Some teams add Product schema via Google Tag Manager to avoid touching theme code. This only helps clients that execute the injected script. Server-rendered HTML is the safer assumption for any crawler whose JavaScript behavior you can’t verify: OAI-SearchBot and PerplexityBot are broadly understood to run little or no client-side JS, though neither publishes full technical details, while Googlebot and Bingbot do render JavaScript for their own indexes. GTM-based schema is therefore inconsistent across consumers rather than reliably invisible — which is its own problem, since you can’t predict which pipeline will see it. Rendering the JSON-LD from the template removes that uncertainty entirely.

The fix: a server-rendered Product JSON-LD block

Add a JSON-LD block from the product template (or a small module) so it ships in the initial HTML and explicitly declares the Product–Offer relationship the existing microdata leaves implicit. Populate every value from live product, store, and stock data.

<script type="application/ld+json">
{
  "@context": "https://schema.org/",
  "@type": "Product",
  "@id": "https://yourstore.com/cast-iron-skillet-26cm#product",
  "name": "Cast Iron Skillet 26cm",
  "url": "https://yourstore.com/cast-iron-skillet-26cm",
  "image": "https://yourstore.com/media/catalog/product/skillet.jpg",
  "description": "Pre-seasoned cast iron skillet, oven-safe to 260°C.",
  "sku": "SKILLET-26",
  "mpn": "SK-26-CI",
  "gtin13": "0123456789012",
  "category": "Kitchen > Cookware > Skillets",
  "brand": { "@type": "Brand", "name": "Your Brand" },
  "aggregateRating": {
    "@type": "AggregateRating",
    "ratingValue": "4.7",
    "reviewCount": "128"
  },
  "offers": {
    "@type": "Offer",
    "url": "https://yourstore.com/cast-iron-skillet-26cm",
    "priceCurrency": "EUR",
    "price": "89.00",
    "availability": "https://schema.org/InStock",
    "itemCondition": "https://schema.org/NewCondition",
    "hasMerchantReturnPolicy": {
      "@type": "MerchantReturnPolicy",
      "returnPolicyCategory": "https://schema.org/MerchantReturnFiniteReturnWindow",
      "merchantReturnDays": 30
    },
    "shippingDetails": {
      "@type": "OfferShippingDetails",
      "shippingRate": { "@type": "MonetaryAmount", "value": "4.90", "currency": "EUR" },
      "shippingDestination": { "@type": "DefinedRegion", "addressCountry": "NL" }
    }
  }
}
</script>

aggregateRating should only be emitted when real review data exists for that product — don’t copy the sample values above as a placeholder; Google treats fabricated ratings as a policy violation.

mpn, hasMerchantReturnPolicy, and shippingDetails are optional but increasingly expected for Google Merchant Center parity — include them if the data exists, skip otherwise rather than hardcoding placeholders.

Reuse the same source values the existing microdata already computes — $product->getIsSalable() for availability, the same final price and currency — so the two blocks never disagree. The existing Offer/image microdata doesn’t need to be removed; it’s incomplete rather than harmful, and adding JSON-LD alongside it doesn’t create a conflict since the microdata was never linked to a Product in the first place.

Validation checklist

  1. Check raw source, not the rendered DOM — “View Page Source,” or fetch with curl. If the JSON-LD isn’t in that output, server-rendering has failed and it won’t reach any non-JS consumer.
  2. Run Google’s Rich Results Test and confirm it reports a Product, not just an Offer.
  3. Cross-check with validator.schema.org for the full entity graph, including nested Offer, brand, and rating.
  4. Compare the product URL across three places — the page’s canonical tag, Product.url, and offers.url — they should all resolve to the same URL.
  5. Compare price and availability in the JSON-LD against the existing microdata — a mismatch is worse than either being absent.
  6. Spot-check a configurable, an out-of-stock, and a discounted product, since these are where mappings usually break.

The takeaway

Hyvä’s server-rendered HTML and live price/availability computation are genuine strengths. The missing piece is one wrapper: a declared Product entity that ties the existing data together. A server-side JSON-LD block adds that connection explicitly, without needing to touch or remove what’s already there.

FAQ

Does Hyvä have zero structured data for products?

No. price.phtml emits Offer-level microdata, and the gallery emits itemprop="image". What’s missing is the schema.org/Product wrapper that would connect those fragments into one entity.

Can I just use a schema plugin instead of editing the template?

Yes, provided the plugin outputs JSON-LD server-side. Verify it in raw page source; tag-manager-based or JS-rendered solutions won’t reach clients that skip JavaScript execution.

Is a connected Product JSON-LD enough on its own for AI shopping surfaces?

It’s one necessary piece, not the whole picture. Crawler access in robots.txt, page speed and crawlability, and (for Google’s surfaces specifically) a complete Merchant Center feed all factor in separately. A declared Product entity removes a common structural blocker; it doesn’t substitute for the rest.