Shopify Builds>Three Ships>Hand-authored per-market JSON-LD for the Bundles & Kits collection

Hand-Authored Per-Market JSON-LD for the Bundles & Kits Collection

One Liquid snippet in the layout's head carries a complete schema.org graph for the Bundles & Kits collection — breadcrumb, collection page, a product list with offers, and the page's FAQ — written twice, once for the Canadian storefront and once for the US one.

A Shopify theme emits no entity graph for a collection page — no ItemList, no Offer per listed product, no FAQPage. On Three Ships we hand-authored one for the Bundles & Kits collection, in two versions, one per storefront domain, each a single @graph of BreadcrumbList, CollectionPage, an ItemList of Product-with-Offer nodes and a six-question FAQPage, cross-referenced by @id to the Organization node the layout already emits.

The Problem

Shopify doesn't emit structured data; themes do, and a theme's collection template usually emits none. Palo Alto 5.8.0 marks up the organization in the layout and the product on the product page, and leaves the collection page as it is: a grid of products a crawler can see and a machine can't read as a list. There's no ItemList, no Offer for any product in the grid, no FAQPage behind the accordion at the bottom of the page.

That gap matters most on exactly this kind of page. A bundle collection is the page an answer engine reaches for when someone asks which set suits dry skin or whether a routine saves money over buying the pieces. If the page carries nothing machine-readable, the answer is assembled from whatever the engine can scrape from the grid — or from another site.

The Constraint

Three Ships runs two storefronts on two domains, Canadian and US, in two currencies. Every entity that appears in a collection graph — the canonical URL of the page, the URL of each product, the priceCurrency of each offer, the country each offer serves, the language of the page — differs between them. A single graph written for one domain would be wrong on the other, and a Liquid loop over collection.products can't state the things a hand-written graph can: which products are Routines, Duos or Kits; which country an offer is for; the six questions and answers the page's FAQ accordion shows.

So the graph is hand-authored rather than looped, and because it's hand-authored, each storefront needs a version of its own. The snippet also had to fit the head the theme already builds: the layout emits an Organization node of its own, and the collection graph had to refer to that node rather than declare a second one.

What We Built

bundles-kits-collection-schema.liquid carries two graphs, one written for the Canadian storefront and one for the US storefront, and emits only the one for the domain serving the request. Each is a single <script type="application/ld+json"> with one @graph of four nodes.

A BreadcrumbList — Home, Shop All, Bundles & Kits — with an @id on the collection URL. A CollectionPage with its own name and description, inLanguage set to en-CA or en-US, a breadcrumb reference to the list above, a mainEntity reference to the product list below, and a publisher reference to /#organization on the storefront's own domain. An ItemList whose every ListItem wraps a Product node: name, the product's URL on that domain, a Brand of Three Ships, a category of Routines, Duos or Kits, an image, and an Offer carrying priceCurrency in that storefront's currency, price, availability, the offer's url, and an areaServed Country of Canada or United States. And a FAQPage of six Question/Answer pairs — how much a bundle saves, whether a shopper can build their own, which bundle suits firming, which suits dry or dehydrated skin, which suits a beginner, how to choose — the same six the collection page's accordion section displays.

The nodes reference each other by @id rather than nesting, and the most important reference leaves the file. theme.liquid emits the site's Organization node with an @id built from request.origin plus /#organization. The collection graph's publisher names that same identifier, so a consumer merging the page's graphs finds one organization, defined once in the layout, referenced from the collection page.

theme.liquid · HEADbundles-kits-collection-schema.liquid · ONE @graph PER STOREFRONT Organization node @id: request.origin + /#organization emitted on every page Render gate page_type == 'collection' collection.handle == 'bundles-kits' BreadcrumbList Home › Shop All › Bundles & Kits CollectionPage inLanguage · breadcrumb · mainEntity publisher → #organization ItemList ListItem › Product › Offer priceCurrency · areaServed FAQPage six Question / Answer pairs mirroring the page's accordion Snippet re-checks the handle emits nothing for any other collection even if rendered from elsewhere renders breadcrumb mainEntity publisher, by @id
Two files, one identifier. The layout emits the Organization node and builds its @id from the request origin; the collection graph names that same identifier as its publisher, so the page resolves to one organization defined once. Reconstructed from the code — no record of the design decision survives.

The wiring is in the layout. theme.liquid renders the snippet in <head> behind request.page_type == 'collection' and collection.handle == 'bundles-kits', and the snippet checks the handle again on its own before emitting anything.

Why This Way

Referencing by @id instead of nesting keeps one graph per page and lets the collection page point at an organization it doesn't define. Nesting an Organization inside the CollectionPage would have put a second, competing definition of the brand on the page next to the layout's; a reference makes the layout's node the only one. The same principle holds inside the graph — the breadcrumb and the item list are declared once and referenced, not repeated inside the page node.

