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.

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_itemwith the correct variant ID - The
Add to Cartform 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:
- Sub-150ms perceived swap between variant selections
- Correct price, media, and availability at all times
- Working browser history — back button should return to the previous variant, not the previous product
- Clean analytics — one
view_itemper real variant view, not per click - 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:
- Add the variant JSON snippet to your product template. Ship it behind a feature flag and verify the payload size on your largest product.
- Wire up the price, availability, and CTA swap first. Leave media for phase two — it's where most of the visual bugs live.
- Add the
visibilitychangerevalidation before you go live. It's cheap insurance against angry support tickets. - Audit your analytics. Decide explicitly what counts as a
view_itemvs aselect_itemand 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.
Want a team like ours?
72Technologies builds production software for the kind of teams who actually read this blog.
Start a projectKeep reading

Inventory Sync Hell: Why Your Shopify Store Oversells on Black Friday (And the Architecture That Fixes It)
Overselling on peak days is rarely a Shopify bug. It's a sync architecture problem. Here's how we redesigned a multi-channel inventory pipeline to stop the bleed without rewriting the ERP.

WhatsApp Checkout in LATAM: What We Learned Wiring a Shopify Store to a Conversational Funnel
We replaced a third of a Shopify store's checkout volume with a WhatsApp-driven flow. Here's the architecture, the conversion math, and the parts we'd build differently next time.

Headless Shopify Is Not Free: A Honest Look at Hydrogen vs Liquid for Mid-Market Stores
We've shipped both Hydrogen storefronts and heavily customized Liquid themes for stores doing $5M–$50M GMV. Here's where headless actually pays for itself, and where it quietly bleeds money for two years before anyone admits it.
