Why JSON-LD belongs in theme Liquid, not in an app
Theme-level JSON-LD is authored once, lives with the page, and cannot be blocked. App-injected schema depends on JavaScript execution and sometimes gets skipped by retrievers.
Shopify merchants have two main options for emitting JSON-LD: write it directly into theme Liquid, or have a Shopify app inject it at render or via client-side JavaScript. Theme-level is almost always the right answer, for three reasons. Theme-emitted JSON-LD is present in the server-rendered HTML that every retriever sees on the first fetch — no JavaScript dependency, no injection timing issues. It is also auditable: you can read exactly what ships by inspecting the .liquid files. And it is resilient: the app can uninstall, the subscription can lapse, the integration can break silently, but the theme snippet keeps working until someone explicitly removes it.
App-injected JSON-LD has its place — mostly when the data source is an external system the app integrates with — but even then the app typically writes Liquid rather than injecting client-side, because the retrieval reliability story is so much better for server-rendered schema. If you are evaluating an app that promises 'AI-ready schema' and the mechanism is client-side JavaScript injection, that is a red flag worth asking about.
19%
of Shopify stores in our audit panel have client-side-only JSON-LD that retrievers can skip
Surfient crawl audit, 847 Shopify stores, Q1 2026. The 19% emit JSON-LD only via JavaScript after page load, meaning retrievers that do not execute JS (which is most of them) never see the structured data.
What Dawn and OS 2.0 themes emit by default — and when to override
Dawn ships some Product, Organization, and WebSite schema automatically. You need to know what so you do not double-emit.
Dawn — Shopify's reference OS 2.0 theme and the basis for most modern Shopify themes — ships JSON-LD automatically on several page types. Any schema you add should check what is already there, because two Product JSON-LD blocks on the same page confuse retrievers and cause validators to pick whichever one they find first (which might be the less-complete default).
- Product pages (Dawn)
- Dawn emits a basic Product schema with name, description, image, sku, offers (price + availability), and brand. It does NOT emit aggregateRating, review, additionalProperty, or custom fields you might want.
- Collection pages (Dawn)
- Dawn does not emit CollectionPage schema by default. Opportunity to add it without overlap.
- Article pages (Dawn)
- Dawn emits basic Article schema with headline, image, datePublished, and author. Often under-populated.
- Home / store pages (Dawn)
- Dawn emits WebSite schema (with SearchAction) and Organization schema. Both are usually fine; review them before duplicating.
- Breadcrumbs
- Dawn does NOT emit BreadcrumbList by default. High-ROI add.
- FAQ blocks
- Dawn does NOT emit FAQPage schema. You must add it if you want FAQ schema.
To see exactly what your current theme ships, use Google's Rich Results Test on a live product page, a live collection page, and your homepage. It will list every JSON-LD block it finds, with field-level parsing — so you know before you add anything whether you are filling a gap or colliding with an existing block.
The safe Liquid pattern for Product JSON-LD
Put schema in a dedicated snippet. Use the | json filter on every value. Render conditionally and test with a regression check.
File: snippets/schema-product.liquid
{%- if product -%}
{%- assign first_variant = product.selected_or_first_available_variant -%}
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "Product",
"name": {{ product.title | json }},
"description": {{ product.description | strip_html | truncate: 300 | json }},
"url": "{{ shop.url }}{{ product.url }}",
"sku": {{ first_variant.sku | json }},
"image": [
{%- for img in product.images limit: 4 -%}
{{ img | image_url: width: 1200 | prepend: "https:" | json }}{%- unless forloop.last -%},{%- endunless -%}
{%- endfor -%}
],
"brand": {
"@type": "Brand",
"name": {{ product.vendor | json }}
},
"offers": {
"@type": "Offer",
"price": {{ first_variant.price | money_without_currency | json }},
"priceCurrency": {{ cart.currency.iso_code | json }},
"availability": {%- if first_variant.available -%}"https://schema.org/InStock"{%- else -%}"https://schema.org/OutOfStock"{%- endif -%},
"url": "{{ shop.url }}{{ product.url }}"
}
}
</script>
{%- endif -%}Include it from sections/main-product.liquid
{%- comment -%} Delete Dawn's default Product schema first, then: {%- endcomment -%}
{%- render "schema-product" -%}Centralising schema includes in layout/theme.liquid
Render all schema snippets in one place with template-type conditionals. Makes audit and change straightforward.
{%- comment -%} In layout/theme.liquid, just before </head> {%- endcomment -%}
{%- render "schema-organization" -%}
{%- if template contains "product" -%}
{%- render "schema-product" -%}
{%- render "schema-breadcrumb" -%}
{%- render "schema-faq" -%}
{%- elsif template contains "collection" -%}
{%- render "schema-collection" -%}
{%- render "schema-breadcrumb" -%}
{%- elsif template contains "article" -%}
{%- render "schema-article" -%}
{%- render "schema-breadcrumb" -%}
{%- elsif template == "index" -%}
{%- render "schema-website" -%}
{%- endif -%}- One place controls every schema emission — easy to audit, easy to change.
- Each snippet is self-contained and handles its own guard clauses (no-op if the data it needs is missing).
- Adding new schema types is a two-line change (new snippet + new render line).
- Removing a schema type from a template type is a one-line delete.
- Rich Results Test and schema validators can test each template type independently because the emission is predictable.
Validation and regression testing — catching silent breakage
Theme edits break JSON-LD silently if you do not test. Rich Results Test plus an in-repo regression check catches both classes.
Silent breakage is the real risk with theme-level JSON-LD. A developer or agency edits main-product.liquid six months from now to add a new feature, accidentally wraps the product title in double-quotes instead of using the json filter, and every product page now ships invalid JSON. The theme looks fine, analytics look fine, and AI citations quietly decline for weeks until someone notices.
- Google Rich Results Test
- Run on one product, one collection, one article, and the homepage after every theme ship. Manual but fast. Catches gross errors.
- Schema.org validator
- Stricter than Google's. Catches type errors and missing required fields. Worth running on major changes.
- In-repo Playwright regression test
- A test that loads each template type, parses every JSON-LD block, and asserts shape and required fields. Runs on every commit. The only reliable protection against silent breakage.
- Live-site continuous monitoring
- A service that crawls your live site periodically and flags schema regressions. Catches the breakage that slipped past the regression test (cache issues, CDN quirks, theme-app conflicts).
“The value of schema is that retrievers use it to build confidence. The cost of broken schema is that retrievers use it to lower confidence. You want monitoring that catches broken markup the day it breaks, not the quarter after.”