We audit Shopify Schema.org output for a living. Out of every ten PDPs we crawl, seven ship with at least one error that blocks the rich result — and therefore degrades AI citation share. Most merchants don't know because the errors don't throw, they silently return a downgraded rich result or no snippet at all. This post is the twelve errors we see most, the one-line fix for each, and the validation stack that keeps them from coming back after the next app update.
The problem with ship-and-forget
Schema.org output on Shopify is usually shipped once during theme setup, validated by one pass through Google's Rich Results Test, and then forgotten. The problem: schema regresses constantly. An app install adds duplicate Product nodes. A theme update changes how brand is emitted. A metafield rename breaks the additionalProperty map. A reviewCount drops to zero after a store reset. None of these throw an error in production. All of them silently degrade or hide the rich result.
The fix is never "write correct schema." You already did that. The fix is a validation stack that runs continuously and catches regressions before they land.
The twelve errors we see on 70% of Shopify PDPs
This list is ranked by impact on AI citation share based on our Q1 2026 audit dataset (131 Shopify stores). The first five are the ones we fix first on every engagement.

1. Missing Offer.priceValidUntil
Google requires priceValidUntil on Product offers since the 2023 guideline update. Merchants who set up schema earlier often skip it. The Rich Results warning is "Missing field priceValidUntil (optional)." The word "optional" is misleading — without it, the offer ships but with reduced eligibility for the price-drop rich result. Fix: emit today+30d on every render. Never a static date.
2. Invalid Offer.availability enum
Every Shopify theme we audit has at least one product page emitting free-text values like "In Stock," "Out of Stock," or "Pre-order" for availability. These are not valid. The spec expects the full enum URL. Fix:
// Wrong
"availability": "In Stock"
// Right
"availability": "https://schema.org/InStock"3. Product.brand as a string
Another 2023+ tightening: brand must be an object with @type: Brand, not a raw string. Shopify's default product schema snippet ships the string form. Fix:
"brand": {
"@type": "Brand",
"name": "Alora"
}4. aggregateRating.reviewCount equals zero
Review apps default to emitting aggregateRating on every product — even products with zero reviews. Google's logic: if reviewCount is zero, silently hide the entire rich result rather than risk showing an empty rating. Fix: suppress the aggregateRating object entirely when reviewCount === 0. In Liquid:
{% if product.metafields.reviews.rating_count > 0 %}
"aggregateRating": {
"@type": "AggregateRating",
"ratingValue": {{ product.metafields.reviews.rating }},
"reviewCount": {{ product.metafields.reviews.rating_count }}
},
{% endif %}5. Relative image URLs
Shopify's CDN serves images at paths like /cdn/shop/files/alora-hero.jpg. Emitting that relative path into Product.image means Google (and GPTBot, and Perplexity) can't resolve it without the domain. Fix: prefix with the canonical domain on every emission.
6. Duplicate Product nodes
The most frustrating error because it's invisible in DevTools unless you grep. An example we see weekly: theme emits a full Product node, and a review app (often Judge.me, Yotpo, or Okendo with default settings) emits its own stripped-down Product node to attach reviews. Google picks whichever it parses last. Fix: view-source, grep for type":"Product, count the results. If > 1, disable one source.
7. Missing Offer.shippingDetails
Agents (ChatGPT Atlas, Perplexity Pro, Claude with browser) now check Offer.shippingDetails.eligibleRegion before attempting cart-add. Missing it means the agent aborts and recommends a competitor with fuller schema. Fix:
"shippingDetails": {
"@type": "OfferShippingDetails",
"shippingRate": {
"@type": "MonetaryAmount",
"value": "0",
"currency": "USD"
},
"shippingDestination": {
"@type": "DefinedRegion",
"addressCountry": "US"
},
"deliveryTime": {
"@type": "ShippingDeliveryTime",
"handlingTime": { "@type": "QuantitativeValue", "minValue": 0, "maxValue": 1, "unitCode": "DAY" },
"transitTime": { "@type": "QuantitativeValue", "minValue": 2, "maxValue": 5, "unitCode": "DAY" }
}
}8. Review without itemReviewed back-reference
Review nodes often emit in isolation — just the review content, author, and datePublished — without linking back to the Product. Google treats these as orphans. Fix: every Review node gets itemReviewed: { "@id": productId }.
9. Relative URLs in BreadcrumbList
Same issue as error 5 but on breadcrumbs. listitem.item must be an absolute URL or the whole breadcrumb chain silently fails. Fix: template your breadcrumbs with the canonical domain prefix.
10. WebSite without @id
Advanced-graph stores that emit Organization, WebSite, and Product nodes expecting them to link often forget to give WebSite a stable @id. Without it, the graph breaks and Organization.sameAs references go nowhere. Fix: always @id: "https://domain.com#website".
11. Stale priceValidUntil
We've seen stores shipping priceValidUntil: "2024-01-01" in 2026. The date was hardcoded during initial setup and never regenerated. Rich Results warns, AI engines notice. Fix: generate on every build. In Hydrogen loader, return new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString().slice(0, 10).
12. Single Offer for multiple variants
When a product has variants (sizes, colors, finishes), offers must be an array of Offer objects — one per variant — not a single Offer. Single-Offer emission means agents see only one price, usually the cheapest or the first variant, and can't fulfill buy commands for the others. Fix: iterate variants and emit one Offer per variant with a stable sku keyed to the Shopify variant ID.
The four-layer validation stack
Fixing the twelve errors once is a theme change. Keeping them fixed forever is where most merchants regress. The stack below is what our highest-performing clients (90%+ citation share, 95+ QPA scores) run in production.

