All articles
E-commerceJune 15, 2026 6 min read

Variant Switching Without a Full Page Reload: A Practical PDP Pattern for Shopify

Most Shopify themes still reload the PDP when a customer picks a size. Here's the pattern we use to swap variants in under 150ms without breaking SEO, analytics, or the cart.

Variant Switching Without a Full Page Reload: A Practical PDP Pattern for Shopify

Pick a size on most Shopify stores and the whole page reloads. The URL changes to ?variant=4839..., the hero image flickers, the sticky add-to-cart bar rebuilds, and the customer waits 600 – 1200ms for a decision they already made. On mobile, that's the difference between a tap and a bounce.

We've shipped this pattern across enough mid-market stores now that it deserves a write-up. It's not a library, it's not a theme — it's a way of thinking about variant state on a PDP that respects SEO, analytics, inventory accuracy, and the back button.

Why the default behaviour exists

Shopify's variant model treats each variant as a first-class resource. It has its own URL (/products/handle?variant=ID), its own canonical metadata, its own price, and — critically — its own inventory state that can change between the time the page rendered and the time the customer clicks buy.

The default Dawn theme uses a full reload because it's the safest option:

  • Server-rendered HTML always reflects current inventory
  • Search engines get a clean, indexable URL per variant
  • Analytics fires a fresh view_item with the correct variant ID
  • The Add to Cart form references a server-validated variant ID

The problem: it's hostile to conversion. On a 3G connection with a heavy theme, a reload can cost a full second. And if the customer is comparing sizes, they're paying that cost three or four times.

What we're actually optimising for

Before touching code, get clear on the goal. We want:

  1. Sub-150ms perceived swap between variant selections
  2. Correct price, media, and availability at all times
  3. Working browser history — back button should return to the previous variant, not the previous product
  4. Clean analytics — one view_item per real variant view, not per click
  5. No stale add-to-cart — clicking buy must hit the currently selected variant

The pattern: hydrate once, swap state, sync URL

The core idea is that the PDP renders server-side with one default variant, then takes over client-side as a small state machine. Every variant's data is embedded in the initial HTML as JSON, so swapping is a local state change, not a network request.

Here's the Liquid snippet we drop into the product template:

<script type="application/json" id="product-variants-data">
  {
    "productId": {{ product.id | json }},
    "handle": {{ product.handle | json }},
    "variants": [
      {%- for v in product.variants -%}
        {
          "id": {{ v.id }},
          "title": {{ v.title | json }},
          "price": {{ v.price }},
          "compareAt": {{ v.compare_at_price | default: 0 }},
          "available": {{ v.available }},
          "sku": {{ v.sku | json }},
          "options": {{ v.options | json }},
          "featuredMediaId": {{ v.featured_media.id | default: 0 }},
          "barcode": {{ v.barcode | json }}
        }{% unless forloop.last %},{% endunless %}
      {%- endfor -%}
    ]
  }
</script>

That JSON is roughly 200 bytes per variant. Even a product with 40 SKUs adds under 10KB to the document, which is cheaper than a single round-trip to fetch the same data on click.

The client-side swap

The component reads the JSON once, then handles option clicks locally:

const data = JSON.parse(
  document.getElementById('product-variants-data').textContent
);

const state = {
  selected: {}, // { Size: 'M', Color: 'Black' }
  variant: null
};

function resolveVariant() {
  const opts = Object.values(state.selected);
  return data.variants.find(v =>
    v.options.every((o, i) => o === opts[i])
  );
}

function applyVariant(v) {
  if (!v) return renderUnavailable();
  state.variant = v;

  // Price
  document.querySelector('[data-price]').textContent = formatMoney(v.price);

  // Availability
  const cta = document.querySelector('[data-add-to-cart]');
  cta.disabled = !v.available;
  cta.dataset.variantId = v.id;
  cta.textContent = v.available ? 'Add to cart' : 'Sold out';

  // Media — only swap if a featured media is set
  if (v.featuredMediaId) {
    document
      .querySelector(`[data-media-id="${v.featuredMediaId}"]`)
      ?.scrollIntoView({ behavior: 'instant', block: 'nearest' });
  }

  // URL — replaceState for option changes, pushState only on first interaction
  const url = new URL(window.location.href);
  url.searchParams.set('variant', v.id);
  history.replaceState({ variantId: v.id }, '', url);
}

