← Back to blog

Magento 2 Structured Data: Product JSON-LD the Right Way

Rodrigo Biazzi

Structured data tells search engines what a page is about in a format they can parse without guessing. For a store, that mostly means Product with Offer, BreadcrumbList, and Organization or WebSite on the home page. Done right, it makes product pages eligible for rich results with price and availability. Done wrong, it produces warnings in Search Console or, worse, markup that does not match the visible page.

This article covers what Magento 2.4 outputs by default, why I replace it with JSON-LD, and how to build product structured data as a small module with a view model.

What Luma outputs by default

The Luma theme adds schema.org microdata to the product page. The product container has itemscope with type Product, and attributes like itemprop="name", itemprop="sku" and an offer block with price and currency are spread across several templates. Hyvä themes handle this differently, so check what your theme outputs before adding anything.

The default microdata has a few problems:

  • It is incomplete. Fields such as image, brand, availability or identifiers like GTIN are missing or depend on the theme.
  • It is scattered over templates, so any theme override can break it without anyone noticing.
  • It mixes presentation and data, which makes it hard to change without touching HTML.

Google supports JSON-LD, microdata and RDFa, and recommends JSON-LD. A single JSON-LD script built from data in PHP is easier to test and maintain. If you add it, remove the old microdata from your theme overrides, or you will have two Product entities on the page with different values.

Building product JSON-LD

The setup is a module with a view model, a layout file for the product page and a template. The view model collects the data and returns an array. The template encodes it.

The view model

<?php
declare(strict_types=1);

namespace Vendor\StructuredData\ViewModel;

use Magento\Catalog\Helper\Data as CatalogHelper;
use Magento\Catalog\Helper\Image as ImageHelper;
use Magento\Catalog\Model\Product;
use Magento\Framework\View\Element\Block\ArgumentInterface;
use Magento\Store\Model\StoreManagerInterface;

class ProductSchema implements ArgumentInterface
{
    public function __construct(
        private readonly CatalogHelper $catalogHelper,
        private readonly ImageHelper $imageHelper,
        private readonly StoreManagerInterface $storeManager
    ) {
    }

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

        $price = $product->getPriceInfo()->getPrice('final_price')->getAmount()->getValue();
        $currency = $this->storeManager->getStore()->getCurrentCurrencyCode();

        $data = [
            '@context' => 'https://schema.org',
            '@type' => 'Product',
            'name' => $product->getName(),
            'sku' => $product->getSku(),
            'url' => $product->getProductUrl(),
            'image' => $this->imageHelper->init($product, 'product_page_image_large')->getUrl(),
            'description' => trim(strip_tags((string) $product->getData('short_description'))),
            'offers' => [
                '@type' => 'Offer',
                'url' => $product->getProductUrl(),
                'priceCurrency' => $currency,
                'price' => number_format((float) $price, 2, '.', ''),
                'availability' => $product->isSalable()
                    ? 'https://schema.org/InStock'
                    : 'https://schema.org/OutOfStock',
            ],
        ];

        $brand = $product->getAttributeText('manufacturer');
        if (is_string($brand) && $brand !== '') {
            $data['brand'] = ['@type' => 'Brand', 'name' => $brand];
        }

        return (string) json_encode(
            $data,
            JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_AMP
        );
    }
}

A few details matter here. Magento\Catalog\Helper\Data::getProduct() returns the current product set by the product controller, so you avoid using the registry directly in your own code. The final price comes from the price info, which already applies catalog price rules and special prices for the current customer group. JSON_HEX_TAG escapes angle brackets, so a product name containing </script> cannot break out of the script tag.

Replace manufacturer with whatever attribute holds the brand in your catalog. If you have GTIN, EAN or MPN attributes, add them as gtin13 or mpn in the same way. Only output fields that have a real value, since empty strings trigger warnings.

The layout

Create view/frontend/layout/catalog_product_view.xml in the module:

<?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>
        <referenceContainer name="before.body.end">
            <block name="vendor.product.schema"
                   template="Vendor_StructuredData::product-schema.phtml">
                <arguments>
                    <argument name="view_model" xsi:type="object">Vendor\StructuredData\ViewModel\ProductSchema</argument>
                </arguments>
            </block>
        </referenceContainer>
    </body>