Hand-authoring is what makes the graph say more than the catalog does. The category values, the areaServed country on each offer, the page's name and description written for the market, and the six answers are things no Shopify object holds; a loop over collection.products could name the products and stop there.

Why Not an App

An SEO app emits what it can infer from Shopify objects. On a collection page that's a CollectionPage, sometimes a BreadcrumbList, occasionally a product list built from the grid. It has no way to know which products are Routines and which are Duos, which country an offer is for, or which six questions a shopper actually asks about bundles — none of that is a Shopify field. The app's output is generic by construction, which is fine for the long tail of collections and not fine for the one collection an answer engine is asked about most. What the graph names instead comes from the merchandising taxonomy the collection already uses and from the questions its own accordion answers — both authored, neither derivable.

Implementation Notes

  • Two graphs in one snippet file, roughly 1,100 lines between them, each a complete @graph for one storefront: the Canadian storefront with CAD, en-CA and areaServed Canada; the US storefront with USD, en-US and areaServed United States.
  • Every @id is an absolute URL on the storefront's own domain with a fragment — #breadcrumb, #collectionpage, #itemlist, #faq — so the four nodes are addressable and the CollectionPage references them by identifier.
  • The publisher reference is to /#organization on the same domain, which matches the @id the layout builds from request.origin for its Organization node.
  • areaServed is set per Offer as a Country, so the market is stated on the offer itself rather than inferred from the currency.
  • Each Product carries a category of Routines, Duos or Kits — a merchandising taxonomy the collection has and Shopify's product model doesn't.
  • The six Question names are the six headings in the collection page's accordion section, so the FAQPage describes content a shopper can see on the page.
  • The snippet is rendered from the layout's <head>, not from the collection template, so it's present regardless of which sections the template mounts.

Edge Cases

  • Any other collection: the layout's gate never renders the snippet, and the snippet's own handle check emits nothing even if it were rendered from somewhere else.
  • A product page for one of the bundles: the gate is on request.page_type == 'collection', so the graph appears only on the collection, and the product page keeps its own product markup.
  • On either storefront: the layout's Organization @id and the graph's publisher reference are the same identifier, so the page resolves to one organization.
  • A consumer that flattens the graph without following @ids still finds every node fully declared, because each is a top-level member of @graph rather than a bare reference.

Platform Primitives Used

  • Structured data (JSON-LD) — the graph itself: four node types, @id-referenced, one @graph per storefront.
  • Liquid snippets — the graph lives in a snippet rendered from the layout, gated on request type and collection handle.

Where It Runs

One collection on both storefront domains. The snippet is rendered from theme.liquid's head whenever the request is the Bundles & Kits collection page, so it applies to whichever collection template that collection uses and to no other page. Both versions were confirmed emitting on the live storefronts, each with its own currency, language and organization reference.

What This Demonstrates

  • Fixed bundles and kit merchandising — the primary capability: the collection where bundles are merchandised is the one that carries a machine-readable list of them, with a category per set and the questions shoppers ask.
  • Structured data for AI answers and rich results — a hand-authored entity graph forked per market so each storefront's collection resolves to its own entities.
  • International SEO and hreflang — the same graph in two market-specific versions, each with its own canonical URLs, currency and language.

How We Know

One snippet file, roughly 1,100 lines, read from the theme, plus the layout that renders it and the layout's own Organization node. Both graphs were confirmed present on the live storefronts, with the expected currency, language and organization identifier in each. Documented from the code; no client-side record of the decision survives. The structure is verifiable; the reasoning above is our reading of it.

Related Builds

The Buy-vs-Build Question

An SEO app buys generic collection markup kept current without attention; a hand-authored graph buys categories, countries and answers no Shopify field holds, at the cost of editing it by hand. Where that line falls is argued at structured data and schema markup: buy or build?.

Provenance & Evidence

  • Client: Three Ships — threeshipsbeauty.ca and threeshipsbeauty.com
  • Surface: Collection listing
  • Templates served: whichever collection template the Bundles & Kits collection uses, on both domains
  • Complexity: Medium — one snippet, roughly 1,100 lines across two graphs
  • Attribution: Deploi-authored. The snippet and both graphs are ours. It's rendered by the layout of Palo Alto 5.8.0 by Presidio Creative, a paid premium theme the client licenses, and its publisher reference points at the Organization node that layout emits.
  • Status: Live, verified 2026-09-07
  • Evidence: the snippet and the layout, read from the theme; both graphs confirmed on the live storefronts
  • Confidence: Moderate — documented from the code; no client-side record of the decision survives
  • Not claimed: any rich-result outcome, any citation by an answer engine, any figure for what a bundle saves
  • Primary capability: Fixed bundles and kit merchandising

Ready to Give Your Best Collection a Graph a Machine Can Read?

If the collection you'd most like an answer engine to quote is still a grid and nothing else, the markup is a snippet away — and the harder question is which fields are worth writing by hand. Contact us today and we'll go through your collections with you.

More builds