Notice we use replaceState not pushState. We don't want twenty back-button entries because the customer clicked through every size. The first variant change can use pushState if you want the back button to return to the originally-loaded variant — that's a judgement call.

The traps nobody warns you about

This pattern is easy to ship and easy to ship wrong. Here are the four things that bite teams.

Inventory drift

The JSON is a snapshot from page render. If a variant sells out 90 seconds later, your client thinks it's still available. For most stores this is fine — Shopify will reject the add-to-cart and you can handle the error. For high-turnover drops (sneakers, limited editions) you need a soft revalidation:

// Revalidate when the page regains focus or after 60s idle
async function refreshInventory() {
  const res = await fetch(`/products/${data.handle}.js`);
  const fresh = await res.json();
  fresh.variants.forEach(fv => {
    const local = data.variants.find(v => v.id === fv.id);
    if (local) local.available = fv.available;
  });
  if (state.variant) applyVariant(resolveVariant());
}

document.addEventListener('visibilitychange', () => {
  if (!document.hidden) refreshInventory();
});

That /products/{handle}.js endpoint is built into every Shopify store and returns fresh JSON without spinning up a Liquid render. It's typically under 80ms.

Media that doesn't match

If your merchandiser only uploaded one photo per colour (not per size), don't swap media on size changes. The flicker is worse than the inconsistency. Group variants by the option that has distinct media and only react to that:

const MEDIA_DRIVING_OPTION = 'Color';

function handleOptionChange(name, value) {
  state.selected[name] = value;
  const next = resolveVariant();
  const mediaChanged = name === MEDIA_DRIVING_OPTION;
  applyVariant(next, { swapMedia: mediaChanged });
}

Analytics double-counting

If you fire view_item on every variant change, your GA4 will show inflated product views and broken conversion rates. The rule we use: fire view_item once on load with the initially-rendered variant, then fire select_item (or a custom variant_change event) on subsequent swaps. Only fire a second view_item if the URL change is significant enough to count as a new product view — which it usually isn't.

SEO and canonicals

Google indexes variant URLs. If your store relies on per-variant search traffic (think colour-specific landing pages), make sure the canonical tag in your <head> is rendered server-side based on the initial variant and doesn't get mutated by the client swap. Updating history.replaceState doesn't update canonicals automatically, which is what you want here.

When this pattern doesn't fit

A few cases where we don't recommend it:

  • Fully headless storefronts already do this. Hydrogen, Next.js Commerce, and Nuxt Commerce handle variant state in their component model. You don't need to bolt anything on.
  • Heavily configured products (engraving, bundles, made-to-order). The combinatorial explosion of variants makes client-side resolution painful. Use a server-side configurator endpoint instead.
  • B2B catalogs with customer-specific pricing. Prices in the embedded JSON are wrong by definition. Fetch on selection.

For a standard apparel, beauty, or home-goods PDP with under 100 variants, the pattern is close to free wins.

What it's worth

On the stores where we've shipped this — typically replacing a Dawn-style reload pattern — we see PDP interaction-to-next-paint drop from the 600 – 1100ms range to roughly 80 – 180ms. Add-to-cart rate on mobile usually moves a few percent in the right direction, though we'd never claim a universal number; merchandising and traffic mix dominate.

The more interesting effect is qualitative. Customers click through more variants before adding to cart, which suggests they're actually comparing rather than committing to the first option that doesn't reload painfully.

Where we'd start

If you're running a Shopify 2.0 theme today and want to ship this in a week:

  1. Add the variant JSON snippet to your product template. Ship it behind a feature flag and verify the payload size on your largest product.
  2. Wire up the price, availability, and CTA swap first. Leave media for phase two — it's where most of the visual bugs live.
  3. Add the visibilitychange revalidation before you go live. It's cheap insurance against angry support tickets.
  4. Audit your analytics. Decide explicitly what counts as a view_item vs a select_item and document it.

If you'd rather not own this code, our team builds and audits Shopify storefronts for exactly this kind of work — see our e-commerce services or browse other PDP and checkout posts on the blog.

#Shopify#PDP#Performance#CRO#Frontend

Want a team like ours?

72Technologies builds production software for the kind of teams who actually read this blog.

Start a project