Shopify Technical SEO in 2026: A Liquid-Level Playbook for Crawl Budget, Schema, and Core Web Vitals
Most "Shopify SEO" content is a repackaged checklist: write good titles, add alt text, install an SEO app. None of that touches the actual failure modes that cap organic growth on a real store — a faceted navigation system quietly burning 60%+ of your crawl budget on filter combinations nobody will ever click, a theme shipping three separate JSON-LD blocks that contradict each other, or an LCP image that's technically "optimized" but still renders 2.8 seconds late because a font is blocking it. Those are Liquid problems, not content problems, and fixing them requires opening the theme code.
I maintain and build Shopify themes for a living, and this is the audit-to-fix workflow I run store by store: crawl budget control, schema correctness, and Core Web Vitals — in that order, because each one gates the next. There's no point optimizing images for LCP if Googlebot is spending most of its budget crawling ?color=red&size=medium&sort_by=price-ascending variations of the same collection page instead of your actual catalog.
If you haven't read it yet, this pairs with my Shopify CRO playbook — that post covers what happens once a visitor lands on the page; this one covers whether Google sends them there at all.
Part 1: Faceted Navigation Is Eating Your Crawl Budget
This is the highest-leverage fix on this list and the one almost nobody does correctly. Faceted navigation — the color/size/price/availability filters on your collection pages — generates a combinatorial explosion of URLs. A collection with 5 filter types and 6 options each can theoretically produce tens of thousands of unique, crawlable URL variants from a catalog of 40 products. Google's own crawl budget documentation is explicit that faceted URLs are one of the most common causes of wasted crawl budget on ecommerce sites, and audits across large Shopify catalogs consistently find the majority of Googlebot's requests landing on filter-parameter URLs no human ever typed or shared.
Diagnose it first
Before touching code, check what's actually being crawled. In Google Search Console, go to Settings → Crawl stats → open report, and look at crawl requests by URL path. If you see high volumes of /collections/*?* requests relative to your actual page count, you have this problem. Cross-reference against your XML sitemap — anything Google is crawling heavily that isn't in your sitemap is pure crawl-budget waste.
Fix it in the theme, not with a plugin band-aid
The correct fix has three layers, and Shopify themes need all three because none of them alone is reliable at scale.
1. Canonical tag on every faceted URL, pointing to the clean collection path. This should already exist in theme.liquid or your collection template, but verify it's not silently broken when filters are combined:
liquid
{% comment %} snippets/seo-canonical.liquid {% endcomment %}
{%- liquid
assign canonical_url = canonical_url | default: shop.url | append: page.url
if template contains 'collection'
assign canonical_url = shop.url | append: collection.url
endif
-%}
<link rel="canonical" href="{{ canonical_url }}">{% comment %} snippets/seo-canonical.liquid {% endcomment %}
{%- liquid
assign canonical_url = canonical_url | default: shop.url | append: page.url
if template contains 'collection'
assign canonical_url = shop.url | append: collection.url
endif
-%}
<link rel="canonical" href="{{ canonical_url }}">{% comment %} snippets/seo-canonical.liquid {% endcomment %}
{%- liquid
assign canonical_url = canonical_url | default: shop.url | append: page.url
if template contains 'collection'
assign canonical_url = shop.url | append: collection.url
endif
-%}
<link rel="canonical" href="{{ canonical_url }}">The key detail most themes get wrong: collection.url on a filtered page still needs to resolve to the unfiltered path. Don't build the canonical from request.path, which includes the query string — build it explicitly from collection.url.
2. noindex, follow on filtered states via robots meta, not noindex blanket rules. You want Google to still crawl through filtered pages to discover linked products, just not index the filtered URL itself as a separate page:
liquid
{%- if current_tags.size > 0 or request.query_string contains 'filter.' -%}
<meta name="robots" content="noindex, follow">
{
{%- if current_tags.size > 0 or request.query_string contains 'filter.' -%}
<meta name="robots" content="noindex, follow">
{
{%- if current_tags.size > 0 or request.query_string contains 'filter.' -%}
<meta name="robots" content="noindex, follow">
{
3. Strip non-essential parameters from internal links so you stop generating new crawlable combinations in the first place. If your filter UI uses <a href> tags with query strings instead of JS-driven state, every filter click is a new crawlable, linkable URL. Convert filter controls to use history.pushState or a <form> with client-side rendering so the crawlable surface area doesn't grow with every added variant:
javascript
document.querySelectorAll('[data-facet-link]').forEach(el => {
el.addEventListener('click', (e) => {
e.preventDefault();
const url = new URL(el.href);
history.pushState({}, '', url);
applyFacetState(url.searchParams);
});
});
document.querySelectorAll('[data-facet-link]').forEach(el => {
el.addEventListener('click', (e) => {
e.preventDefault();
const url = new URL(el.href);
history.pushState({}, '', url);
applyFacetState(url.searchParams);
});
});
document.querySelectorAll('[data-facet-link]').forEach(el => {
el.addEventListener('click', (e) => {
e.preventDefault();
const url = new URL(el.href);
history.pushState({}, '', url);
applyFacetState(url.searchParams);
});
});Do this and you redirect crawl budget from thousands of duplicate filter permutations back to your actual product and collection inventory — which is what gets indexed, ranked, and clicked.
Part 2: Schema Markup That Doesn't Contradict Itself
Structured data is doing more work in 2026 than it used to — it feeds rich results, but it's also a primary input for how AI Overviews and AI shopping agents parse and cite product pages (relevant if you read my post on automating Amazon operations — the same "machine-readable product truth" principle applies on your own storefront). The problem on most Shopify stores isn't missing schema, it's conflicting schema: the theme ships a Product JSON-LD block, an SEO app injects a second one, and a review app adds a third with different price and rating values. Google picks one, arbitrarily, and you lose control over what shows in the SERP.
Audit for duplicates first
View source on a product page and search for application/ld+json. If you get more than one Product-type block, you have a conflict. Fix priority: keep exactly one source of truth, ideally hand-built in the theme so you control every field.
A correct, complete Product schema in Liquid
liquid
{% comment %} snippets/product-schema.liquid {% endcomment %}
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "Product",
"name": {{ product.title | json }},
"description": {{ product.description | strip_html | truncate: 500 | json }},
"image": [
{%- for image in product.images limit: 5 -%}
{{ image | image_url: width: 1200 | prepend: "https:" | json }}{% unless forloop.last %},{% endunless %}
{%- endfor -%}
],
"sku": {{ product.selected_or_first_available_variant.sku | json }},
"brand": {
"@type": "Brand",
"name": {{ product.vendor | json }}
},
"offers": {
"@type": "Offer",
"url": {{ shop.url | append: product.url | json }},
"priceCurrency": {{ cart.currency.iso_code | json }},
"price": {{ product.selected_or_first_available_variant.price | money_without_currency | json }},
"availability": "https://schema.org/{% if product.selected_or_first_available_variant.available %}InStock{% else %}OutOfStock{% endif %}",
"itemCondition": "https://schema.org/NewCondition"
}
{%- if product.metafields.reviews.rating.value -%}
,"aggregateRating": {
"@type": "AggregateRating",
"ratingValue": {{ product.metafields.reviews.rating.value.rating | json }},
"reviewCount": {{ product.metafields.reviews.rating_count | json }}
}
{%- endif -%}
}
</script>{% comment %} snippets/product-schema.liquid {% endcomment %}
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "Product",
"name": {{ product.title | json }},
"description": {{ product.description | strip_html | truncate: 500 | json }},
"image": [
{%- for image in product.images limit: 5 -%}
{{ image | image_url: width: 1200 | prepend: "https:" | json }}{% unless forloop.last %},{% endunless %}
{%- endfor -%}
],
"sku": {{ product.selected_or_first_available_variant.sku | json }},
"brand": {
"@type": "Brand",
"name": {{ product.vendor | json }}
},
"offers": {
"@type": "Offer",
"url": {{ shop.url | append: product.url | json }},
"priceCurrency": {{ cart.currency.iso_code | json }},
"price": {{ product.selected_or_first_available_variant.price | money_without_currency | json }},
"availability": "https://schema.org/{% if product.selected_or_first_available_variant.available %}InStock{% else %}OutOfStock{% endif %}",
"itemCondition": "https://schema.org/NewCondition"
}
{%- if product.metafields.reviews.rating.value -%}
,"aggregateRating": {
"@type": "AggregateRating",
"ratingValue": {{ product.metafields.reviews.rating.value.rating | json }},
"reviewCount": {{ product.metafields.reviews.rating_count | json }}
}
{%- endif -%}
}
</script>{% comment %} snippets/product-schema.liquid {% endcomment %}
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "Product",
"name": {{ product.title | json }},
"description": {{ product.description | strip_html | truncate: 500 | json }},
"image": [
{%- for image in product.images limit: 5 -%}
{{ image | image_url: width: 1200 | prepend: "https:" | json }}{% unless forloop.last %},{% endunless %}
{%- endfor -%}
],
"sku": {{ product.selected_or_first_available_variant.sku | json }},
"brand": {
"@type": "Brand",
"name": {{ product.vendor | json }}
},
"offers": {
"@type": "Offer",
"url": {{ shop.url | append: product.url | json }},
"priceCurrency": {{ cart.currency.iso_code | json }},
"price": {{ product.selected_or_first_available_variant.price | money_without_currency | json }},
"availability": "https://schema.org/{% if product.selected_or_first_available_variant.available %}InStock{% else %}OutOfStock{% endif %}",
"itemCondition": "https://schema.org/NewCondition"
}
{%- if product.metafields.reviews.rating.value -%}
,"aggregateRating": {
"@type": "AggregateRating",
"ratingValue": {{ product.metafields.reviews.rating.value.rating | json }},
"reviewCount": {{ product.metafields.reviews.rating_count | json }}
}
{%- endif -%}
}
</script>Two details that matter and get missed constantly: price must be a plain decimal string with no currency symbol (money_without_currency, not money), and availability must resolve per-variant, not per-product — a product with one sold-out variant and five in-stock ones is not globally OutOfStock.
BreadcrumbList schema for collection depth
This one is cheap to add and directly supports how your site's hierarchy gets represented in search results:
liquid
{% comment %} snippets/breadcrumb-schema.liquid {% endcomment %}
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "BreadcrumbList",
"itemListElement": [
{
"@type": "ListItem",
"position": 1,
"name": "Home",
"item": {{ shop.url | json }}
}
{%- if collection -%}
,{
"@type": "ListItem",
"position": 2,
"name": {{ collection.title | json }},
"item": {{ shop.url | append: collection.url | json }}
}
{%- endif -%}
{%- if product -%}
,{
"@type": "ListItem",
"position": {% if collection %}3{% else %}2{% endif %},
"name": {{ product.title | json }},
"item": {{ shop.url | append: product.url | json }}
}
{%- endif -%}
]
}
</script>{% comment %} snippets/breadcrumb-schema.liquid {% endcomment %}
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "BreadcrumbList",
"itemListElement": [
{
"@type": "ListItem",
"position": 1,
"name": "Home",
"item": {{ shop.url | json }}
}
{%- if collection -%}
,{
"@type": "ListItem",
"position": 2,
"name": {{ collection.title | json }},
"item": {{ shop.url | append: collection.url | json }}
}
{%- endif -%}
{%- if product -%}
,{
"@type": "ListItem",
"position": {% if collection %}3{% else %}2{% endif %},
"name": {{ product.title | json }},
"item": {{ shop.url | append: product.url | json }}
}
{%- endif -%}
]
}
</script>{% comment %} snippets/breadcrumb-schema.liquid {% endcomment %}
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "BreadcrumbList",
"itemListElement": [
{
"@type": "ListItem",
"position": 1,
"name": "Home",
"item": {{ shop.url | json }}
}
{%- if collection -%}
,{
"@type": "ListItem",
"position": 2,
"name": {{ collection.title | json }},
"item": {{ shop.url | append: collection.url | json }}
}
{%- endif -%}
{%- if product -%}
,{
"@type": "ListItem",
"position": {% if collection %}3{% else %}2{% endif %},
"name": {{ product.title | json }},
"item": {{ shop.url | append: product.url | json }}
}
{%- endif -%}
]
}
</script>Validate everything with Google's Rich Results Test before shipping — schema with correct syntax but wrong field types (a string where a number is expected) fails silently; Google just ignores the block instead of erroring loudly.
Part 3: Core Web Vitals — LCP and INP Are the Two That Actually Move Rankings
Shopify's own performance data shows enormous variance between themes on identical hardware, and the gap is almost always Liquid and app bloat, not Shopify's infrastructure. The single biggest cause of Core Web Vitals failure on Shopify stores is installed apps injecting JS/CSS on every page, including pages the app has nothing to do with. Audit your app list against theme.liquid and any {% render %} calls before touching anything else — uninstalling three unused apps often fixes more than a week of manual optimization.
Fix LCP: stop making the browser guess what matters
Your Largest Contentful Paint element on a product page is almost always the main product image. Tell the browser immediately instead of letting it discover this after parsing CSS:
liquid
{% comment %} sections/main-product.liquid, inside the featured image render {% endcomment %}
<link rel="preload"
as="image"
href="{{ product.featured_image | image_url: width: 800 }}"
fetchpriority="high">
<img
src="{{ product.featured_image | image_url: width: 800 }}"
srcset="
{{ product.featured_image | image_url: width: 400 }} 400w,
{{ product.featured_image | image_url: width: 800 }} 800w,
{{ product.featured_image | image_url: width: 1200 }} 1200w"
sizes="(min-width: 990px) 50vw, 100vw"
fetchpriority="high"
loading="eager"
width="{{ product.featured_image.width }}"
height="{{ product.featured_image.height }}"
alt="{{ product.featured_image.alt | escape }}">{% comment %} sections/main-product.liquid, inside the featured image render {% endcomment %}
<link rel="preload"
as="image"
href="{{ product.featured_image | image_url: width: 800 }}"
fetchpriority="high">
<img
src="{{ product.featured_image | image_url: width: 800 }}"
srcset="
{{ product.featured_image | image_url: width: 400 }} 400w,
{{ product.featured_image | image_url: width: 800 }} 800w,
{{ product.featured_image | image_url: width: 1200 }} 1200w"
sizes="(min-width: 990px) 50vw, 100vw"
fetchpriority="high"
loading="eager"
width="{{ product.featured_image.width }}"
height="{{ product.featured_image.height }}"
alt="{{ product.featured_image.alt | escape }}">{% comment %} sections/main-product.liquid, inside the featured image render {% endcomment %}
<link rel="preload"
as="image"
href="{{ product.featured_image | image_url: width: 800 }}"
fetchpriority="high">
<img
src="{{ product.featured_image | image_url: width: 800 }}"
srcset="
{{ product.featured_image | image_url: width: 400 }} 400w,
{{ product.featured_image | image_url: width: 800 }} 800w,
{{ product.featured_image | image_url: width: 1200 }} 1200w"
sizes="(min-width: 990px) 50vw, 100vw"
fetchpriority="high"
loading="eager"
width="{{ product.featured_image.width }}"
height="{{ product.featured_image.height }}"
alt="{{ product.featured_image.alt | escape }}">The three things doing the actual work here: fetchpriority="high" (tells the browser to prioritize this fetch over render-blocking resources it would otherwise get to first), explicit width/height (prevents layout shift, which also protects CLS), and loading="eager" specifically on this one image — every other image on the page below the fold should be loading="lazy", which most themes get backwards by lazy-loading everything uniformly.
Fix INP: defer everything that isn't needed for first interaction
Interaction to Next Paint replaced FID as a Core Web Vital, and it punishes exactly what most Shopify themes do by default: loading cart drawers, review widgets, upsell modals, and chat widgets synchronously on every page load whether or not the user will ever touch them.
liquid
{% comment %} theme.liquid — defer non-critical third-party scripts {% endcomment %}
<script>
window.addEventListener('load', () => {
setTimeout(() => {
const deferred = document.querySelectorAll('script[data-defer-src]');
deferred.forEach(s => {
const script = document.createElement('script');
script.src = s.getAttribute('data-defer-src');
script.async = true;
document.body.appendChild(script);
});
}, 2000);
});
</script>
<script data-defer-src="{{ 'review-widget.js' | asset_url }}"><
{% comment %} theme.liquid — defer non-critical third-party scripts {% endcomment %}
<script>
window.addEventListener('load', () => {
setTimeout(() => {
const deferred = document.querySelectorAll('script[data-defer-src]');
deferred.forEach(s => {
const script = document.createElement('script');
script.src = s.getAttribute('data-defer-src');
script.async = true;
document.body.appendChild(script);
});
}, 2000);
});
</script>
<script data-defer-src="{{ 'review-widget.js' | asset_url }}"><
{% comment %} theme.liquid — defer non-critical third-party scripts {% endcomment %}
<script>
window.addEventListener('load', () => {
setTimeout(() => {
const deferred = document.querySelectorAll('script[data-defer-src]');
deferred.forEach(s => {
const script = document.createElement('script');
script.src = s.getAttribute('data-defer-src');
script.async = true;
document.body.appendChild(script);
});
}, 2000);
});
</script>
<script data-defer-src="{{ 'review-widget.js' | asset_url }}"><
Reduce nested Liquid loops on collection and search pages too — every {% for %} inside a {% for %} iterating over product.variants or product.metafields compiles to real server-side work on every request. Pre-assign filtered/sorted arrays once at the top of the template instead of recomputing them inside render loops.
Part 4: Sitemap and Robots.txt — Make Sure You're Not Fighting Your Own Fixes
Shopify auto-generates sitemap.xml and you can't fully hand-edit it, but you can control what feeds it: unpublished products, draft collections, and password-protected pages are correctly excluded already. What you need to verify manually is that your robots.txt.liquid override (if you have one) isn't blocking paths your canonical/schema strategy above depends on:
liquid
{% comment %} templates/robots.txt.liquid — verify, don't blanket-block {% endcomment %}
{%- for group in robots.default_groups -%}
{%- for rule in group.rules -%}
{{ rule.directive }}: {{ rule.value }}
{%- endfor -%}
{%- if group.sitemap != blank -%}
Sitemap: {{ group.sitemap }}
{%- endif -%}
{%- endfor -%}
# Only add explicit disallows for paths you've confirmed generate real crawl waste —
# don't disallow /collections/*?* wholesale if you're relying on the noindex,follow
# approach from Part 1, or you'll block Googlebot from reaching linked products entirely.{% comment %} templates/robots.txt.liquid — verify, don't blanket-block {% endcomment %}
{%- for group in robots.default_groups -%}
{%- for rule in group.rules -%}
{{ rule.directive }}: {{ rule.value }}
{%- endfor -%}
{%- if group.sitemap != blank -%}
Sitemap: {{ group.sitemap }}
{%- endif -%}
{%- endfor -%}
# Only add explicit disallows for paths you've confirmed generate real crawl waste —
# don't disallow /collections/*?* wholesale if you're relying on the noindex,follow
# approach from Part 1, or you'll block Googlebot from reaching linked products entirely.{% comment %} templates/robots.txt.liquid — verify, don't blanket-block {% endcomment %}
{%- for group in robots.default_groups -%}
{%- for rule in group.rules -%}
{{ rule.directive }}: {{ rule.value }}
{%- endfor -%}
{%- if group.sitemap != blank -%}
Sitemap: {{ group.sitemap }}
{%- endif -%}
{%- endfor -%}
# Only add explicit disallows for paths you've confirmed generate real crawl waste —
# don't disallow /collections/*?* wholesale if you're relying on the noindex,follow
# approach from Part 1, or you'll block Googlebot from reaching linked products entirely.That last comment matters more than it looks — this is the single most common self-inflicted mistake I see when stores "fix" faceted navigation. Blanket-disallowing filtered paths in robots.txt stops Google from crawling them at all, which means it also stops discovering product links reachable only through a filtered view. The noindex, follow approach from Part 1 is deliberately the gentler fix: it keeps the crawl path open while removing the URL from the index.
Running Order and What to Measure
Do these in sequence, not in parallel — each layer's fix changes what you're measuring in the next:
Crawl budget (Part 1) — verify in GSC Crawl Stats after 2–3 weeks that faceted-URL crawl requests drop and indexed page count stabilizes around your true catalog size.
Schema (Part 2) — validate with Rich Results Test immediately; monitor rich result impressions in GSC's Enhancement reports over 4–6 weeks.
Core Web Vitals (Part 3) — check PageSpeed Insights field data (CrUX, not lab data) monthly; lab scores move instantly, real-user field data takes 28 days to reflect a change.
Robots/sitemap (Part 4) — confirm via site:yourdomain.com spot checks and GSC Coverage report that nothing got over-blocked.
None of this replaces good content or link building — it just makes sure Google can actually find, parse, and render what you've already built. On a catalog of any real size, that's usually worth more incremental organic traffic than another six months of blog posts targeting keywords Google can't even get to.