Theme Design, SEO and Performance

This content was generated with the assistance of AI and should be verified against the official documentation of each platform before being relied on in production.

This section’s bibliography lists the reference material consulted while preparing these pages.

This page was written against Online Store 2.0 themes as they work on 2026-10-27, the date it was checked: the reference themes Skeleton (the default of shopify theme init), Dawn 16.0.0 and Horizon 4.3.0, Shopify CLI 5.0.0 with Theme Check 3.31.0, Liquid (which is unversioned) and the Ajax API (also unversioned, so it can change at any time). Everything here applies to every Shopify plan that has the Online Store sales channel. The Ajax API, the Section Rendering API and the theme files described below exist only for themes that Shopify hosts; a custom storefront built with Hydrogen or the Storefront API has to produce the same results itself, and the Storefront API and headless page covers that route.

A theme is judged by four things that are easy to treat separately and are better treated together: whether a visitor understands each page at a glance (information hierarchy), whether a search engine understands it (SEO), whether it appears quickly and stays still while it loads (performance), and whether a keyboard or screen reader user can complete a purchase (accessibility). The same markup decisions serve all four. One h1 per page, content in reading order, images with dimensions and alternative text, and a cart that works as a plain form before JavaScript enhances it are good for visitors, crawlers, Lighthouse and assistive technology at the same time. The page ends with the Ajax Cart API, the mechanism that lets a theme update the cart without a page load, built so that it keeps those four properties.

The running example is the Pocket Coffee shop: Spanish (default) and English storefronts, the product "Single-origin beans" with 18 variants (origin by grind by size, SKUs such as PC-COL-WHL-250 at 8.90 EUR), the "Starter kit", the Coffee Club discount, free shipping above 40 EUR and a roast date metafield. This page builds a small Pocket Coffee theme on top of Skeleton: a head with complete SEO tags, structured data, a robots.txt.liquid, a hero banner that handles the Largest Contentful Paint image correctly, an accessible price and product page, and a cart drawer driven by the Ajax API.

It sits after the pages on theme architecture, Liquid and sections and theme blocks, which explain the files used here, and before theme tooling and workflow, which covers the commands that upload and check them. The three reference themes are compared in Reference Themes: Skeleton, Dawn and Horizon.

What was run and what was not

Everything below was prepared without a Shopify account, so nothing here was executed against a real store. The table says exactly what ran locally and what is described from Shopify’s documentation as read on 2026-10-27.

Example What ran What did not run

The Pocket Coffee theme (layout, snippets, sections, templates, robots.txt.liquid, locale file)

shopify theme check on the whole theme with Shopify CLI 5.0.0 (Theme Check 3.31.0): 46 files inspected, no offenses, also with the stricter theme-check:all preset. A second run on a deliberately bad snippet reported the errors quoted in the performance part.

Rendering in a store (shopify theme dev), because that needs a store and a login. The output of structured_data, image_tag, image_url and money is therefore quoted from Shopify’s documentation, not produced here.

json-ld-site.liquid, json-ld-breadcrumbs.liquid and robots.txt.liquid

Rendered with LiquidJS 10.30.0, which runs plain Liquid, against plain data and stand-in objects for robots; the JSON-LD output was parsed with JSON.parse.

Real robots objects and real shop, request, collection and product objects. The stand-ins are not evidence of Shopify’s output.

cart-api.js and pocket-cart.js

node tests with a stubbed fetch: eight checks on the API wrapper and five on the DOM behaviour (jsdom 26). They found and fixed a wrong URL for cart.js.

Calls to a real cart. The responses used by the tests copy the shape documented for the Ajax API.

Lighthouse, Core Web Vitals, Search Console, Rich Results Test

Nothing.

All of them need a deployed storefront.

The sample Shopify values (store handles, IDs) that appear in quoted output come from Shopify’s documentation. The only domain used for Pocket Coffee is pocket-coffee.example.com, a reserved example name.

What the books taught and what changed

Two books cover this ground, and both were written for older themes. Gavin Ballard, The Definitive Guide to Shopify Themes (Apress, 2017), has the design chapters that this page borrows its method from: designing layout and navigation, the product page and its information hierarchy, the home and collection pages, the cart, and then SEO and social sharing and performance. Ivan Djordjevic, Shopify Theme Customization with Liquid (Packt, 2021), has a chapter on the Ajax API. The method is still sound: decide the job of each page, order the content by importance, make the first screen carry the decision. The mechanics changed, and the table lists what to read as history.

In the books and in older themes Today

Static sections and theme settings in .liquid templates

JSON templates compose sections, section groups hold the header and footer, and the theme editor reorders everything. The information hierarchy is now the order array of a template.

Microdata and hand-written JSON-LD in the theme

The structured_data filter produces Product, ProductGroup and Article markup; the theme adds only what the filter does not cover.

Page weight, request counts, asset concatenation and YSlow

Shopify’s CDN serves HTTP/3 with Brotli, minifies CSS and JavaScript, sends Early Hints and streams the HTML. The measures that matter are the Core Web Vitals (LCP, INP, CLS) and the Lighthouse scores that the Theme Store checks.

Theme Kit, Slate, Grunt and jQuery

Shopify CLI, Theme Check, native fetch, custom elements and ES modules with import maps.

Currency and language hacks in JavaScript

Shopify Markets: locale-aware URLs from window.Shopify.routes.root, contextual prices read from Liquid, hreflang tags added by the platform.

The cart updated by $.post('/cart/add.js') and a hand-built HTML snippet

The Ajax Cart API with bundled section rendering: the server returns the re-rendered cart section in the same response.

The Theme Store’s own requirements shaped parts of this page, so they are quoted where they apply: a minimum average Lighthouse performance score of 60 and accessibility score of 90, the SEO snippet, no robots.txt.liquid and the accessibility checklist. They are explained in the reference themes page.

Page information hierarchy

Information hierarchy is the order in which a page tells its story: what a visitor must understand first, what they must do next and what can wait. It decides the order of the sections in the template, the heading levels, what is above the fold and what a screen reader reads first. Because the DOM order is also the keyboard focus order and the order in which a screen reader reads the page, designing the hierarchy once gives a consistent result for all three.

One job per page

Each template has one job, and the hierarchy follows from it. The four pages that carry a purchase are described in the table with the Pocket Coffee choices; the search page, the 404 page, content pages and the blog follow the same rule with a different job.

Template Job Above the fold Stays visible or is easy to reach

index

Say what the shop sells and send the visitor to a collection or a product.

Header, one h1 with one promise and one button, the hero image (usually the LCP element).

Search, cart link, language and country selector, newsletter and policies in the footer.

collection

Let the visitor find the right product.

Collection title (h1) and a short description, then filters and sorting, then the product grid.

Filters and sort control (origin and grind for Pocket Coffee), pagination, a way back to all collections.

product

Make the buying decision and add to cart.

Gallery with the first image loaded eagerly, product title (h1), price with the sale price marked, variant picker, add to cart.

Stock and shipping hint (free shipping above 40 EUR), description, roast date, recommendations.

cart

Confirm what will be bought and move to checkout.

Title and item count, line items with quantity and remove, subtotal, checkout button.

Discount code field, shipping threshold progress, express payment buttons, order note.

The figure shows the same four pages as wireframes with the reading order numbered. Blue blocks identify the shop, green blocks carry content and amber blocks ask the visitor to decide or act.

Wireframes of the home, collection, product and cart pages with numbered reading order

Ballard’s design chapters make the same cut. For the product page he orders imagery, then product details, then the add to cart form, then recommended products; the sequence in the figure is the same with today’s additions (variants as a picker, sale price markup, the roast date metafield). What no longer applies is how the order is encoded.

Encoding the hierarchy in templates and section groups

In an Online Store 2.0 theme the hierarchy lives in the JSON template, and the theme editor can reorder it without code. The order array is the DOM order. The header and footer are section groups (header-group.json, footer-group.json), which Skeleton includes in the layout with {% sections 'header-group' %}. A template for the Pocket Coffee home page needs one section, the hero:

{
  "sections": {
    "hero": {
      "type": "hero-banner",
      "settings": {
        "heading": "Freshly roasted, shipped in 24 hours",
        "button_label": "Shop beans"
      }
    }
  },
  "order": ["hero"]
}

The product template keeps the Skeleton convention of a single main section, because everything on the product page belongs together and is read in one order:

{
  "sections": {
    "main": {
      "type": "product",
      "settings": {}
    }
  },
  "order": ["main"]
}

A few rules keep a hierarchy intact as the merchant edits the theme:

  • Give every page exactly one h1, the title of the thing the page is about: the product title on a product page, the collection title on a collection page, the hero heading on the home page. Shopify’s accessibility guidance requires the h1 to identify the main topic and the other headings to follow in sequence, without skipping levels for visual effect.

  • Make the first section of the template the one that carries the decision. The section.index property, which is the 1-based position of a section in its location, lets a section behave differently when it is first (see the hero below).

  • Put structure in sections, not in conditionals. A section can be hidden or reordered by the merchant, while an if in a layout cannot.

  • Keep the product page’s reading order equal to its DOM order. Do not move the add to cart button with CSS order or position so that it appears before the title; keyboard users follow the DOM.

  • Use the routes object for every internal URL: routes.root_url, routes.cart_url, routes.search_url, routes.collections_url, routes.all_products_collection_url, routes.cart_add_url, routes.cart_change_url, routes.cart_update_url, routes.cart_clear_url, routes.predictive_search_url and routes.product_recommendations_url. A hard-coded /cart link on a localized storefront, where the cart is /en/cart, sends the visitor back to the default language or market, and Theme Check reports it with the HardcodedRoutes check.

The product page in detail

The product page has the most structure, and Pocket Coffee’s "Single-origin beans" is the hardest case: one product, three options (origin, grind and size) and 18 variants. The section below renders the gallery, the title, the price, a variant selector, the roast date badge, the add to cart form and the description in that order.

{% comment %}
  Pocket Coffee product page. Reading order: media, title, price, variant, add to cart, details.
{% endcomment %}

{%- liquid
  assign current_variant = product.selected_or_first_available_variant
-%}

