Shopify Structured Data in Liquid: Find the Duplicates

In a current Shopify theme, product and article structured data comes from one Liquid filter: {{ product | structured_data }} inside a <script type="application/ld+json"> tag. It writes a schema.org Product or ProductGroup for products and an Article for blog posts. The thing to check is where your theme calls it, because Dawn and Horizon both call it from featured-product sections as well as from the product page.

We traced this through the theme source, not a live store. The testing section near the end says what we ran and what we could not.

What does the structured_data filter output?

The filter reference says it works on the product and article objects only. Products are output “as a schema.org Product if they have no variants, and a ProductGroup if they have one or more variants.” Read “no variants” as a product with only the default variant, since every Shopify product has at least one.

The documented example output for a single-variant product carries @id (a relative /products/crocodile-tears#product), brand from the vendor, category, description, image, name, a single Offer with availability, price, priceCurrency and a variant URL, and the product url. Two details in that sample are easy to miss. The @id is relative, not absolute. And category and description are empty strings in that sample: the filter outputs what the product record holds and fills no gaps.

That limit bites hardest on variants. Google’s product variant guidelines say “Each variant must have a unique ID in its corresponding structured data markup (for example, using the sku or gtin properties).” If the merchant never filled in SKUs or barcodes, no theme code, filter or otherwise, can supply them.

Where does Shopify structured data come from in Liquid?

Dawn switched to the filter in pull request #3380, merged on 30 May 2024 and first shipped in Dawn 15.0.0. The PR description is short: “Replace product and article structured data generated in the theme with structured data generated by the structured_data filter.” Before that, main-product.liquid built a Product by hand with an offers array looping over every variant and picking gtin12, gtin13 or gtin14 from the barcode length. A theme forked from Dawn 14 or earlier still carries hand-built markup like it unless someone took it out.

To see what your theme does today, search it. Run this from the theme root, either a Git checkout or the output of shopify theme pull:

grep -rn --include='*.liquid' -e 'structured_data' -e 'application/ld+json' \
  layout sections snippets blocks templates 2>/dev/null

On Dawn v16.1.1 that finds JSON-LD in four sections: header.liquid (an Organization, plus a WebSite block on the home page only), main-article.liquid, main-product.liquid and featured-product.liquid. On Horizon (main branch, commit 99604908, 4 November 2026) it finds header.liquid, main-blog-post.liquid, product-information.liquid, and two more product sections: featured-product.liquid and featured-product-information.liquid, both passing section.settings.product to the filter.

Reading those templates, a Dawn home page with two featured-product sections emits two Product or ProductGroup items for two different products. A product template with a featured-product section underneath, showing a related item, emits two different products on one product page. Google’s product snippet guidelines say product rich results “only support pages that focus on a single product (or multiple variants of the same product)”, and recommend “focusing on adding markup to product pages instead of pages that list products or a category of products.”

If your grep turns up a hand-built "@type": "Product" block as well as a filter call in the same section, you have the older duplicate: two descriptions of the same product, with no shared @id, that can disagree on price or availability.

How do you audit a product page’s JSON-LD?

The grep tells you what the templates can emit. This script tells you what one rendered page did emit. It has no dependencies, and we ran it on Node 24.12.0 only.

// jsonld-audit.mjs: list the JSON-LD items a page emits and flag product problems.
// Usage: node jsonld-audit.mjs <url-or-saved-html-file>
import { readFile } from 'node:fs/promises';

const target = process.argv[2];
if (!target) {
  console.error('Usage: node jsonld-audit.mjs <url-or-file>');
  process.exit(2);
}

async function loadHtml(src) {
  if (/^https?:\/\//.test(src)) {
    const res = await fetch(src, { headers: { 'user-agent': 'jsonld-audit' } });
    if (!res.ok) throw new Error(`${res.status} ${res.statusText} for ${src}`);
    return res.text();
  }
  return readFile(src, 'utf8');
}

const types = (item) => [].concat(item?.['@type'] ?? []);
const isProduct = (item) =>
  types(item).some((t) => t === 'Product' || t === 'ProductGroup');

const html = await loadHtml(target);
const blockRe =
  /<script[^>]*type=["']?application\/ld\+json["']?[^>]*>([\s\S]*?)<\/script>/gi;
const items = [];
const problems = [];
let block = 0;

for (const match of html.matchAll(blockRe)) {
  block += 1;
  let data;
  try {
    data = JSON.parse(match[1]);
  } catch (err) {
    problems.push(`block ${block}: not valid JSON (${err.message})`);
    continue;
  }
  const top = Array.isArray(data) ? data : (data['@graph'] ?? [data]);
  for (const item of top) items.push({ block, item });
}

for (const { block: b, item } of items) {
  console.log(`block ${b}: ${types(item).join(',') || '(no @type)'}`,
    item['@id'] ?? '(no @id)', item.name ?? '');
}

const products = items.filter(({ item }) => isProduct(item));
// Two blocks with the same @id describe one node, so count distinct ids.
const distinct = new Set(products.map(({ block: b, item }) => item['@id'] ?? `block ${b}`));
if (distinct.size > 1) {
  problems.push(`${distinct.size} different top-level Product/ProductGroup items; ` +
    'product rich results expect a page about one product');
}

for (const { block: b, item } of products) {
  if (!types(item).includes('ProductGroup')) continue;
  const variants = [].concat(item.hasVariant ?? []);
  const ids = variants.map((v) => v['@id']).filter(Boolean);
  if (new Set(ids).size < ids.length) {
    problems.push(`block ${b}: ${ids.length} variants share ` +
      `${new Set(ids).size} distinct @id value(s)`);
  }
  const noId = variants.filter((v) =>
    !v.sku && !Object.keys(v).some((k) => k.startsWith('gtin')));
  if (noId.length) {
    problems.push(`block ${b}: ${noId.length} of ${variants.length} ` +
      'variants have no sku or gtin');
  }
  if (!item.productGroupID) problems.push(`block ${b}: no productGroupID`);
}

if (problems.length) {
  console.log('\nProblems:');
  for (const p of problems) console.log(`- ${p}`);
  process.exitCode = 1;
} else {
  console.log('\nNo product problems found.');
}

It exits 1 when it finds a problem, so it can sit in a post-deploy check. Two limits. It reads the HTML the server sends, so JSON-LD that an app injects with client-side JavaScript will not appear; for that, use Google’s Rich Results Test or Search Console’s URL Inspection, which Google’s docs point to for checking how Google sees the page. And the regex is enough for theme output, not a general HTML parser.

The variant checks exist because of a Shopify Community report from July 2024 that the filter gave every hasVariant entry the same @id, and that the Rich Results Test then showed only the first variant. That is one developer’s report, and we saw no reply from Shopify staff in the thread; we have not been able to confirm whether current output still does it, which is why the script checks the page rather than asserting the answer.

Delete the JSON-LD block from the featured-product section and leave the one in the main product section. If a merchant builds single-product landing pages on a page template around that section, keep it for that case only:

{%- comment -%}
  Product JSON-LD belongs to the page's one main product. On the home page
  or under a product template this section adds a second product, so only
  emit it on a page template built around this section.
{%- endcomment -%}
{%- if request.page_type == 'page' and product != blank -%}
  <script type="application/ld+json">
    {{ product | structured_data }}
  </script>
{%- endif -%}

page is one of the documented request.page_type values. In Dawn, product is assigned from section.settings.product at the top of featured-product.liquid; in Horizon, use section.settings.product directly. Two featured-product sections on one landing page would still emit two products, so the guard assumes one per page.

While you are in Dawn’s product section, shopify theme check flags UnusedAssign on the seo_media assignments around line 740 of main-product.liquid. It fed the old hand-built image property and has had no reader since the switch, so it is safe to remove.

What we tested, and what we did not

We ran the grep against Dawn v16.1.1 and Horizon at commit 99604908. We ran the audit script on Node 24.12.0 against saved HTML fixtures and, through a local server, against the same pages over HTTP. The fixtures are ours: one uses Shopify’s documented example output verbatim, and the others are built to match the shapes described above (two products on a home page, a ProductGroup with shared variant @id values shaped after the community report, a broken hand-built block, an @graph wrapper). We applied the Liquid guard to a copy of Dawn v16.1.1 and ran shopify theme check with Shopify CLI 4.9.4: no offences in that file, and the same offence list across the theme as before the change.

We did not render any of this on a live store. A request to Shopify’s Dawn demo store returned HTTP 429 and we stopped there, so the filter’s current ProductGroup output, including whether variants still share an @id, is unverified here. Run the script against your own product pages before you act on that part.

What we would do

Keep the filter. The Dawn pull request’s stated reason for the switch was that updating themes to keep up with structured data developments was “pretty painful”, and a fork inherits that maintenance if it goes back. Remove it from featured-product sections, remove any hand-built Product block that sits beside it, and fix SKUs and barcodes in the product data rather than in Liquid. Hand-write a ProductGroup only if the audit shows a problem the filter causes on your store, and then delete the filter call in that section so you do not swap one duplicate for another.

If you are tidying a theme’s markup around a migration, our Shopify migration SEO checklist covers the redirect and canonical checks that go with it. Our Theme Check in CI post shows how to stop lint offences like the one above coming back. We build and audit Shopify themes for merchants as part of our Shopify development work.

Need this built properly?

Get in touch