Shopify collection page schema: what Google reads on a category page, and what the theme emits
Dawn and Horizon emit nothing about the collection on a collection page. What Google reads on a category page, where an ItemList earns a display, and one snippet.
12 min read
Contents — 6 sections
- A category page has no rich result of its own, and Google says so in one sentence
- ItemList earns a display in one beta, in some countries, and nowhere else
- Both reference themes emit one Organization node on a collection page, and nothing about the collection
- Check it in five minutes, on one collection and its second page
- One snippet emits a CollectionPage carrying the page's list, rendered inside the paginate block
- Fix the page-two link first; the markup is an hour
A category page has no rich result of its own, and Google says so in one sentence
Product page markup has a visible pay-off: price, availability and stars in the result. A collection page has no equivalent. Google's merchant listing guide puts the rule in its technical guidelines: product rich results only support pages that focus on a single product or the variants of one product, "shoes in our shop" is not a specific product, and Google recommends adding markup to product pages instead of pages that list products or a category of products. A Product node on /collections/bags is markup on a page the feature excludes.
What Google does read on a category page is set out in two other documents. The ecommerce structured data guide lists the types it considers relevant to a shop — BreadcrumbList, LocalBusiness, Organization, Product and ProductGroup, Review, VideoObject — and no category type is among them. The site structure guide says where the category page's value actually sits: Google analyses the links between pages to infer relative importance, it asks for links from menus to category pages and from category pages to every product, and it recommends structured data to help it understand the purpose of the different pages and reinforce that structure. Reinforce, not replace: the links are the signal, and the markup describes what the links already show.
So a collection page's schema has three legitimate things to say. What this page is, which is a CollectionPage. Where it sits, which is a BreadcrumbList and has its own page. And what it lists, which is an ItemList. The first two are understood everywhere. The third has exactly one Google feature attached to it, and the feature is regional.
ItemList earns a display in one beta, in some countries, and nowhere else
Google documents two carousels built on ItemList. The original carousel is a host carousel for four content types — Course, Movie, Recipe, Restaurant — and products are not on the list. The second, structured data carousels (beta), adds Product, Event and LocalBusiness items, and it is the one that applies to a shop. Read its feature availability paragraph before writing anything: the beta is only available in European Economic Area countries, Türkiye and South Africa, and for products the instructions route through the Comparison Shopping Services programme. For the markets we work in, that means a French store is in scope, a Swiss store is not — Switzerland is not in the EEA — and a UAE store is not. Outside those countries the same ItemList is read and understood and displays nothing.
Where it applies, its rules decide the shape of the markup, and they are worth following everywhere, because they are the only published statement of what a correct product list looks like.
| Rule from the beta guide | What it means on a collection page |
|---|---|
| The page must be a summary or category page with at least three entities, linking out to detail pages | A collection with fewer than three products is out of scope |
Every item needs image, name and url; the URL is the canonical detail page, unique, same domain | The flat /products/handle URL, not a variant or collection-scoped one |
offers is recommended: price and priceCurrency, or lowPrice and highPrice for a range | Offer for a single price, AggregateOffer when variants differ |
| Mark up all items that are on the page | The list must match the grid on this page, not the first fifty products of the collection |
Paginated categories carry one ItemList per page, listing that page's entities | Page two lists page two |
| For infinite scroll, mark up the entities initially loaded in the viewport | The first server-rendered batch, which on Horizon is twenty-four |
The vocabulary underneath adds two things. The schema.org definition of ItemList says the order of elements in the markup is not sufficient to indicate order, so each entry is a ListItem with a position, and it says numberOfItems may describe the entire list even when a page carries only part of it. And CollectionPage is a subtype of WebPage, which is why it can carry a breadcrumb, a description and a main entity: it describes the page, and the ItemList describes the contents.
Both reference themes emit one Organization node on a collection page, and nothing about the collection
Open sections/header.liquid in Dawn and search for ld+json. There are two blocks near the bottom. The first is an Organization — name, logo, the social links from theme settings, and a url — and it renders on every page because the header does. The second is a WebSite with a SearchAction, wrapped in {%- if request.page_type == 'index' -%}, so it renders on the home page only. Both use http://schema.org as their context. Horizon emits the Organization without the social links and nothing else.
Neither theme has a line of JSON-LD in its collection template. Dawn's main-collection-product-grid.liquid paginates collection.products, renders a card per product, renders the pagination snippet, and emits no script. Horizon's main-collection.liquid does the same through a product-grid snippet. The platform adds nothing either: the structured_data filter is documented for product and article objects and nothing else. We confirmed the result on Shopify's own demo stores for both themes on 17 September 2026: /collections/all on each carries exactly one JSON-LD node, and it is the Organization.
What Shopify does inject, through content_for_header, is a pair of link elements: <link rel="next"> on a page with a following page and <link rel="prev"> on a page with a preceding one. Google's pagination guide says in so many words that it no longer uses these tags, although other search engines may. The canonical tag, by contrast, does exactly what the same guide asks — page two is canonical to itself with ?page=2, sorting and filter parameters are stripped — and Shopify canonical URL covers that in full.
That leaves the links, which are what Google actually reads on an untouched collection page, and here the themes differ in a way that matters more than any schema. Dawn shows sixteen products per page by default and renders a pagination snippet of ordinary <a href> links, so page two is one crawlable hop away. Horizon ships with the collection setting "Auto-load products" switched on, which is infinite scroll: the section renders twenty-four products and a <span ref="viewMoreNext"> sentinel, an intersection observer fetches ?page=2 when the sentinel scrolls into view, and no <a href> to page two exists in the HTML. On the demo store the only reference to ?page=2 is the rel="next" link Google ignores. The pagination guide is explicit that Google's crawlers do not click buttons or trigger JavaScript that requires user actions, and that it crawls URLs found in the href attribute of <a> elements. Every product past the first twenty-four is still in the sitemap; what it lacks is the category-to-product link the site structure guide uses to weigh it.
Check it in five minutes, on one collection and its second page
Open your largest collection on the live store and view the source. Search for ld+json. On either reference theme you find one block. If you find more, an app or a previous developer wrote them, and you need to know what they say before you add anything.
On the same page, open DevTools and in the Console run:
[...document.querySelectorAll('script[type="application/ld+json"]')]
.map(s => JSON.parse(s.textContent))
.flatMap(d => Array.isArray(d) ? d : (d['@graph'] ?? [d]))
.map(n => [n['@type'], n.mainEntity?.['@type'] ?? '', (n.itemListElement ?? n.mainEntity?.itemListElement ?? []).length])
It returns one row per node: its type, the type of its main entity if it has one, and how many list items it carries. It is the same line we use for Product nodes, read for the other types. Four things to look for.
- A
Productrow on a list page. An app that writes a product node for every card, or one for the first product, has put merchant listing markup on a page the merchant listing guide excludes. Delete it before anything else; a page asserting sixteen products is not about one thing, and the guideline says Google will not read it as such. - An
ItemListwhose count does not match the grid. Count the cards. A list of fifty on a page showing sixteen was rendered outside thepaginateblock and is describing the collection rather than the page, which breaks the completeness rule in Google's general guidelines — structured data must be a true representation of the page content — and the beta's own rule to mark up the items on this page. - Prices in the list that are not the prices on the cards. A compare-at price, a price in the store's base currency on a market that sells in another, or a variant's price rather than the range. Whatever the list says, the card is what the reader sees, and the visible-content rule applies.
- A
BreadcrumbListwith no visible trail. Neither theme ships one; if the console prints one, it came from an app and it may be a list with no breadcrumb on the page, which is the case the visibility rule forbids.
Then the second page. Search the source of page one for href=" followed by your collection path and ?page=2. On Dawn it is in the pagination. On Horizon with auto-load on, it is absent, and the rel="next" in the head is the only mention. That absence is the first finding on most Horizon stores.
Last, paste the collection URL into the Rich Results Test. On an untouched theme there is nothing collection-specific for it to detect. Once the snippet below is in place, it reports syntax errors and missing required properties on the items; it will not promise a carousel, because eligibility is decided by region, not by the validator. The Schema Markup Validator checks the same JSON against the vocabulary without Google's rules on top, and it is the one to use for the CollectionPage wrapper, which is not a rich result type and so sits outside what Google's test reports.
One snippet emits a CollectionPage carrying the page's list, rendered inside the paginate block
Create snippets/collection-json-ld.liquid:
{%- liquid
assign currency = localization.country.currency.iso_code
assign origin = shop.url
-%}
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "CollectionPage",
"@id": {{ canonical_url | append: '#collection' | json }},
"url": {{ canonical_url | json }},
"name": {{ collection.title | json }},
{%- if collection.description != blank %}
"description": {{ collection.description | strip_html | json }},
{%- endif %}
{%- if collection.image %}
"image": {{ collection.image | image_url: width: 1600 | prepend: 'https:' | json }},
{%- endif %}
"mainEntity": {
"@type": "ItemList",
"numberOfItems": {{ collection.products_count }},
"itemListElement": [
{%- for product in products %}
{
"@type": "ListItem",
"position": {{ forloop.index }},
"item": {
"@type": "Product",
"name": {{ product.title | json }},
"url": {{ product.url | split: '?' | first | prepend: origin | json }},
{%- if product.featured_image %}
"image": {{ product.featured_image | image_url: width: 1200 | prepend: 'https:' | json }},
{%- endif %}
"offers": {
{%- if product.price_varies %}
"@type": "AggregateOffer",
"lowPrice": {{ product.price_min | divided_by: 100.0 }},
"highPrice": {{ product.price_max | divided_by: 100.0 }},
{%- else %}
"@type": "Offer",
"price": {{ product.price_min | divided_by: 100.0 }},
{%- endif %}
"priceCurrency": {{ currency | json }},
"availability": "https://schema.org/{% if product.available %}InStock{% else %}OutOfStock{% endif %}"
}
}
}{% unless forloop.last %},{% endunless %}
{%- endfor %}
]
}
}
</script>
Then render it from inside the pagination. In Dawn, open sections/main-collection-product-grid.liquid, find the line {%- endpaginate -%}, and on the line above it add:
{% render 'collection-json-ld', products: collection.products %}
In Horizon the same line goes into sections/main-collection.liquid, above {% endpaginate %}. The placement is the point, and each of the other choices is a bug we have fixed on a live store.
- Inside the
paginateblock, withproductspassed in. Thepaginatetag exists because aforloop is limited to fifty iterations per page; inside the block,collection.productsis the current page's slice, and outside it, the first fifty. Horizon's own section passesproducts: collection.productsinto its grid snippet from inside the block for the same reason. Rendered anywhere else, the list describes the wrong products. positionfromforloop.index,numberOfItemsfrom the collection. Google's beta guide wants one list per page with 1-based positions; schema.org saysnumberOfItemsmay be the whole list under multi-page pagination. Thecollectionobject givesproducts_countfor the current view, filtered if the reader filtered it, which is the honest number for the page they are on.split: '?'on the product URL. Theproductobject says that in search results and filtered collectionsproduct.urlcarries the variant parameter of the most relevant variant. The beta guide wants the canonical detail page URL; the split returns the flat one whatever view the page is in.price_minandprice_variesdecide the offer type. The beta guide says to usepricefor a single price andlowPricewithhighPricefor a range, and not both. Prices are in the currency's subunit, sodivided_by: 100.0gives Google the dot-separated number it asks for rather than the locale-formatted string the money filters produce.imageon every item. Required for the carousel, and required to be crawlable; the product's featured image on your CDN is both. A product with no image is emitted without one and is simply not eligible, which is correct.canonical_urlfor the page, notrequest.path. On a filtered or sorted view the canonical is the bare collection, and the node should identify the page Google will index, not the variation the reader loaded. ABreadcrumbListon the same page needs no linking; Google's multiple-items guidance reads the two as separate items.jsonon every string. A collection description with a quotation mark in it is invalid JSON without it, and invalid JSON is no markup at all.
Nothing in the snippet is invented content. The title, description and image are what Dawn's collection banner already shows, the names and prices are the cards, and the URLs are the cards' links. The visible-content rule is satisfied by construction.
Fix the page-two link first; the markup is an hour
In this order, because each step is measurable before the next one starts:
- The link to page two, on Horizon. Five minutes. In the theme editor, open the collection template, select the product grid section, and switch off "Auto-load products". Pagination controls with real
<a href>links replace the sentinel. Google's pagination guide sets out the trade: paginated pages give the reader size and position at the cost of a page load per step; infinite scroll gives one page at the cost of the crawler never seeing past the first batch. If the merchant wants the scroll, keep it, knowing that products past twenty-four depend on the sitemap and on links from elsewhere. - Any
Productnode on a list page. Half an hour, most of it in the app's settings finding the toggle. Nothing else on the page can be judged while a node asserts the wrong thing. - The breadcrumb. The one markup on a collection page with a documented display in every country, on desktop, and it is the breadcrumb snippet. Under two hours.
- The
CollectionPagesnippet. An hour, including the two tests. Expect nothing visible from it in Switzerland or the UAE, and expect the carousel in France only after the store is in the programme the beta guide points to. What you get everywhere is a page that states what it is, what it lists and what each item costs, in the format an answer engine reads without guessing — which is why it belongs in the AI visibility section of a store audit. - The collection description. Not schema, but the node carries it and Dawn renders it. A collection whose
descriptionis blank has aCollectionPagewith a name and a list and nothing that says who the products are for. Writing two sentences per collection is the afternoon that most stores never spend.
If you would rather have all of it found, ranked against everything else the store is leaving on the table and written up, it is section five of the Store Teardown: €750, five days, and the document is yours whatever you decide afterwards.