<div class="product">
  <div class="product__media">
    {%- for media in product.media limit: 6 -%}
      {%- if media.media_type == 'image' -%}
        {%- assign alt = media.alt | default: product.title -%}
        {%- if forloop.first -%}
          {{ media | image_url: width: 1500 | image_tag: sizes: '(min-width: 750px) 50vw, 100vw', widths: '375, 750, 1100, 1500', loading: 'eager', fetchpriority: 'high', alt: alt }}
        {%- else -%}
          {{ media | image_url: width: 1500 | image_tag: sizes: '(min-width: 750px) 25vw, 50vw', widths: '375, 750, 1100', loading: 'lazy', alt: alt }}
        {%- endif -%}
      {%- endif -%}
    {%- endfor -%}
  </div>

  <div class="product__info">
    <h1>{{ product.title }}</h1>

    {% render 'price', product: product, variant: current_variant %}

    {%- assign roast_date = product.metafields.custom.roast_date.value | date: '%-d %B %Y' -%}
    {%- if roast_date != blank -%}
      <p class="roast-badge">
        {{ 'products.roast_date' | t: date: roast_date }}
      </p>
    {%- endif -%}

    {% form 'product', product, id: 'product-form' %}
      <label for="variant-id">{{ 'products.variant' | t }}</label>
      <select id="variant-id" name="id">
        {%- for variant in product.variants -%}
          <option
            value="{{ variant.id }}"
            {% if variant == current_variant %}selected{% endif %}
            {% unless variant.available %}disabled{% endunless %}
          >
            {{- variant.title }} - {{ variant.price | money -}}
          </option>
        {%- endfor -%}
      </select>

      <label for="quantity">{{ 'products.quantity' | t }}</label>
      <input
        type="number"
        id="quantity"
        name="quantity"
        value="1"
        min="1"
        inputmode="numeric"
        autocomplete="off"
      >

      <button
        type="submit"
        name="add"
        data-add-to-cart
        {% unless current_variant.available %}disabled{% endunless %}
      >
        {%- if current_variant.available -%}
          {{ 'products.add_to_cart' | t }}
        {%- else -%}
          {{ 'products.sold_out' | t }}
        {%- endif -%}
      </button>

      <p class="product__error" role="alert" data-product-error hidden></p>
    {% endform %}

    <div class="product__description">
      {{ product.description }}
    </div>
  </div>
</div>

{% stylesheet %}
  .product {
    display: grid;
    gap: 2rem;
    padding: 0 var(--page-margin);
  }
  @media (min-width: 750px) {
    .product {
      grid-template-columns: 1fr 1fr;
    }
  }
{% endstylesheet %}

{% schema %}
{
  "name": "Product",
  "settings": [],
  "disabled_on": { "groups": ["header", "footer"] }
}
{% endschema %}

What the section does, in the order a visitor meets it:

  • The gallery loops over product.media with limit: 6, so a product with many images does not render all of them. Only the first image is loaded eagerly and with fetchpriority="high", because it is the Largest Contentful Paint element of the page. The others are lazy. The alternative text is the alt of the media or, when the merchant left it empty, the product title, computed once with assign because a default filter placed after image_tag arguments would apply to the whole <img> tag and not to the alt text.

  • The h1 is the product title. The price comes from the price snippet, shown below, so every template that prints a price marks a sale the same way.

  • The roast date is a metafield (custom.roast_date, a date) read once, outside any loop, and formatted with the date filter. A metafield read inside a loop over variants is one of the most common causes of a slow server response; see the Liquid performance section.

  • The variant picker is a <select name="id"> inside the product form, as in Skeleton, so the form works without JavaScript. Unavailable variants are disabled. The Single-origin beans product has 18 variants, well below the limit of 250 variants that the Ajax API returns for a product.

  • The form has labels with matching for attributes, a quantity input with inputmode="numeric" and an alert region for errors. The option text of the select carries each variant’s price, so the price of a choice is visible without JavaScript; a theme that rewrites the price when the choice changes has to announce the change, as the accessibility part explains.

The price snippet marks regular and sale prices both visually and for screen readers, which Shopify’s accessibility guidance requires:

{% doc %}
  Renders a price so that sighted users and screen reader users get the same information.

  @param {product} product - The product whose price is rendered
  @param {variant} [variant] - The selected variant, defaults to the first available one
{% enddoc %}

{%- liquid
  assign target = variant | default: product.selected_or_first_available_variant
  assign on_sale = false
  if target.compare_at_price > target.price
    assign on_sale = true
  endif
-%}

<div class="price" data-price>
  {%- if on_sale -%}
    <p>
      <span class="visually-hidden">{{ 'products.price.regular' | t }}</span>
      <s>{{ target.compare_at_price | money }}</s>
      <span class="visually-hidden">{{ 'products.price.sale' | t }}</span>
      <strong>{{ target.price | money }}</strong>
    </p>
  {%- else -%}
    <p>
      <span class="visually-hidden">{{ 'products.price.regular' | t }}</span>
      <strong>{{ target.price | money }}</strong>
    </p>
  {%- endif -%}
  {%- if target.unit_price_measurement -%}
    <p class="price__unit">
      <span class="visually-hidden">{{ 'products.price.unit' | t }}</span>
      {{ target.unit_price | money }} / {{ target.unit_price_measurement.reference_value }}{{ target.unit_price_measurement.reference_unit }}
    </p>
  {%- endif -%}
</div>

The snippet uses a LiquidDoc doc block to declare its parameters. The Coffee Club discount does not change the variant’s price: automatic discounts are applied in the cart and at checkout, which is why the cart drawer prints item.final_line_price. The Theme Store requires that a theme shows discounts in the cart.

Navigation, search and the 404 page

Navigation is part of the hierarchy. Keep the main menu shallow, since each additional level doubles the work for a keyboard user, and render one menu that adapts with CSS rather than two copies for mobile and desktop, which Shopify lists as a performance recommendation because duplicated menus double the DOM size. Wrap navigation in a nav element, mark the current page with aria-current, and do not use role="menu" for site navigation. The search page and the 404 page each need a clear next action: a search box and links back to the collections, not a dead end.

Ballard’s guidance on categories, filtering, sorting and pagination for collection pages carries over unchanged. Two platform details matter now: Shopify limits pagination of arrays of objects to 25,000 objects, and a count query stops at 25,001, which signals "more than 25,000". If a collection is that deep, the hierarchy needs more filters, not deeper paging.

Pitfalls

  • Several h1 elements, or a logo marked as h1 on every page, make the page topic ambiguous. Skeleton prints the shop name in an h2 in its header for this reason.

  • Skeleton’s layout has no main element and no skip link, and Horizon’s layout renders a main. Check the layout of the theme you start from; the accessibility part adds both.

  • A hero image that is a CSS background cannot be discovered early by the browser and has no alternative text. Use an img.

  • Content that exists only after JavaScript runs (a product description fetched by fetch) is later to paint and later to be understood by crawlers than server-rendered Liquid.

SEO in the theme

Search engine optimization splits between what the theme controls and what the merchant controls in the admin. A theme that outputs the right tags makes the merchant’s work count: the page title and meta description a merchant types in the admin do nothing unless the layout prints page_title and page_description.

Signal Who controls it Where

Title tag and meta description

The merchant writes them per product, collection, page and article in the admin. The theme prints them.

{{ page_title }} and {{ page_description }} in the layout head.

Canonical URL

The platform computes it; the theme prints it.

{{ canonical_url }} in link rel="canonical".

Open Graph and Twitter cards

The theme.

A snippet in the head, with page_image for the image.

Structured data

The theme, with the structured_data filter for products and articles.

JSON-LD scripts in the head or in a section.

hreflang alternates

Shopify adds them automatically when the store uses Markets.

They come from {{ content_for_header }}; do not add your own unless you turn the automatic ones off.

sitemap.xml

The platform. The theme has no template for it.

/sitemap.xml, referenced from robots.txt.

robots.txt

The platform generates a default; a theme may replace it with robots.txt.liquid.

templates/robots.txt.liquid, not allowed in Theme Store themes.

Image alternative text, URL handles, redirects

The merchant, in the admin.

Read in the theme through image.alt and product.url.

The head: title, description and canonical

Shopify’s SEO metadata guide names three tags that every theme must print in the <head>: the title, the meta description and the canonical link. The Theme Store requires this snippet in every theme. The code below is the guide’s own layout example.

<head>
  <title>
    {{ page_title -}}
    {%- if current_tags %} &ndash; tagged "{{ current_tags | join: ', ' }}"{% endif -%}
    {%- if current_page != 1 %} &ndash; Page {{ current_page }}{% endif -%}
    {%- unless page_title contains shop.name %} &ndash; {{ shop.name }}{% endunless -%}
  </title>

  {% if page_description %}
    <meta name="description" content="{{ page_description | escape }}" />
  {% endif %}

  <link rel="canonical" href="{{ canonical_url }}" />
</head>

Line by line:

  • page_title is a global object that holds the page title for search engine listings and social previews. The merchant edits it in the admin, in the search engine listing fields of a product, collection, page or article (the Help Center page that describes them blocks automated requests and could not be opened, so what Shopify uses when the field is empty is not stated here, and the theme should not assume it). The suffixes keep titles unique across pagination (current_page) and tag filtering (current_tags), and the last line appends the shop name unless the title already contains it.

  • page_description is the matching global object for the description. It is printed only when present, and it goes through escape because it is merchant-typed text placed in an attribute.

  • canonical_url is the URL that search engines should treat as the page’s main address. Duplicate URLs are unavoidable in a shop: a product can be reached through a collection path or with a ?variant= parameter, and a collection through sort and filter parameters. Print the object as it is, and do not build a canonical from request.path or request.origin: the platform knows the canonical rules and a hand-made value can contradict them. Google treats rel="canonical" as a strong signal but not a directive (redirects are stronger and sitemap inclusion is weaker), so it works best when the rest of the signals (internal links, the sitemap, the hreflang set) point to the same URL.

Skeleton keeps these tags in a meta-tags snippet, and Dawn prints the title and canonical in layout/theme.liquid and the social tags in its own meta-tags snippet. The Pocket Coffee theme uses one seo-head snippet that renders from the layout ({% render 'seo-head' %}), so the SEO tags have a single owner. It is shown in full after the next two subsections, because it also prints the social tags and the structured data.

Open Graph and Twitter cards