</page>

JSON-LD does not have to be in the head. Placing it at the end of the body keeps it out of the critical path. More about layout handles and containers is in the layout XML guide.

The template

<?php
/** @var \Magento\Framework\View\Element\Template $block */
/** @var \Magento\Framework\View\Helper\SecureHtmlRenderer $secureRenderer */
/** @var \Vendor\StructuredData\ViewModel\ProductSchema $viewModel */
$viewModel = $block->getData('view_model');
$json = $viewModel->getJson();
if ($json === '') {
    return;
}
?>
<?= /* @noEscape */ $secureRenderer->renderTag('script', ['type' => 'application/ld+json'], $json, false) ?>

Using SecureHtmlRenderer means the script tag gets a CSP nonce when the page needs one, which avoids problems on stores with a strict Content Security Policy.

Full page cache and price accuracy

Product pages are cached by Varnish or the built in cache, and the JSON-LD is part of the cached HTML. That is fine, because the cache is invalidated when the product changes. Two cases need attention:

  • Customer group prices. The cached page for guests contains the guest price, which is what search engines see. That is correct.
  • Price changes from catalog price rules. Rule prices change at the start and end of a rule, and the cache must be cleaned for those products. If your rules apply on a schedule, make sure the indexer runs and invalidates cache. See catalog price rules indexing.

Google checks that the price in structured data matches the visible price. A mismatch, for example when the visible price is updated by JavaScript but the JSON-LD is not, can get rich results removed.

Configurable products

For a configurable product, the simplest correct approach is one Product with an AggregateOffer that has lowPrice, highPrice and offerCount, built from the salable children. If you want variant level rich results, schema.org has ProductGroup with hasVariant, where each child is a Product with its own offer. It is more work, and the children's URLs must lead to a page that shows that variant selected. Check the current Google documentation for variant markup before building it, since the requirements there have changed over time.

Breadcrumbs, Organization and WebSite

BreadcrumbList on product and category pages helps search engines show the path in results. Build it from the same data the breadcrumb block uses, so the markup and the visible breadcrumb match. Note that the product page breadcrumb in Luma depends on how the customer arrived, while a product URL without category path has no single category. Pick one category per product, for example the deepest one, and use it in both places.

Organization markup with name, logo and sameAs links to official profiles goes on the home page. WebSite with a potentialAction SearchAction pointing to /catalogsearch/result/?q={search_term_string} describes the site search. These are static values and can be stored in config fields of your module.

Reviews and ratings

If the store uses Magento's native product reviews, you can add aggregateRating with ratingValue and reviewCount to the Product. The values come from the review summary Magento keeps per product and store. Two rules apply. Only output it when the product has approved reviews, and only when those reviews are visible on the page. Markup for ratings that the visitor cannot see is against Google's guidelines. Also keep in mind that Magento stores the rating summary as a percentage, so convert it to the five point scale your stars show before putting it in the markup.

If reviews come from an external service, that service often injects its own markup. Check the page source so you do not end up with two ratings on one product.

Testing

  1. Run a product URL through Google's Rich Results Test and the Schema Markup Validator.
  2. Check a product with a special price, one out of stock and one configurable.
  3. View the source of a cached page as a guest to confirm the price is the guest price.
  4. Check Search Console's merchant listings and product snippets reports after release for new errors.

Structured data is one part of technical SEO. The rest of the list is in the Magento 2 technical SEO checklist.

FAQ

Does Magento 2 include structured data by default?

Luma includes basic schema.org microdata on the product page, but it is incomplete. Most stores benefit from replacing it with a single JSON-LD block.

Should JSON-LD go in the head or body in Magento?

Either works for search engines. Placing it in the before.body.end container keeps it out of the head and does not affect rendering.

Will full page cache serve wrong prices in structured data?

Not if cache invalidation works. The page is cleaned when the product or its price index changes. Check that indexers and cron run so rule based prices are refreshed on time.

Do I need to remove Luma microdata when adding JSON-LD?

Yes. Two Product entities with different data on the same page confuse validators and can cause warnings. Remove the microdata attributes in your theme overrides.

I am available for freelance Magento and Adobe Commerce work, including SEO focused development. If you need help with this, get in touch.

Related articles