Layer 1 — Google Rich Results Test (manual, one-off)
The default starting point. Paste a URL into search.google.com/test/rich-results and read the report. Catches ~60% of the twelve: syntax, missing required fields, invalid enums. Misses duplicate nodes and cross-node references. Run this on every PDP template after any theme change.
Layer 2 — Schema.org Validator (stricter)
validator.schema.org runs the spec strictly. Catches everything Rich Results catches plus cross-node reference failures, orphan Reviews, BreadcrumbList URL resolution, @id linkage. Coverage: ~80%. Run after Layer 1 passes.
Layer 3 — Custom unit tests in CI
This is where most merchants stop, and it's where the highest-leverage fixes live. A small Vitest (or Jest) test that fetches your PDP via HTTP, parses the JSON-LD block, and asserts shape against a JSON schema. Example:
import { describe, it, expect } from "vitest";
import Ajv from "ajv";
import { productSchemaJSON } from "./schemas/product.json";
const ajv = new Ajv({ allErrors: true });
const validate = ajv.compile(productSchemaJSON);
describe("PDP JSON-LD", () => {
it("alora-72 matches Product schema", async () => {
const html = await fetch("https://yourshop.com/products/alora-72").then((r) => r.text());
const ld = extractJSONLD(html);
expect(validate(ld)).toBe(true);
expect(ld.offers).toHaveLength(6); // 6 variants
expect(ld.aggregateRating.reviewCount).toBeGreaterThan(0);
expect(ld.brand["@type"]).toBe("Brand");
expect(ld.offers[0].priceValidUntil).toMatch(/^\d{4}-\d{2}-\d{2}$/);
});
});Add this to your CI on every PR. The test breaks when a regression lands; no one merges until fixed. Coverage: ~95%.
Layer 4 — Live URL monitoring (weekly cron)
CI catches what developers break. It doesn't catch what ops breaks — app installs, metafield edits, theme-settings flips. Layer 4 is a scheduled cron that runs weekly against production URLs, hashes the JSON-LD, diffs vs. the baseline hash, and Slacks on drift.
This is where Surfient fits — our platform runs this weekly for every client and the drift alerts are how we catch 60% of the regressions. You can build it yourself in 30 lines of Node if you don't need the full platform.
The ten-minute audit you can run right now
- Open your top-revenue PDP and paste it into Google's Rich Results Test. Note every warning and error.
- Paste the same URL's raw JSON-LD into validator.schema.org. Compare findings.
- View-source, grep for type":"Product — if you see more than one match, you have duplicate nodes.
- Check Offer.availability — if it's a string like "In Stock" instead of a URL, it's invalid.
- Check Offer.priceValidUntil — is it missing or in the past? Both block the rich result.
- Check Product.brand — is it a string or an object? Only the object form is valid.
- Check aggregateRating.reviewCount — if it's 0 on a product with no reviews, suppress the whole aggregateRating node.
- Check Product.image URLs — absolute or relative? They must be absolute.
- If you find any of the twelve errors in this post, you have a schema bug. Fix it, re-run step 1, confirm green.