Social platforms read Open Graph (og:) and Twitter card (twitter:) tags to build link previews. The page_image object holds the image Shopify picked for the page (the product’s featured image, the collection image, the article image), and it is an image object, so it needs a width or a height when passed to image_url: the filter returns an error when neither is given, and it never enlarges an image beyond its original size. A width of 1200 is the usual Open Graph size. image_url returns a protocol-relative URL (it starts with //), so the snippet prepends https:. og:type follows request.page_type: product for product pages, article for articles and website otherwise.

Structured data

Structured data tells search engines what a page contains in a machine-readable vocabulary (schema.org). Shopify provides a filter that does the hard part for products and articles.

<script type="application/ld+json">
  {{ product | structured_data }}
</script>

The structured_data filter works on the product and article objects. A product with no variants becomes a schema.org Product; a product with variants becomes a ProductGroup. An article becomes an Article. The documented output for a single-variant product contains @context, an @id such as /products/crocodile-tears#product, the Brand, the name, description, image and an offers object with an Offer, its availability and price.

Rules that keep structured data valid:

  • Output it once per page. Skeleton prints the product markup from its meta-tags snippet, while Dawn prints {{ product | structured_data }} inside its product sections (the main product, the featured product) and {{ article | structured_data }} in its article section. If a theme and an app both print product markup the page carries two Product entities that can disagree. The Pocket Coffee snippet prints it for request.page_type equal to product and the product section prints nothing.

  • In a multi-currency store the offer’s priceCurrency has to be the currency the visitor sees, cart.currency, and not the shop currency; Shopify’s multi-currency guide says so for any structured data you output for products. Inspect the offers that the structured_data filter prints in a store with a second currency (Pocket Coffee sells in USD as well as EUR), and when you write product markup by hand set priceCurrency from cart.currency.iso_code and read prices through Liquid, because contextual prices differ per market and a base price converted by hand is wrong.

  • The json filter is the way to put a Liquid value into a JSON-LD script: it adds the quotes and escapes characters. When applied to a product object it omits inventory_quantity and inventory_policy for stores created after 2017-12-05, which keeps stock quantities away from bots and crawlers that have no access to the store.

  • Google’s product snippet and merchant listing documentation says which properties unlock rich results (price, availability, ratings and so on). The structured_data output covers the basics; ratings and reviews come from the app that holds them and must be added to the same entity, not to a second one.

Shopify’s requirements page still tells theme authors to test with Google’s Structured Data Testing Tool. That address now redirects to Google’s general structured data documentation, which recommends the Rich Results Test for validating markup; the Schema Markup Validator at schema.org checks the vocabulary itself.

What the filter does not cover is the shop itself and the page’s position in the site. The Pocket Coffee theme adds an Organization and a WebSite entity on the home page and a BreadcrumbList on product pages. They use only standard Liquid filters (json, append, prepend), so they can be rendered outside a store:

{% # Organization and WebSite markup for the home page only %}
<script type="application/ld+json">
  {
    "@context": "https://schema.org",
    "@graph": [
      {
        "@type": "Organization",
        "@id": {{ request.origin | append: '/#organization' | json }},
        "name": {{ shop.name | json }},
        "url": {{ request.origin | json }}
      },
      {
        "@type": "WebSite",
        "@id": {{ request.origin | append: '/#website' | json }},
        "name": {{ shop.name | json }},
        "url": {{ request.origin | json }},
        "publisher": { "@id": {{ request.origin | append: '/#organization' | json }} },
        "inLanguage": {{ request.locale.iso_code | json }}
      }
    ]
  }
</script>
{% # BreadcrumbList for a product, using the collection the visitor came from when there is one %}
{%- liquid
  assign position = 1
-%}
<script type="application/ld+json">
  {
    "@context": "https://schema.org",
    "@type": "BreadcrumbList",
    "itemListElement": [
      {
        "@type": "ListItem",
        "position": 1,
        "name": {{ shop.name | json }},
        "item": {{ request.origin | json }}
      }
      {%- if collection != blank -%}
        ,{
          "@type": "ListItem",
          "position": 2,
          "name": {{ collection.title | json }},
          "item": {{ collection.url | prepend: request.origin | json }}
        }
        {%- assign position = 3 -%}
      {%- else -%}
        {%- assign position = 2 -%}
      {%- endif -%}
      ,{
        "@type": "ListItem",
        "position": {{ position }},
        "name": {{ product.title | json }}
      }
    ]
  }
</script>

Rendered with LiquidJS against plain data (the shop name, request.origin and a collection and product for the breadcrumbs), then parsed to prove the output is valid JSON, the result is:

json-ld-site: valid JSON, @type = Organization+WebSite
{"@context":"https://schema.org","@graph":[{"@type":"Organization","@id":"https://pocket-coffee.example.com/#organization","name":"Pocket Coffee","url":"https://pocket-coffee.example.com"},{"@type":"WebSite","@id":"https://pocket-coffee.example.com/#website","name":"Pocket Coffee","url":"https://pocket-coffee.example.com","publisher":{"@id":"https://pocket-coffee.example.com/#organization"},"inLanguage":"es"}]}
json-ld-breadcrumbs: valid JSON, @type = BreadcrumbList
{
  "@context": "https://schema.org",
  "@type": "BreadcrumbList",
  "itemListElement": [
    {
      "@type": "ListItem",
      "position": 1,
      "name": "Pocket Coffee",
      "item": "https://pocket-coffee.example.com"
    },
    {
      "@type": "ListItem",
      "position": 2,
      "name": "Beans",
      "item": "https://pocket-coffee.example.com/collections/beans"
    },
    {
      "@type": "ListItem",
      "position": 3,
      "name": "Single-origin beans"
    }
  ]
}
no collection -> 1:Pocket Coffee > 2:Single-origin beans

The breadcrumb snippet uses the collection object only when it is present: a product reached from a collection page has that context, and a product opened by its direct URL does not, so the list is one level shorter, as the second check shows. The last item has no item URL, which schema.org and Google allow for the current page.

The complete head snippet

The snippet below is rendered from the layout and holds everything above: the character set and viewport (zoom stays enabled, as the accessibility guidance requires), title, description, canonical, social tags and one structured-data source per page type.

{% # Basics: charset, viewport (zoom stays enabled) %}
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">

{% # Title, description and canonical, as in the Shopify SEO metadata guide %}
<title>
  {{ page_title }}
  {%- if current_tags %} &ndash; tagged "{{ current_tags | join: ', ' }}"{% endif -%}
  {%- if current_page != 1 %} &ndash; Page {{ current_page }}{% endif -%}
  {%- unless page_title contains shop.name %} &ndash; {{ shop.name }}{% endunless -%}
</title>

{% if page_description %}
  <meta name="description" content="{{ page_description | escape }}">
{% endif %}

<link rel="canonical" href="{{ canonical_url }}">

{% # Open Graph and Twitter cards %}
{%- liquid
  assign og_title = page_title | default: shop.name
  assign og_description = page_description | default: shop.description | default: shop.name
  assign og_type = 'website'
  if request.page_type == 'product'
    assign og_type = 'product'
  elsif request.page_type == 'article'
    assign og_type = 'article'
  endif
-%}
<meta property="og:site_name" content="{{ shop.name | escape }}">
<meta property="og:url" content="{{ canonical_url }}">
<meta property="og:title" content="{{ og_title | escape }}">
<meta property="og:type" content="{{ og_type }}">
<meta property="og:description" content="{{ og_description | escape }}">
{%- if page_image -%}
  <meta property="og:image" content="https:{{ page_image | image_url: width: 1200 }}">
  <meta property="og:image:width" content="{{ page_image.width }}">
  <meta property="og:image:height" content="{{ page_image.height }}">
{%- endif -%}
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="{{ og_title | escape }}">
<meta name="twitter:description" content="{{ og_description | escape }}">

{% # Structured data: one source per page type, never the same markup twice %}
{%- case request.page_type -%}
  {%- when 'index' -%}
    {% render 'json-ld-site' %}
  {%- when 'product' -%}
    <script type="application/ld+json">
      {{ product | structured_data }}
    </script>
    {% render 'json-ld-breadcrumbs', collection: collection, product: product %}
  {%- when 'article' -%}
    <script type="application/ld+json">
      {{ article | structured_data }}
    </script>
{%- endcase -%}

hreflang and Markets

Pocket Coffee has a Spanish storefront (the default) and an English one. Search engines use hreflang annotations to send each visitor to the right language or region. When the store uses Shopify Markets, Shopify adds the tags automatically through {{ content_for_header }}, which the layout prints in the <head>; the tags follow each market’s languages and regions (fr-ca, en-us for a country market, fr for a broader one), update when markets change and stay in sync with the page’s canonical URL.

A theme or an app that adds its own hreflang tags on top of the automatic ones creates duplicate or conflicting annotations that can hurt rankings. If you must manage them yourself (an app already prints them, or you link versions across separate stores), turn off Automatic hreflang tags in Online store > Preferences, in the Social sharing and SEO section, and build the tags from the localization object rather than from hard-coded domains or handles, since a resource’s handle can differ in each language. Each localized URL must reference itself as its own canonical.

The markets and domains side is covered in Markets and Internationalization.

Sitemaps

Shopify generates the sitemap. It is served at /sitemap.xml, and the sitemap object that robots.txt.liquid prints shows the directive as Sitemap: https://your-store.myshopify.com/sitemap.xml. None of the reference themes has a sitemap template, so the theme’s job is to keep the URLs that the sitemap lists correct: valid canonical URLs, no duplicate paths and pages that return real content.

Google’s rules for sitemaps are the ones to design around: a single sitemap may contain up to 50,000 URLs or 50 MB uncompressed, and a larger site uses several sitemaps listed in a sitemap index file. Submit the sitemap URL in Google Search Console; a domain is a separate property there, so a store with several domains submits the sitemap of each one. The Sitemap: line in robots.txt lets crawlers find it without a submission.

Shopify’s Help Center pages on the structure of the generated sitemap (which child sitemaps it contains and how many URLs each holds) and on hiding a resource from search engines could not be opened, because the site blocks automated requests; they are therefore not summarized here, and the sitemap should be inspected in the browser at /sitemap.xml. A headless storefront is served by its own code, so its sitemap is a route that code has to provide.

robots.txt

robots.txt tells crawlers which URLs they may request. Shopify generates a default file that works for most shops. A theme can replace it by adding templates/robots.txt.liquid, which cannot be a JSON template, and which Shopify does not include in any theme by default. The Theme Store forbids the template in submitted themes, so it is a store-specific customization, not something a distributed theme ships.

The template can use only six Liquid objects:

Object What it holds

robots

The default rule groups, in robots.default_groups.

group

One group: a user_agent, its rules and an optional sitemap.

rule

One directive (Allow, Disallow) and its value; printing the object prints the line.

user_agent

The crawler a group applies to, with a value such as *.

sitemap

The Sitemap: directive and its URL.

request

The current request, used for host-specific rules with request.host.

Shopify strongly recommends using these objects instead of replacing the file with plain text, because adding the template replaces the generated output and the output of the template does not always match the generated file exactly. Before publishing, save a copy of the current /robots.txt and compare it with the rendered result.

Pocket Coffee wants crawlers to skip the sorted views of a collection (?sort_by= URLs duplicate the unsorted page), so it adds one rule to the * group. Shopify’s example also adds its literal rule inside the loop; the template here appends an explicit line break to the literal rule, so it can never run into the line that follows, whatever the rule objects print.

{%- capture newline %}
{% endcapture -%}

{%- for group in robots.default_groups -%}
  {{- group.user_agent }}

  {%- for rule in group.rules -%}
    {{ rule }}
  {%- endfor -%}

  {%- if group.user_agent.value == '*' -%}
    {{ 'Disallow: /*?sort_by=*' | append: newline }}
  {%- endif -%}

  {%- if group.sitemap != blank -%}
    {{ group.sitemap }}
  {%- endif -%}
{% endfor %}

LiquidJS rendered this template against stand-in objects that mimic two default groups (the real ones come from Shopify, and this is not evidence of Shopify’s output), with the result:

--- robots.txt ---
User-agent: *
Disallow: /admin
Disallow: /cart
Disallow: /*?sort_by=*
Sitemap: https://pocket-coffee.example.com/sitemap.xml
User-agent: adsbot-google
Disallow: /checkout

The same page documents the other customizations, all variations of the loop above. To remove a default rule, skip it inside the inner loop:

{% for group in robots.default_groups %}
  {{- group.user_agent }}

  {%- for rule in group.rules -%}
    {%- unless rule.directive == 'Disallow' and rule.value == '/policies/' -%}
      {{ rule }}
    {%- endunless -%}
  {%- endfor -%}

  {%- if group.sitemap != blank -%}
    {{ group.sitemap }}
  {%- endif -%}
{% endfor %}

To give one market a different crawl policy, compare request.host inside the loop (for example {%- if request.host == 'example.fr' -%} followed by a Disallow: /en/ line). Do this only when the store has distinct domains per market. Rules for crawlers that are not in the default set are plain text outside the loop:

User-agent: discobot
Disallow: /

Sitemap: [sitemap-url]

robots.txt controls crawling, not indexing. Google states that the file is not a mechanism for keeping a page out of Google: to keep a page out, block indexing with noindex or password-protect the page. Do not block a URL in robots.txt and also rely on noindex on it, because a crawler that may not fetch the page cannot read the tag.

URLs, handles and duplicate content

Online Store URLs follow fixed patterns, as every example in the documentation shows (/products/handle, /collections/handle, /cart, /search); what the merchant controls is the handle, the last path segment. Duplicate content comes from parameters that change the view (?sort_by=, ?variant=, filters and page), and the tools against it are the canonical URL, the default robots.txt and, for sorted views, a rule such as the one above. Keep handles short, lowercase and stable, since changing one changes the URL. The admin has a URL redirects feature for old addresses, but its Help Center page could not be opened here, so it is not described. A theme should still never link to its own redirected URLs.

SEO checklist for the theme

Check How

One title, one meta name="description" and one rel="canonical" per page

View source on a product, a collection, a page and a paginated collection; the snippet above prints each once.

Structured data valid and printed once

Rich Results Test on a product URL; the page should show one Product or ProductGroup, plus the breadcrumb list.

One h1 per page

Neither Theme Check nor the tests on this page enforce it; check the page outline in the browser’s accessibility tree or with an accessibility tool.

hreflang from one source

Look for link rel="alternate" hreflang in the source of a localized page; there should be one set.

Sitemap reachable

Open /sitemap.xml on every domain and submit it in Search Console.

robots.txt intentional

Compare /robots.txt before and after adding robots.txt.liquid; do not ship the template in a Theme Store theme.

Images have dimensions and alt text

Theme Check ImgWidthAndHeight; image_tag adds both width and height and takes alt from the media or the title.

Image and asset performance

Performance on a Shopify theme is two problems with two different owners. The platform owns the network: the CDN, the compression, the server rendering and the early delivery of resources. The theme owns what the browser is asked to do with what it receives: which image is the main one, how big it is, what blocks rendering, how much JavaScript runs. This part covers the measures first and then the controls the theme has, in the order that moves the numbers most.

The metrics and the thresholds

The Core Web Vitals are the three metrics that Google uses as a ranking signal and that Shopify’s guidance is organized around. Google’s thresholds, measured at the 75th percentile of page loads and segmented across mobile and desktop, are:

Metric What it measures Good Usual cause in a theme

Largest Contentful Paint (LCP)

When the largest element in the initial viewport finishes rendering, usually the hero or the product image.

2.5 seconds or less

A lazy-loaded or late-discovered hero, a huge image, render-blocking CSS or apps, slow Liquid.

Interaction to Next Paint (INP)

How long the page takes to respond visibly after a tap or click.

200 milliseconds or less

Main-thread JavaScript from the theme and from apps, many stylesheets, a huge DOM.

Cumulative Layout Shift (CLS)

How much visible content moves unexpectedly.

0.1 or less

Images without dimensions, fonts that swap late, app-injected content without reserved space.

Shopify adds two supporting metrics that are not scored but locate the cause of a slow LCP: Time to First Byte (TTFB), which on a Shopify storefront is mostly Liquid rendering, and First Contentful Paint (FCP), which render-blocking stylesheets and scripts in the head delay. If the server response is slow, look at the Liquid; if it is fast, look at what the browser does next.

The Theme Store applies two lab scores to a submitted theme: a minimum average Lighthouse performance score of 60 and a minimum average accessibility score of 90, each averaged across the home, product and collection pages for both desktop and mobile, with sections that contain real images and content. Those are lab scores on a benchmark store and entry thresholds, not targets: the Core Web Vitals are measured separately, on real visitors.

What the platform already does

Before optimizing, know what not to redo. Shopify’s performance documentation describes these platform features, all of which work without changes to the theme:

Feature What it does and what it means for the theme

CDN

A Cloudflare-backed content delivery network. Files are minified and compressed with Brotli and gzip, requests use HTTP/3 and TLS 1.3, and storefront asset URLs are same-origin paths such as /cdn/shop/files/hero.jpg. Do not hard-code cdn.shopify.com; reference assets through the asset_url and image_url filters.

Versioned asset URLs

The asset_url filter appends a version such as ?v=1384022871, so a changed file is refetched. An image linked without the filter can stay stale for a day or more. Only the v parameter is recognized for versioning; a raw timestamp or an arbitrary parameter does not bust the cache. A CSS url(bg.gif) is static, so write url({{ 'bg.gif' | asset_url }}) in a Liquid-processed stylesheet.

Server-side rendering

The Storefront Renderer serves storefront requests, which improves cache misses. TTFB is dominated by the theme’s Liquid.

Streamed HTML

Eligible pages are sent in two parts: the layout up to {{ content_for_header }} first, the rest after the sections render, so the browser works on the head while Shopify renders the sections. See below.

Minification

CSS and JavaScript with valid ES5 syntax are minified when requested. Shopify serves the original when the minified file would be larger or when the file name ends in .min.js or .min.css; rename a file to .min.js to debug it.

Early Hints

Shopify sends HTTP 103 hints with Link preload and preconnect headers for the render-blocking scripts and stylesheets in the head, on every storefront page. The hints that the theme adds through preload_tag, stylesheet_tag: preload: true and image_tag: preload: true fill the remaining space of a limited header.

Speculation rules

Shopify injects speculation rules for prefetching and prerendering likely next pages in browsers that support them. Themes can add rules; the default rules aim to avoid problems with data use, caching and analytics.

Import map polyfill

Shopify includes es-module-shims when needed, so <script type="importmap"> and type="module" work without a second copy of the polyfill.

Pagination limit

Arrays of objects paginate to at most 25,000 objects.

Images: image_url and image_tag

Images are usually the LCP element, so their loading strategy is the largest single decision. Shopify’s image_url and image_tag filters do the difficult parts: they produce a srcset, add width and height to avoid layout shift and serve from the CDN. Using them consistently is the baseline, and everything else builds on it.

image_url returns the CDN URL of an image from an article, collection, image, line_item, product, variant or country object (or their src). Its parameters are:

Parameter Behaviour

width, height

Output size in pixels, up to 5760. At least one is required: with neither the filter returns an error. With one, the other is computed from the aspect ratio. An image is never enlarged beyond its original dimensions.

crop

Which part to keep when the requested ratio differs from the original: top, center (default), bottom, left, right or region. With region, crop_left, crop_top, crop_width and crop_height define the area, and width and height can resize it. A country flag is an SVG and can only be cropped when format is also given.

format

pjpg (progressive JPEG) or jpg. Only some conversions are possible: png to jpg or pjpg, and jpg to pjpg. Shopify detects the formats a browser supports (WebP, AVIF and others) and picks the best one automatically, taking the features of a requested format into account.

pad_color

A hex color (hex3 or hex6) used to pad the image when the dimensions give a different aspect ratio.

quality

An integer from 10 to 90; lower values reduce size and visual quality. Omitting it lets Shopify choose by format, which is not the same as 100, and the output is not guaranteed to be byte-identical even when the size matches the source.

image_tag turns an image_url into an <img> element:

Parameter Behaviour

width, height

Added from the image URL’s dimensions and aspect ratio by default. Passing a value overrides it; passing nil omits the attribute. If only one is given, only that attribute is added.

widths

A comma-separated list for the srcset widths instead of Shopify’s default set, up to the width in the URL.

sizes

The HTML sizes attribute, which tells the browser how wide the image is displayed so it can choose a srcset entry.

srcset

Overrides the generated srcset; use it only to remove the attribute with nil.

preload

When true, sends a Link header with rel=preload and the matching imagesrcset and imagesizes. It does not change the tag. Use it sparingly, for one above-the-fold image at most.

alt

Overrides the default, which is the media alt text, or the resource title for article, collection, line item, product and variant images.

Any HTML attribute

A parameter named like an attribute (class, loading, fetchpriority, id) is added to the tag.

When the image has a focal point set in the admin, image_tag also adds an object-position style. Without preload, loading is set to lazy automatically for images in sections further down the page, and the documentation warns not to lazy-load images above the fold; where the default does not fit, write your own logic with section.index and section.location.

Shopify’s example of the default output, for {{ product | image_url: width: 200 | image_tag }}, is an <img> with the CDN src, the alt, a one-entry srcset, and width and height:

<img src="//polinas-potent-potions.myshopify.com/cdn/shop/files/science-beakers-blue-light-new.jpg?v=1683744744&amp;width=200" alt="Health potion" srcset="//polinas-potent-potions.myshopify.com/cdn/shop/files/science-beakers-blue-light-new.jpg?v=1683744744&amp;width=200 200w" width="200" height="133">

This output comes from the documentation; it was not produced here, because the filter needs a store to render.

The LCP image

For most pages the LCP element is one image: the hero on the home page, the first gallery image on a product page, the collection banner. Shopify lists these as its highest-impact recommendations, in this order of importance:

  • Never lazy-load the LCP image. loading="lazy" delays an image until layout completes. Load every image that is visible in the initial viewport eagerly.

  • Mark the LCP image with fetchpriority="high", which tells the browser to fetch it before other resources.

  • Do not hide it behind a fade-in or reveal animation: the LCP event waits for the animation even after the download ends.

  • Use an <img>, not a CSS background-image, for hero content, so that the browser’s preload scanner discovers it early.

  • Render essential content in Liquid and HTML, not in JavaScript.

  • Use <picture> when the mobile and desktop heroes are different images, instead of two images toggled with CSS, so the browser downloads only the one that matches.

  • Preload sparingly: one or two resources that the browser finds late, because every hint competes for the same limited header.

The Pocket Coffee hero section implements these. The first section of a template is above the fold, and section.index reports a section’s 1-based position within its location, so the section loads its image eagerly and with high priority only when section.index is 1. section.index is nil for a static section, while the theme editor renders, and in the Section Rendering API, so the test is written to fail safe: in those contexts the image gets the default behaviour of image_tag rather than high priority by accident. The section also supports an optional separate mobile image through a <picture> with a media query, a sizes of 100vw, and an explicit widths list.

{%- liquid
  # Only the first section of a template is above the fold. section.index is nil
  # for static sections, in the theme editor and in the Section Rendering API,
  # so the test must be written to fail safe (eager only when it is 1).
  assign is_first = false
  if section.index == 1
    assign is_first = true
  endif

  assign sizes = '100vw'
  assign widths = '375, 750, 1100, 1500, 1780, 2000, 3000'
-%}

<section class="hero" aria-labelledby="hero-heading-{{ section.id }}">
  {%- if section.settings.image != blank -%}
    <picture class="hero__media">
      {%- if section.settings.image_mobile != blank -%}
        <source
          media="(max-width: 749px)"
          srcset="
            {{ section.settings.image_mobile | image_url: width: 375 }} 375w,
            {{ section.settings.image_mobile | image_url: width: 750 }} 750w
          "
          sizes="100vw"
          width="{{ section.settings.image_mobile.width }}"
          height="{{ section.settings.image_mobile.height }}"
        >
      {%- endif -%}
      {%- if is_first -%}
        {{
          section.settings.image
          | image_url: width: 3000
          | image_tag:
            sizes: sizes,
            widths: widths,
            loading: 'eager',
            fetchpriority: 'high',
            class: 'hero__image'
        }}
      {%- else -%}
        {{
          section.settings.image
          | image_url: width: 3000
          | image_tag: sizes: sizes, widths: widths, class: 'hero__image'
        }}
      {%- endif -%}
    </picture>
  {%- endif -%}

  <div class="hero__content">
    <h1 id="hero-heading-{{ section.id }}">{{ section.settings.heading }}</h1>
    {%- if section.settings.text != blank -%}
      <p>{{ section.settings.text }}</p>
    {%- endif -%}
    {%- if section.settings.button_link != blank -%}
      <a class="button" href="{{ section.settings.button_link }}">{{ section.settings.button_label }}</a>
    {%- endif -%}
  </div>
</section>

{% stylesheet %}
  .hero {
    position: relative;
    display: grid;
  }
  .hero__media,
  .hero__content {
    grid-area: 1 / 1;
  }
  .hero__image {
    width: 100%;
    height: auto;
    aspect-ratio: 16 / 9;
    object-fit: cover;
  }
  .hero__content {
    align-self: end;
    padding: 2rem var(--page-margin);
    color: #fff;
    background: linear-gradient(transparent, rgb(0 0 0 / 70%));
  }
{% endstylesheet %}

{% schema %}
{
  "name": "Hero banner",
  "settings": [
    { "type": "image_picker", "id": "image", "label": "Image" },
    { "type": "image_picker", "id": "image_mobile", "label": "Mobile image" },
    { "type": "text", "id": "heading", "label": "Heading", "default": "Freshly roasted, shipped in 24 hours" },
    { "type": "textarea", "id": "text", "label": "Text" },
    { "type": "text", "id": "button_label", "label": "Button label", "default": "Shop beans" },
    { "type": "url", "id": "button_link", "label": "Button link" }
  ],
  "presets": [{ "name": "Hero banner" }]
}
{% endschema %}

Dawn uses the same idea: its image banner, image with text and slideshow sections compute fetch_priority from section.index == 1 and pass it to image_tag as fetchpriority. The product gallery of the Pocket Coffee product section applies it to the first image only, and the rest of the gallery is lazy with a smaller sizes, so the browser spends its first requests on the image the visitor sees.

Layout order: first-paint resources before content_for_header

Streaming makes the order of the layout part of performance. Everything above {{ content_for_header }} reaches the browser while the sections are still rendering, so stylesheets, font preloads, the import map and CSS variables that the first paint needs belong above it, and the browser can download them in that time. A page streams when it renders from a JSON template, when content_for_header is a plain output tag (no filter, not inside another tag or snippet, not assigned to a variable first) and when it sits inside <head> before the closing tag. A wrapped, filtered or relocated tag turns streaming off for every page that uses the layout. Preview themes, the preview bar and the theme editor are not streamed, so measure on the live theme.

Layout split into a streamed first chunk above content_for_header and a second chunk with the sections, with a timeline of the browser fetching resources

The Pocket Coffee layout follows the figure. The import map, CSS variables, the critical stylesheet (preloaded through stylesheet_tag: preload: true, which also puts it in the Early Hints header) and the SEO head are above content_for_header; the header group, the main content, the footer group, the cart drawer and the module script follow.

<!doctype html>
<html lang="{{ request.locale.iso_code }}">
  <head>
    {% # Import map for the cart modules, above content_for_header so it is part of the first streamed chunk %}
    <script type="importmap">
      { "imports": { "cart-api": {{ 'cart-api.js' | asset_url | json }} } }
    </script>

    {% # Inlined CSS variables and fonts %}
    {% render 'css-variables' %}

    {% # Critical CSS, loaded synchronously and preloaded through the Link header %}
    {{ 'critical.css' | asset_url | stylesheet_tag: preload: true }}

    {% # Title, description, canonical, social tags and structured data %}
    {% render 'seo-head' %}

    {{ content_for_header }}
  </head>

  <body>
    <a class="skip-link" href="#MainContent">{{ 'accessibility.skip_to_content' | t }}</a>

    {% sections 'header-group' %}

    <main id="MainContent" tabindex="-1">
      {{ content_for_layout }}
    </main>

    {% sections 'footer-group' %}

    {% section 'cart-drawer' %}

    <script src="{{ 'pocket-cart.js' | asset_url }}" type="module"></script>
  </body>
</html>

Two cautions go with this layout. The TTFB that a tool reports on a streamed page marks when the first part arrives, not when rendering finishes, so judge server work by the total duration of the document request. And everything above the tag must be cheap Liquid, because Shopify renders it before it can send the first part.

Stylesheets, fonts and scripts

  • Stylesheets. Keep the number of <link rel="stylesheet"> tags low, because each extra stylesheet triggers a style recalculation that hurts INP. Load the critical CSS synchronously. stylesheet_tag: preload: true adds the preload hint. Shopify subsets the CSS of {% stylesheet %} tags so each page loads only the styles of the sections it renders, so a class must be used only within the file that defines it or files it renders directly.

  • Fonts. The font_face filter outputs the @font-face rule; Skeleton’s css-variables snippet calls it with font_display: 'swap' for each font variant. A web font blocks text rendering until it loads or times out; system fonts avoid the download, and web fonts uploaded to the Shopify CDN avoid the DNS and connection cost of Google Fonts. Use size-adjust and the other override descriptors on the fallback font to reduce layout shift when the web font swaps in. Theme Check reports a redundant preconnect to Shopify’s CDN (CdnPreconnect) and manual preloads that a Shopify filter would do better (AssetPreload).

  • Scripts. Use defer for non-critical scripts, async only for independent third-party scripts whose order does not matter, and type="module" (deferred by default) for the theme’s own modules. Load code on interaction with dynamic import() inside event listeners. Prefer an import map with bare specifiers over a bundler: the Pocket Coffee layout maps cart-api to its versioned asset URL, so pocket-cart.js can import { addToCart } from 'cart-api' and still gets cache busting. Do not load another copy of es-module-shims.

  • Apps. Third-party scripts are the largest source of INP and LCP problems on many stores. Audit every script, remove render-blocking apps and A/B-test anti-flicker snippets that run when no test is active, and reserve space with min-height for app content so that it does not shift the layout. App blocks and app embed blocks of theme app extensions are the supported way for an app to appear in a theme; the older script tags are being retired (scriptTagCreate and scriptTagUpdate return an error from 2026-10-01 and Shopify stops injecting script tags into online store pages on 2027-03-01). The Theme App Extensions and Web Pixels page covers the replacement.

Liquid performance

The server time of a page is the time its Liquid takes, and Shopify’s recommendations for it are about how cost grows with the size of the catalog. A theme that is fast with 10 products can be slow with 100.

Recommendation Why and how

Avoid deeply nested loops

Loops nested across products, variants and options grow quadratically. It is the most common cause of a high TTFB.

Move metafield access out of loops

A metafield read inside a loop costs a lookup per iteration; read it once before the loop, and prefer theme settings for static data. The Pocket Coffee product reads custom.roast_date once.

Do not over-fetch variants

Iterating product.variants forces database queries. Use product.options_with_values for option UI, and load only the variant data that the first render needs.

Limit what a loop fetches

A for loop’s limit reduces the fetch for collection.products and collections; other arrays need paginate. The pagination ceiling is 25,000 objects.

Flatten render nesting

Each nested snippet adds overhead, especially inside a loop. Prefer one level of snippets.

Move repeated work out of loops

Assign filter results before the loop, assign block.settings to a variable, and use echo or output tags instead of append and prepend chains.

Defer dialog content

Guard expensive Liquid in dialogs and drawers so it runs only when the component opens. The cart drawer is a static section that re-renders after each change, so it is cheap by construction.

Use section.index and section.location

Apply different loading strategies above and below the fold.

Use the Section Rendering API for updates

Re-render a section through Ajax instead of reloading the page. The Ajax part below does this.

To see which Liquid line costs time, use the Shopify Theme Inspector for Chrome, and compare the document request’s total duration with its TTFB in DevTools.

Measuring: Theme Check, Lighthouse and field data

Theme Check is the fast, offline half of performance testing: it catches parser-blocking scripts, images without dimensions, remote assets and missing assets in the editor and in CI, before anything is deployed. The whole Pocket Coffee theme passes, also with the strictest preset:

$ shopify theme check
│  46 files inspected with no offenses found.                                  │
$ shopify theme check -C theme-check:all
│  46 files inspected with no offenses found.                                  │

To show the checks working, a throwaway snippet was added with five common mistakes: a remote script without defer, a local script without defer that points at a missing file, an img without dimensions, a hard-coded /cart link and a remote stylesheet.

<script src="https://cdn.example.com/slider.js"></script>
<script src="{{ 'theme.js' | asset_url }}"></script>
<img src="{{ product.featured_image | image_url: width: 800 }}" alt="">
<a href="/cart">Cart</a>
<link rel="stylesheet" href="https://fonts.example.com/css?family=Work+Sans">
{{ 'missing.css' | asset_url | stylesheet_tag }}

Theme Check 3.31.0 reported (the run exits with an error, because the default fail level is error, and the snippet was deleted afterwards):

line 1  error    ParserBlockingScript   Avoid parser blocking scripts by adding `defer` or `async` on this tag
line 1  warning  RemoteAsset            Asset should be served by the Shopify CDN for better performance.
line 2  error    MissingAsset           'assets/theme.js' does not exist
line 2  error    ParserBlockingScript   Avoid parser blocking scripts by adding `defer` or `async` on this tag
line 3  error    ImgWidthAndHeight      Missing width and height attributes on img tag
line 4  warning  HardcodedRoutes        Use routes object {{ routes.cart_url }} instead of hardcoding /cart
line 5  warning  RemoteAsset            Asset should be served by the Shopify CDN for better performance.
line 6  error    MissingAsset           'assets/missing.css' does not exist

The other half is Lighthouse and real-user data, which need a deployed theme. Shopify recommends:

  • Lighthouse against a development store populated with Shopify’s performance test data, run in CI with the Shopify Lighthouse CI GitHub action, which tests the home page, a product page and a collection page on every pull request. The action connects to the store with the client ID and secret of a Dev Dashboard app, stored as repository secrets.

  • The Shopify Web Performance Dashboard in the admin, which reports the Core Web Vitals from real visitors by page type, device and region.

  • Lab data to debug and field data to decide: use field data to find what to fix, lab data to confirm the fix, and field data again to verify that it landed.

Dawn 16.0.0 and Horizon 4.3.0, the two reference themes with real section libraries, give a measured baseline for Theme Check on this CLI: Dawn has 155 files and 9 warnings (an orphaned snippet, two variable names, two unused assigns, one Liquid complexity warning and three undefined objects) and Horizon has 357 files and 6 warnings (one excessive-settings warning and five unused doc parameters); Skeleton has none.

Performance pitfalls

  • A score from the theme editor preview or a preview theme is not representative, because those are not streamed, and the first visit to a page right after publishing a change can arrive without Early Hints.

  • Apps that cheat Lighthouse (they inject transparent elements or serve different pages to crawlers) improve the lab score and hurt visitors; Shopify tells developers to avoid them.

  • Lazy-loading everything, including the hero, is the most common self-inflicted LCP regression. image_tag already makes the right choice for sections far down the page; override it only for the hero.

  • A hero built as a CSS background, an animated reveal or a carousel that loads every slide are the other three.

  • A preload on every image makes the Early Hints header fill up and pushes out the hints that matter.

Accessibility

An accessible theme can be used by everyone, including people who rely on a keyboard, a screen reader or a switch device. In a shop it also protects revenue: a visitor who cannot reach the add to cart button cannot buy. Shopify’s best practices were written with the Web Content Accessibility Guidelines (WCAG) in mind and are organized by the four principles of WCAG: perceivable, operable, understandable and robust. The general standard, the conformance levels, the legal background and the validation tools are explained in the repository’s Web Accessibility page; this part covers what is specific to a Shopify theme.

Following the best practices does not by itself make a theme accessible, and Shopify says so. Treat the lists as the minimum, test with a keyboard and a screen reader, and use automated tools to catch regressions.

What the Theme Store requires

The Theme Store accessibility requirements are the part that is checked on submission, and they are a good minimum for any theme:

  • Every part of a page is keyboard accessible, including dropdown navigation, and focusable elements show a visible focus state. Keyboard focus order matches the DOM order, top to bottom and left to right.

  • Every image has an alt attribute. Product images use image.alt or image_tag with an alt argument.

  • Form inputs have unique IDs, and labels have for attributes that match the input ID.

  • The HTML is valid.

  • Text contrast is at least 4.5:1 for main body content, and 3:1 for text larger than 18 pt and for non-text elements such as borders and icons.

  • Pointer targets are at least 24 by 24 CSS pixels, except inline body text and elements that meet another exception of WCAG 2.2 target size (minimum). Shopify’s separate best practices page recommends 44 by 44 pixels for primary controls.

  • Headings h1 to h6 are visually different from each other.

  • The minimum average Lighthouse accessibility score is 90 on the home, product and collection pages, desktop and mobile.

Best practices applied to Pocket Coffee

The table walks through Shopify’s best-practice checklist and shows where the Pocket Coffee theme meets each item, so the code in this page can be read as a worked answer.

Best practice Why In the Pocket Coffee theme

lang on html, zoom enabled

Screen readers pronounce text with the right accent; low-vision users must be able to zoom. Do not use maximum-scale or user-scalable="no".

<html lang="{{ request.locale.iso_code }}"> in the layout; the viewport meta in seo-head has no zoom restriction.

Skip link and a focusable main

Keyboard users skip the header on every page. The container needs tabindex="-1" to receive focus.

The layout starts with a.skip-link to #MainContent and wraps content_for_layout in main id="MainContent" tabindex="-1". The link is off-screen until focused.

Linear content flow

No positive tabindex and no autofocus: both force a focus order the visitor did not choose.

None in the Pocket Coffee files. The only tabindex values are -1 on the main container and the drawer heading, which are focused by code.

Headings in sequence, one h1

Headings convey the outline of the page, not its styling.

One h1 per template (product title, hero heading, cart title). The cart drawer title is an h2.

Navigation in nav, aria-current

Assistive technology lists landmarks and announces the current page. Do not use role="menu" for site navigation.

The header section wraps the menu in a labelled nav and sets aria-current="page" on the link whose link.current is true. The icon links have aria-label attributes.

Sale and regular prices marked for screen readers

A struck-through price is only a visual cue.

The price snippet adds visually hidden text ("Regular price", "Sale price", "Unit price").

Dynamic price and availability announced

When a variant change updates the price, aria-live tells the screen reader.

The product section does not rewrite the price when the select changes: each option’s text carries its price. A script that does rewrite it must write the change to a live region. The add to cart error uses role="alert", and the cart drawer has a status region that is updated after every cart change.

Links for navigation, buttons for actions

Native elements bring keyboard support and semantics.

a for product and cart links; button for add to cart, close and checkout.

Forms: labels, required, autocomplete, errors in aria-describedby

Every field needs a name, and errors have to be announced and reachable.

Labels with for on the variant select and the quantity input; errors go in an element with role="alert".

Images: alt always, empty for decoration

A missing alt makes the screen reader read the file name.

alt from the media, falling back to the product title, in the gallery and in the cart drawer.

Media does not autoplay

Motion and sound are disruptive; autoplay can be paused or must be muted.

No autoplaying media. prefers-reduced-motion shortens animation and transition durations in critical.css.

Color and contrast

4.5:1 for normal text, 3:1 for large text, icons and input borders; color is not the only signal.

Defined by the theme settings and checked with a contrast tool; the focus outline uses currentColor with a 3 px width.

Drawers and modals

Focus moves into the modal, stays inside, Escape closes it and focus returns to the launcher; the role is dialog.

The cart drawer is a native dialog opened with showModal(), which provides all four behaviours.

Touch targets

Small targets are hard to hit.

critical.css gives buttons, selects and inputs a minimum block size of 44 pixels and buttons a minimum inline size of 44 pixels.

The layout code that provides the skip link and the landmark is shown in the performance part. The header section of the theme, with the labelled navigation and the cart link that carries the item count, is:

<header>
  <h2 class="header__title">
    {{ shop.name | link_to: routes.root_url }}
  </h2>

  <nav class="header__menu" aria-label="{{ 'accessibility.main_menu' | t }}">
    {%- for link in section.settings.menu.links -%}
      <a href="{{ link.url }}"{% if link.current %} aria-current="page"{% endif %}>{{ link.title }}</a>
    {%- endfor -%}
  </nav>

  <div class="header__icons">
    {% if shop.customer_accounts_enabled %}
      <a href="{{ routes.account_url }}" aria-label="{{ 'accessibility.account' | t }}">
        {{ 'icon-account.svg' | inline_asset_content }}
      </a>
    {% endif %}

    <a href="{{ routes.cart_url }}" aria-label="{{ 'cart.title' | t }}">
      <sup data-cart-count>{{ cart.item_count }}</sup>
      {{ 'icon-cart.svg' | inline_asset_content }}
    </a>
  </div>
</header>

The accessibility lines of critical.css are:

/* Accessibility helpers used by the Pocket Coffee snippets */
.visually-hidden {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: -1px;
  padding: 0;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
  border: 0;
}

.skip-link {
  position: absolute;
  inset-inline-start: 0;
  transform: translateY(-100%);
}

.skip-link:focus {
  transform: none;
}

:focus-visible {
  outline: 3px solid currentColor;
  outline-offset: 2px;
}

@media (prefers-reduced-motion: reduce) {
  * {
    animation-duration: 0.01ms !important;
    transition-duration: 0.01ms !important;
  }
}

/* Primary controls are at least 44 by 44 CSS pixels */
button,
.button,
select,
input:not([type='hidden'], [type='radio'], [type='checkbox']) {
  min-block-size: 44px;
}

button,
.button {
  min-inline-size: 44px;
}

The visually-hidden class removes an element from the layout without removing it from the accessibility tree. A display: none or hidden element is not read by a screen reader, so it is the wrong tool for text that exists for assistive technology only. The skip link uses a transform instead, so that it exists in the layout and appears when it receives focus.

The accessible cart drawer

The drawer in the Ajax part is built from the elements that give it accessibility for free. A native <dialog> opened with showModal() is modal: the rest of the page becomes inert, focus moves into the dialog, Escape closes it and focus returns to the element that opened it. It is labelled by its heading with aria-labelledby. The status paragraph is present in the page before any message is written to it, which matters because a live region only announces changes made after it exists. After a quantity change, focus is placed back on the same input, or on the drawer heading when the line disappeared, so a keyboard user is not dropped at the top of the page. The DOM tests described in the Ajax part check the status text and the focus handling after a change; modality, Escape and focus restoration are native browser behaviour of dialog and were not tested here.

Testing accessibility

  • Automated: Lighthouse, WAVE and Accessibility Insights for Web, and a CI check with the Shopify Lighthouse CI action, which scores accessibility along with performance on every pull request. Automated tools find only a part of the problems.

  • Valid HTML: the W3C HTML checker on the rendered page; Theme Check’s LiquidHTMLSyntaxError and UnclosedHTMLElement catch many errors earlier, in the editor.

  • Manual: tab through every page and check that the focus indicator is visible, that nothing needs a mouse hover and that the order matches the reading order. Operate the cart, the variant picker and the drawer using only the keyboard, then repeat with a screen reader.

  • A contrast tool for text, icons and input borders.

Dawn 16.0.0 follows the same checklist: it has a skip link to #MainContent and a second skip link on the product gallery to the product information, and its cart uses a polite live region (aria-live="polite" with role="status") for cart updates. The merchant’s data entry is part of the result: image_tag takes its default alt from the media alt text, so the theme can print what the merchant wrote, but it cannot write it.

Images, media and the web

Alternative text, dimensions and lazy loading belong to both accessibility and performance, and the repository’s media optimization section explains the browser side in depth, in particular native HTML images and lazy loading (srcset, sizes, loading, fetchpriority and <picture>), which is what image_tag generates.

The Ajax Cart API

The Ajax API is a set of lightweight REST-style endpoints that a Shopify-hosted theme calls from JavaScript, to add products to the cart and update the cart counter, to suggest related products and to suggest products and collections while a visitor types in a search box, all without a page reload. The Cart API is the part used most: it reads and changes the cart of the current session.

The rules that define it:

  • It is only for themes hosted by Shopify. It cannot be used from a custom storefront; a Hydrogen or other headless storefront uses the cart of the Storefront API instead.

  • It is unauthenticated. It needs no access token or client ID, and it works on the cart of the visitor’s current session, so never send an Admin or Storefront token to it.

  • Requests are GET to read the cart and some product data, and POST to change the cart of the current session. Responses are JSON.

  • There are no hard rate limits, but it is subject to Shopify’s standard abuse prevention. Repeated failed attempts to add items to the same cart are throttled and return HTTP 429.

  • It cannot read customer or order data or change store data; for that, use the GraphQL Admin API.

  • A product JSON response lists at most 250 variants.

  • It is unversioned, like Liquid: Shopify can change it at any time, so keep the code that calls it in one module (the cart-api.js below) and test it.

Locale-aware URLs

A store that sells in several languages or countries has localized URLs (/en/cart/add.js), and the Ajax API must be called on the URL of the language and country the visitor chose, or the visitor is forced back to the default. The global window.Shopify.routes.root is the base for every Ajax URL. It always ends in /, so plain string concatenation is safe:

var cartContents = fetch(window.Shopify.routes.root + 'cart.js')
  .then(response => response.json())
  .then(data => { return data });

In Liquid the same information is in routes.cart_add_url, routes.cart_change_url, routes.cart_update_url and routes.cart_clear_url. In the endpoint names below {locale} stands for that root. For Pocket Coffee, whose Spanish storefront is the default and whose English storefront has a prefix, the add endpoint is /cart/add.js in Spanish and /en/cart/add.js in English.

The endpoints

Endpoint Purpose Body and result

POST {locale}/cart/add.js

Add one or more variants.

JSON { "items": [ { "id": <variant id>, "quantity": n } ] } or a FormData built from the add to cart form. Each item may carry properties, selling_plan and parent_id. The response is the JSON of the added line items; with the same variant already in the cart, quantity is the new total for that line.

GET {locale}/cart.js

Read the cart.

The cart JSON. All monetary values are in the visitor’s presentment currency, given by the currency field.

POST {locale}/cart/update.js

Update several lines, the note, the attributes or the discounts at once.

updates (an object keyed by variant ID or line item key, or an array of quantities by position), note, attributes, discount. Returns the cart.

POST {locale}/cart/change.js

Change one existing line.

id (variant ID or line item key) or line (1-based position), plus quantity, properties or selling_plan. Returns the cart.

POST {locale}/cart/clear.js

Set every quantity to zero.

Returns the empty cart.

POST {locale}/cart/prepare_shipping_rates.json, GET {locale}/cart/async_shipping_rates.json

Calculate shipping rates for a destination.

Prepare returns null; asking for the async rates with the same shipping_address parameters returns null until the calculation finishes, then shipping_rates. Preferred over the throttled shipping_rates.json.

GET {locale}/products/{handle}.js

A product as JSON, with up to 250 variants.

Monetary values in the presentment currency.

GET {locale}/recommendations/products.json

Recommended products for a product.

Used for the recommendations block of the product page.

GET {locale}/search/suggest.json

Predictive search suggestions for products, collections, pages and articles.

Used for a search box that suggests as the visitor types.

Line items: how lines merge and split

The Ajax Cart API behaves according to how Shopify models lines, and several of its surprises come from that.

  • Lines are identified by a key of the form variantId:hash. If the same variant is added again with identical price, properties and selling plan, the lines merge and the quantity grows. If the price differs (for instance because an automatic discount applies), or the properties or the selling plan differ, the items split into separate lines with the same variant_id.

  • Because of that, variant_id is not unique. To change a line when the cart may hold duplicates, address it by the line item key, or by line, the 1-based index. The documentation shows change.js with a numeric variant ID, a string key and a line; with a key the id value must be a string. The Pocket Coffee drawer uses line.

  • Line item properties are free-form key/value pairs on a line (an engraving text, a gift message). A request that includes properties overwrites the whole object, and once set it cannot be changed to an empty object; to clear them remove the line. A property whose name starts with an underscore (_foo) is private: hidden at checkout but visible in the admin order details. The storefront does not hide it in the theme, so filter keys that start with _ out of line_item.properties in Liquid. Cart attributes starting with a double underscore (__) are private too, and those do not appear in cart.attributes or in the Ajax response, so they need no theme change.

  • A selling plan is added with selling_plan and the ID of the plan; change.js sets one with an ID, or removes it with null or an empty string, and for that operation the line must be addressed with line and quantity should be given. parent_id in add.js attaches the new line to a parent line, and the response then carries a parent_relationship with the parent’s key.

  • The quantity of a variant that is added is capped by stock: adding 20 when 10 are available adds 10 and returns an error describing the limit. The line quantity set by update.js for a variant already in the cart is not validated against inventory, so a theme can add more than is available; checkout still validates it.

  • Discount codes are applied with the discount field of update.js, comma-separated for several, and removed with an empty string. Automatic discounts, such as Pocket Coffee’s Coffee Club rule, need no call: they appear in the line’s discounts and in final_price and final_line_price. Prices in the JSON are integers in the smallest currency unit: the documentation’s items_subtotal_price: 100100 corresponds to the "$1,001.00 CAD" in the live-region text of the same example. Format money in Liquid, not in JavaScript, since the store’s money format may differ from Intl.NumberFormat; bundled section rendering exists for this.

Errors

An error response has an HTTP error status and a JSON body with status, message and description. The cases in the documentation are:

Endpoint and situation HTTP status Body

add.js, quantity above stock

422

message: "Cart Error", a description such as "You can’t add more item name to the cart." The maximum available quantity is added.

add.js, product sold out

422

"The product 'item name' is already sold out."

add.js, all stock already in the cart

422

"You can’t add more item name to the cart."

add.js, variant does not exist or is not in the Online Store channel

422

message: "Cart Error", description: "Cannot find variant". A variant created, updated or published through the Admin API takes a few seconds to reach the cart; retry after a short delay that grows, and stop after a few attempts.

update.js, variant does not exist or is not in the Online Store channel

404

The same Cannot find variant description with status 404.

change.js, the item is not in the cart

400

status: "bad_request", message: "no valid id or line parameter". change.js never adds an item.

add.js, repeated failures on the same cart

429

Throttled. Handle a response body that is not JSON.

The Section Rendering API and bundled section rendering

A cart change normally has to refresh the cart drawer, the counter in the header and sometimes more. Without help the theme would fetch cart.js and rebuild HTML in JavaScript, duplicating the Liquid that already renders the cart. The Section Rendering API lets the theme ask Shopify for the HTML of sections instead:

  • Append ?sections=id1,id2 (or sections[]=id1&sections[]=id2) to any page URL to get a JSON object that maps each section ID to its rendered HTML. Up to five sections can be requested. The section renders in the context of the page URL, so /products/{handle}?sections=... renders it with that product and parameters such as q or page are respected.

  • ?section_id=id returns the HTML of one section directly, and answers 404 when the section does not exist.

  • A section that fails to render comes back as null, possibly inside an HTTP 200 response, so code must handle null.

  • Settings cannot be passed: an existing section uses its saved settings, and otherwise defaults are used.

  • A section ID is the file name for a static section (social.liquid has the ID social; the Pocket Coffee drawer is cart-drawer). For sections in JSON templates or section groups the ID is generated (template--5678__image_banner, sections--1234__header), which is why the markup of a section is wrapped in an element with the ID shopify-section-<id> that scripts can read.

  • section.index is nil when sections are rendered through this API.

Bundled section rendering folds that second request into the first. The Cart API endpoints add, change, clear and update accept a sections parameter (a comma-separated list or an array of up to five IDs) and an optional sections_url. The response is the usual JSON plus a sections key that maps each ID to HTML rendered after the cart change, so the page can update in one round trip:

items: [
  { id: 36110175633573, quantity: 2 }
],
sections: "cart-items,cart-icon-bubble,cart-live-region-text,cart-footer",
sections_url: "/cart?some_param=foo"

By default the sections render in the context of the current page, taken from the HTTP Referer header; sections_url chooses another page, must begin with / and may carry query parameters. Rendering happens after the data change, so a rendering failure does not change the HTTP status of the cart call: the section is null and the cart is already updated. A sections or sections_url value that is invalid (a sections_url that does not begin with /) makes the whole request return 400 Bad Request, although the rest of the request may have succeeded.

Dawn does exactly this. Its product-form.js builds a FormData from the add to cart form, appends the IDs of the sections its cart drawer needs as sections and the current path as sections_url, posts it to routes.cart_add_url and passes the response to cart.renderContents.

The Pocket Coffee cart

The flow of an add to cart in the Pocket Coffee theme is below. The form works without JavaScript, posting to the cart URL and loading the cart page; the script replaces that with the Ajax request when it runs.

sequenceDiagram participant V as Visitor participant J as pocket-cart.js participant S as Shopify storefront V->>J: Submits the add to cart form J->>S: POST /es/cart/add.js with items, sections and sections_url S->>S: Updates the cart of the session S->>S: Renders the cart-drawer section with the new cart alt Cart change succeeded S-->>J: JSON with the line items and sections.cart-drawer J->>J: Swaps the cart body, updates the counter and the live region J-->>V: Opens the drawer and announces the new subtotal else Cart Error (422 or 404) S-->>J: JSON with status, message and description J-->>V: Shows the description in the alert region end

The code has three parts. cart-api.js is a wrapper with no DOM access: it builds locale-aware URLs from Shopify.routes.root, always sends the sections parameter when asked, turns every error response (an HTTP error status or a Cart Error body, JSON or not) into one CartError, and can retry the "Cannot find variant" error with a growing delay. It matches that error by its description, because the documentation gives status 422 for add.js and 404 for update.js. It is loaded through the import map as cart-api.

/**
 * Thin wrapper around the Shopify Ajax Cart API (add.js, change.js, update.js, clear.js, cart.js).
 * No DOM access, so it can be unit-tested outside a browser.
 */

export class CartError extends Error {
  constructor({ status, message, description }) {
    super(description || message || 'Cart Error');
    this.name = 'CartError';
    this.status = status;
    this.description = description || message;
  }
}

/** Always build URLs from the locale-aware root, never from a hard-coded "/cart". */
const root = () => globalThis.Shopify?.routes?.root ?? '/';

/** POST endpoints live under cart/ (cart/add.js); the read endpoint is cart.js at the root. */
const endpoint = (name) => `${root()}cart/${name}.js`;

const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

async function request(url, init = {}) {
  const response = await fetch(url, {
    ...init,
    headers: { Accept: 'application/json', ...(init.headers || {}) },
  });

  let data = null;
  try {
    data = await response.json();
  } catch {
    // A throttled (429) or proxied response is not always JSON.
  }

  // Cart errors carry an HTTP error status and a body with status/message/description.
  if (!response.ok || (data && data.status && data.message === 'Cart Error')) {
    throw new CartError({
      status: data?.status ?? response.status,
      message: data?.message ?? 'Cart Error',
      description: data?.description ?? response.statusText,
    });
  }
  return data;
}

function post(name, payload, { sections = [], sectionsUrl, signal } = {}) {
  const body = { ...payload };
  if (sections.length > 0) {
    // Bundled section rendering: up to five sections, rendered after the cart change.
    body.sections = sections.join(',');
    body.sections_url = sectionsUrl ?? globalThis.location?.pathname ?? '/';
  }
  return request(endpoint(name), {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(body),
    signal,
  });
}

/**
 * Adds one or more variants. Each item: { id, quantity, properties?, selling_plan? }.
 * With retryUnknownVariant, a "Cannot find variant" error is retried with a growing delay
 * (products created or published through the Admin API take a few seconds to reach the cart).
 */
export async function addToCart(items, options = {}) {
  const { retryUnknownVariant = false, attempts = 3, baseDelayMs = 500 } = options;
  for (let attempt = 1; ; attempt += 1) {
    try {
      return await post('add', { items }, options);
    } catch (error) {
      // add.js reports an unknown variant as 422, update.js as 404: match the description, not the status.
      const unknownVariant = error instanceof CartError && /cannot find variant/i.test(error.description ?? '');
      if (!retryUnknownVariant || !unknownVariant || attempt >= attempts) throw error;
      await sleep(baseDelayMs * 2 ** (attempt - 1));
    }
  }
}

/** Changes one existing line by its 1-based position (the safe way to address duplicated variants). */
export function changeLine({ line, quantity, properties, selling_plan }, options = {}) {
  const payload = { line, quantity };
  if (properties !== undefined) payload.properties = properties;
  if (selling_plan !== undefined) payload.selling_plan = selling_plan;
  return post('change', payload, options);
}

/** Bulk update: { updates, note, attributes, discount }. update.js does not validate stock for lines already in the cart. */
export function updateCart(payload, options = {}) {
  return post('update', payload, options);
}

export function clearCart(options = {}) {
  return post('clear', {}, options);
}

export function getCart({ signal } = {}) {
  return request(`${root()}cart.js`, { signal });
}

pocket-cart.js is the DOM layer. It listens on document, so it works for forms and drawers that appear later; it intercepts the add to cart form (form[action*="/cart/add"]), the quantity inputs and remove links of the drawer, and the header’s cart link (which still works as a plain link without JavaScript). It sets aria-disabled on the button while a request is running, and not disabled, so the focus is not lost. It replaces only the cart body of the drawer with the bundled section HTML, updates every data-cart-count element, and writes the section’s data-announce text into the live region that already exists in the page. If the section came back null after an add, it falls back to the cart page.

import { addToCart, changeLine, CartError } from 'cart-api';

// Static section rendered by layout/theme.liquid with {% section 'cart-drawer' %}: its id is the file name.
const SECTIONS = ['cart-drawer'];

const drawer = () => document.querySelector('[data-cart-dialog]');
const live = () => document.querySelector('[data-cart-live]');

/** Replaces the cart body with the HTML Shopify rendered in the same response. Null means the section failed to render. */
function applySections(sections) {
  const html = sections?.['cart-drawer'];
  if (!html) return false;

  const incoming = new DOMParser().parseFromString(html, 'text/html');
  const body = incoming.querySelector('[data-cart-body]');
  const current = document.querySelector('[data-cart-body]');
  if (!body || !current) return false;
  current.replaceWith(body);

  const count = body.dataset.itemCount ?? '0';
  document.querySelectorAll('[data-cart-count]').forEach((el) => {
    el.textContent = count;
  });
  if (live()) live().textContent = body.dataset.announce ?? '';
  return true;
}

function openDrawer() {
  const dialog = drawer();
  if (dialog && !dialog.open) dialog.showModal();
}

async function onAddToCart(form) {
  const button = form.querySelector('[data-add-to-cart]');
  const errorBox = form.querySelector('[data-product-error]');
  const data = new FormData(form);
  const item = { id: Number(data.get('id')), quantity: Number(data.get('quantity')) || 1 };

  button.setAttribute('aria-disabled', 'true');
  errorBox.hidden = true;
  try {
    const result = await addToCart([item], { sections: SECTIONS });
    if (applySections(result.sections)) {
      openDrawer();
    } else {
      // Section failed to render: fall back to the cart page, the cart itself is already updated.
      window.location.assign(`${window.Shopify.routes.root}cart`);
    }
  } catch (error) {
    errorBox.textContent = error instanceof CartError ? error.description : 'Network error';
    errorBox.hidden = false;
  } finally {
    button.removeAttribute('aria-disabled');
  }
}

async function onChangeLine(line, quantity) {
  try {
    const cart = await changeLine({ line, quantity }, { sections: SECTIONS });
    applySections(cart.sections);
    // Keep keyboard users in place: back to the same line, or to the heading when it was removed.
    const next = document.querySelector(`[data-line-qty][data-line="${line}"]`) ?? document.querySelector('[data-cart-heading]');
    next?.focus();
  } catch (error) {
    if (live()) live().textContent = error instanceof CartError ? error.description : 'Network error';
  }
}

document.addEventListener('submit', (event) => {
  const form = event.target;
  if (!(form instanceof HTMLFormElement) || !form.matches('form[action*="/cart/add"]')) return;
  event.preventDefault();
  if (form.querySelector('[data-add-to-cart]').getAttribute('aria-disabled') === 'true') return;
  onAddToCart(form);
});

document.addEventListener('change', (event) => {
  const input = event.target.closest?.('[data-line-qty]');
  if (input) onChangeLine(Number(input.dataset.line), Number(input.value));
});

document.addEventListener('click', (event) => {
  const remove = event.target.closest?.('[data-line-remove]');
  if (remove) {
    event.preventDefault();
    onChangeLine(Number(remove.dataset.line), 0);
    return;
  }
  const cartLink = event.target.closest?.(`a[href="${window.Shopify.routes.root}cart"]`);
  if (cartLink && drawer()) {
    event.preventDefault();
    openDrawer();
  }
  if (event.target.closest?.('[data-cart-close]')) drawer()?.close();
});

The cart drawer is a static section, so its ID is its file name. It prints the item count and an announcement string in data attributes of the body that the script swaps, which is how the counter and the live region are updated from the same HTML that Liquid produced. Prices are printed with the money filter.

{% comment %}
  Static section (id: cart-drawer). Re-rendered by the Ajax Cart API through bundled section rendering.
{% endcomment %}

{%- assign total = cart.total_price | money -%}

<dialog class="cart-drawer" data-cart-dialog aria-labelledby="cart-drawer-heading">
  <div class="cart-drawer__header">
    <h2 id="cart-drawer-heading" tabindex="-1" data-cart-heading>{{ 'cart.title' | t }}</h2>
    <button type="button" data-cart-close>{{ 'accessibility.close' | t }}</button>
  </div>

  <p class="visually-hidden" role="status" aria-live="polite" data-cart-live></p>

  <div
    data-cart-body
    data-item-count="{{ cart.item_count }}"
    data-announce="{{ 'cart.announce' | t: count: cart.item_count, total: total }}"
  >
    {%- if cart.item_count == 0 -%}
      <p>{{ 'cart.empty' | t }}</p>
    {%- else -%}
      <ul class="cart-drawer__lines">
        {%- for item in cart.items -%}
          <li>
            {%- if item.image -%}
              {%- assign alt = item.image.alt | default: item.product.title -%}
              {{ item.image | image_url: width: 160 | image_tag: width: 80, height: 80, loading: 'lazy', alt: alt }}
            {%- endif -%}
            <div>
              <a href="{{ item.url }}">{{ item.product.title }}</a>
              <p>{{ item.variant.title }} - {{ item.final_line_price | money }}</p>
              <label for="qty-{{ forloop.index }}">{{ 'products.quantity' | t }}</label>
              <input
                type="number"
                id="qty-{{ forloop.index }}"
                value="{{ item.quantity }}"
                min="0"
                inputmode="numeric"
                data-line-qty
                data-line="{{ forloop.index }}"
              >
              <a href="{{ item.url_to_remove }}" data-line-remove data-line="{{ forloop.index }}">{{ 'cart.remove' | t }}</a>
            </div>
          </li>
        {%- endfor -%}
      </ul>
      <p><strong>{{ 'cart.subtotal' | t }}</strong> {{ total }}</p>
      <a class="button" href="{{ routes.cart_url }}">{{ 'cart.view_cart' | t }}</a>
      <form action="{{ routes.cart_url }}" method="post">
        <button type="submit" name="checkout">{{ 'cart.checkout' | t }}</button>
      </form>
    {%- endif -%}
  </div>
</dialog>

{% stylesheet %}
  .cart-drawer {
    margin: 0 0 0 auto;
    height: 100dvh;
    max-height: 100dvh;
    width: min(28rem, 100vw);
  }
{% endstylesheet %}

{% schema %}
{
  "name": "Cart drawer",
  "settings": []
}
{% endschema %}

What was tested

cart-api.js was tested in Node with a stubbed fetch that returns the response shapes documented for the Cart API, and pocket-cart.js was tested in jsdom against a page with the add form, the header link and the drawer. These tests found a real defect (the wrapper requested /cart/cart.js for the read endpoint instead of /cart.js), which is fixed in the code above. No request reached a real store.

$ node cart-api.test.mjs
ok  add.js uses the locale-aware root and bundles sections
ok  422 sold out -> CartError
ok  422 Cannot find variant retried once, then added
ok  no retry unless retryUnknownVariant is set
ok  429 without a JSON body -> CartError(429)
ok  change.js addresses the 1-based line and removes with quantity 0
ok  update.js passes discount and note
ok  without Shopify.routes the root falls back to /
$ node pocket-cart.test.mjs
ok  submit -> add.js with bundled section, body swapped, count and live region updated, drawer opened
ok  422 shows the description in the role=alert region
ok  quantity change -> change.js by line, focus returns to the same input
ok  remove -> quantity 0, focus moves to the drawer heading
ok  header cart link opens the drawer (and still works as a link without JavaScript)

Other cart calls: note, attributes, discounts and shipping rates

update.js carries everything that is not a quantity change on a single line. For Pocket Coffee the cart note holds a gift message, an attribute records the grind choice for a starter kit, and a Coffee Club member can enter a code:

import { updateCart } from 'cart-api';

await updateCart({
  note: 'Gift for Ada, please include a card.',
  attributes: { 'Gift wrap': 'Yes' },
  discount: 'COFFEECLUB',
});

A shipping estimate for the Spain zone uses the prepare and async endpoints with the same shipping_address parameters. The destination is given as shipping_address[zip], shipping_address[country] and shipping_address[province], URL encoded:

POST /cart/prepare_shipping_rates.json?shipping_address%5Bzip%5D=28001&shipping_address%5Bcountry%5D=Spain
GET  /cart/async_shipping_rates.json?shipping_address%5Bzip%5D=28001&shipping_address%5Bcountry%5D=Spain

The second call answers null until the rates are ready, so poll with a short, growing delay; a ready response lists shipping_rates with name, price, code and the delivery fields. These two requests were not run.

Pitfalls

  • Use the variant ID, not the product ID, for id. The select name="id" of the product form already holds the variant ID.

  • Set Content-Type: application/json only when the body is JSON, as in the documentation’s first example. With a FormData body leave the header out, so the browser adds the multipart boundary itself.

  • A hard-coded /cart/add.js URL sends the request to the default language’s cart. Use Shopify.routes.root.

  • Do not rebuild the cart HTML in JavaScript, and do not format money in JavaScript. Ask for the section.

  • Handle null sections and non-JSON error responses (HTTP 429) as well as the documented JSON errors.

  • section.index is nil in the Section Rendering API, so code that depends on it for loading hints must tolerate that.

  • A cart drawer opened by JavaScript has to remain reachable without it: keep the cart page and the add to cart form working as plain HTML.

  • The Ajax API is not a place to keep secrets or to read customers and orders. Anything that needs them belongs to an app and the Admin API.

References