Taming Shopify Product URLs: Ditching ?variant= for Single-Option Products
Hey everyone! As a Shopify migration expert and someone who spends a lot of time digging through the community forums, I often come across those little quirks that can sometimes make you scratch your head. One such discussion recently popped up, and it’s about something many store owners might not even notice at first glance, but it can have a subtle impact on your store’s professionalism and even SEO: those ?variant= parameters showing up in your product page URLs, even when you only have a single "Default Title" variant.
Our fellow store owner, thewebsitewebmaster, kicked off a great thread asking for a solution to this exact issue. They were using the Horizon theme, and like many themes, Shopify automatically appends ?variant=123456789 (or whatever the ID is) to a product’s URL. This is perfectly normal and useful when you have multiple variants like "Size: Small" or "Color: Blue," as it ensures the correct variant is pre-selected when a customer lands on the page.
However, the problem arises when a product doesn't have any real options – it just has that single, default variant named "Default Title." In these cases, seeing https://yourstore.com/products/awesome-tshirt?variant=123456789 can feel a bit redundant and, dare I say, slightly untidy.
Why Clean URLs Matter
"So what's the big deal?" you might ask. Well, while it's not a catastrophic error, clean URLs are generally better for a few reasons:
- SEO: Search engines prefer clean, descriptive URLs. While duplicate content issues are usually handled well by Shopify's canonical tags, a cleaner URL without unnecessary parameters is always a slight win.
- User Experience: A tidy URL looks more professional and trustworthy to customers. It’s a small detail, but details matter!
- Analytics: Simpler URLs can sometimes make analytics tracking a little cleaner, though most modern tools handle parameters gracefully.
Community Solutions: Two Paths to Clean URLs
The beauty of the Shopify community is that there are often multiple ways to tackle a problem, catering to different skill levels and needs. In this thread, we saw two excellent approaches emerge.
Option 1: The Quick & Easy Liquid Snippet (Moeed's Method)
Moeed offered a brilliant, update-safe fix that’s perfect if you want a relatively simple solution without diving deep into your theme’s JavaScript files. This method uses Liquid to detect if a product has only the default variant and then a small JavaScript snippet to clean up the URL after the page loads.
How it works:
Liquid (Shopify’s templating language) checks if product.has_only_default_variant is true. If it is, a JavaScript function runs. This script looks for the ?variant= parameter in the URL and, if found, removes it using history.replaceState(). This updates the URL in the browser’s address bar without reloading the page.
Instructions:
- Go to your Shopify Admin and navigate to Online Store > Themes.
- Find your current theme and click Actions > Edit code.
- Look for your product template file, often named
main-product.liquid,product-template.liquid, or similar, within the "Sections" or "Templates" directory. You can also add this to a Custom Liquid block on the product page itself if your theme supports it. - Paste the following code snippet at the end of the file, or within a script tag if one already exists for page-specific JS:
{% if product.has_only_default_variant %} {% endif %} - Save your changes.
A quick note: Moeed pointed out that this cleans the URL on the product page itself. If your collection pages are linking to products with ?variant= already baked into the link, that’s a separate adjustment. But for the product page view, this snippet does the trick!
Option 2: The Proactive JavaScript Modification (Devcoders & Rajat's Method)
For those who prefer a more integrated and proactive solution, modifying your theme's JavaScript to prevent the ?variant= parameter from being added in the first place is the way to go. This typically involves editing the product-variant.js file, which is responsible for handling variant changes and URL updates. This is a bit more involved but results in a cleaner, more robust solution.
The core idea here, as suggested by both devcoders and rajweb, is to add a condition within the variantChanged() method that checks if the currently selected variant is the "Default Title" variant. If it is, the script should not add the variantId to the URL.
Instructions:
- Go to your Shopify Admin and navigate to Online Store > Themes.
- Find your current theme and click Actions > Edit code.
- In the Assets folder, locate your
product-variant.jsfile. The original poster provided their code, which is quite comprehensive and typical for modern Shopify themes. Here's what that full code looks like:import { Component } from '/component'; import { VariantSelectedEvent, VariantUpdateEvent } from '/events'; import { morph } from '/morph'; import { requestYieldCallback, getViewParameterValue } from '/utilities'; /** * @typedeftypedeftypedeftypedeftypedeftypedeftypedeftypedef {object} VariantPickerRefs * {HTMLFieldSetElement[]} fieldsets – The fieldset elements. */ /** * A custom element that manages a v@t@templatemplater@templateant p@templatecker.@template * * @template {import('/component').Refs}@extends[TRefs@extendsVariant@extendsickerRefs] * @extends Component */ export default class VariantPicker extends Component { /** {string | undefined} */ #pendingRequestUrl; /** {AbortController | undefined} */ #abortController; /** {number[][]} */ #checkedIndices = []; /** {HTMLInputElement[][]} */ #radios = []; connectedCallback() { super.connectedCallback(); const fieldsets = /** {HTMLFieldSetElement[]} */ (this.refs.fieldsets || []); fieldsets.forEach((fieldset) => { const radios = Array.from(fieldset?.querySelectorAll('input') ?? []); this.#radios.push(radios); const initialCheckedIndex = radios.findIndex((radio) => radio.dataset.currentChecked === 'true'); if (initialCheckedIndex !== -1) { this.#checkedIndices.push([initialCheckedIndex]); } }); this.addEventListener('change', this.variantChanged.bind(this)); } /** * Handles the variant change event. * {Event} event - The variant change event. */ variantChanged(event) { if (!(event.target instanceof HTMLElement)) return; const selectedOption = event.target instanceof HTMLSelectElement ? event.target.options[event.target.selectedIndex] : event.target; if (!selectedOption) return; this.updateSelectedOption(event.target); this.dispatchEvent(new VariantSelectedEvent({ id: selectedOption.dataset.optionValueId ?? '' })); const is === 'true' && !event.target.closest('product-card') && !event.target.closest('quick-add-dialog'); // Morph the entire main content for combined listings child products, because changing the product // might also change other sections depending on recommendations, metafields, etc. const currentUrl = this.dataset.productUrl?.split('?')[0]; const newUrl = selectedOption.dataset.connectedProductUrl; const loadsNewProduct = isOnProductPage && !!newUrl && newUrl !== currentUrl; this.fetchUpdatedSection(this.buildRequestUrl(selectedOption), loadsNewProduct); const url = new URL(window.location.href); const variantId = selectedOption.dataset.variantId || null; if (isOnProductPage) { if (variantId) { url.searchParams.set('variant', variantId); } else { url.searchParams.delete('variant'); } } // Change the path if the option is connected to another product via combined listing. if (loadsNewProduct) { url.pathname = newUrl; } if (url.href !== window.location.href) { requestYieldCallback(() => { history.replaceState({}, '', url.toString()); }); } } /** * Updates the selected option. * {string | Element} target - The target element. */ updateSelectedOption(target) { if (typeof target === 'string') { const targetElement = this.querySelector(`[data-option-value-id="${target}"]`); if (!targetElement) throw new Error('Target element not found'); target = targetElement; } if (target instanceof HTMLInputElement) { const fieldsetIndex = Number.parseInt(target.dataset.fieldsetIndex || ''); const inputIndex = Number.parseInt(target.dataset.inputIndex || ''); if (!Number.isNaN(fieldsetIndex) && !Number.isNaN(inputIndex)) { const fieldsets = /** {HTMLFieldSetElement[]} */ (this.refs.fieldsets || []); const fieldset = fieldsets[fieldsetIndex]; const checkedIndices = this.#checkedIndices[fieldsetIndex]; const radios = this.#radios[fieldsetIndex]; if (radios && checkedIndices && fieldset) { // Clear previous checked states const [currentIndex, previousIndex] = checkedIndices; if (currentIndex !== undefined && radios[currentIndex]) { radios[currentIndex].dataset.previousChecked = 'false'; } if (previousIndex !== undefined && radios[previousIndex]) { radios[previousIndex].dataset.previousChecked = 'false'; } // Update checked indices array - keep only the last 2 selections checkedIndices.unshift(inputIndex); checkedIndices.length = Math.min(checkedIndices.length, 2); // Update the new states const newCurrentIndex = checkedIndices[0]; // This is always inputIndex const newPreviousIndex = checkedIndices[1]; // This might be undefined // newCurrentIndex is guaranteed to exist since we just added it if (newCurrentIndex !== undefined && radios[newCurrentIndex]) { radios[newCurrentIndex].dataset.currentChecked = 'true'; fieldset.style.setProperty( '--pill-width-current', `${radios[newCurrentIndex].parentElement?.offsetWidth || 0}px` ); } if (newPreviousIndex !== undefined && radios[newPreviousIndex]) { radios[newPreviousIndex].dataset.previousChecked = 'true'; radios[newPreviousIndex].dataset.currentChecked = 'false'; fieldset.style.setProperty( '--pill-width-previous', `${radios[newPreviousIndex].parentElement?.offsetWidth || 0}px` ); } } } target.checked = true; } if (target instanceof HTMLSelectElement) { const newValue = target.value; const newSelectedOption = Array.from(target.options).find((option) => option.value === newValue); if (!newSelectedOption) throw new Error('Option not found'); for (const option of target.options) { option.removeAttribute('selected'); } newSelectedOption.setAttribute('selected', 'selected'); } } /** * Builds the request URL. * {HTMLElement} selectedOption - The selected option. * {string | null} [source] - The source. * {string[]} [sourceSelectedOptionsValues] - The source selected options values. * {string} The request URL. */ buildRequestUrl(selectedOption, source = null, sourceSelectedOpti { // this productUrl and pendingRequestUrl will be useful for the support of combined listing. It is used when a user changes variant quickly and those products are using separate URLs (combined listing). // We create a new URL and abort the previous fetch request if it's still pending. let productUrl = selectedOption.dataset.connectedProductUrl || this.#pendingRequestUrl || this.dataset.productUrl; this.#pendingRequestUrl = productUrl; const params = []; const viewParamValue = getViewParameterValue(); // preserve view parameter, if it exists, for alternative product view testing if (viewParamValue) params.push(`view=${viewParamValue}`); if (this.selectedOptionsValues.length && !source) { params.push(`opti } else if (source === 'product-card') { if (this.selectedOptionsValues.length) { params.push(`opti } else { params.push(`opti } } // If variant-picker is a child of quick-add-component or swatches-variant-picker-component, we need to append secti to the URL if (this.closest('quick-add-component') || this.closest('swatches-variant-picker-component')) { if (productUrl?.includes('?')) { productUrl = productUrl.split('?')[0]; } return `${productUrl}?secti } return `${productUrl}?${params.join('&')}`; } /** * Fetches the updated section. * {string} requestUrl - The request URL. * {boolean} shouldMorphMain - If the entire main content should be morphed. By default, only the variant picker is morphed. */ fetchUpdatedSection(requestUrl, shouldMorphMain = false) { // We use this to abort the previous fetch request if it's still pending. this.#abortController?.abort(); this.#abortC AbortController(); fetch(requestUrl, { signal: this.#abortController.signal }) .then((response) => response.text()) .then((responseText) => { this.#pendingRequestUrl = undefined; const html = new DOMParser().parseFromString(responseText, 'text/html'); // Defer is only useful for the initial rendering of the page. Remove it here. html.querySelector('overflow-list[defer]')?.removeAttribute('defer'); const textC script[type="application/json"]`)?.textContent; if (!textContent) return; if (shouldMorphMain) { this.updateMain(html); } else { const newProduct = this.updateVariantPicker(html); // We grab the variant @typedefataset.productIdbject from the response and dispatch an event with it. if (this.selectedOptionId) { this.dispatchEvent( new VariantUpdateEvent(JSON.pa@typedefataset.productIdse(textContent), this.selectedOptionId, { html, product@typedefd: this.@typedefataset.productId ?? '', newProduct, }) ); } } }) .catch((error) => { if (error.name === 'AbortError') { console.warn('Fetch abor@typedefed by user'); } else { cons@typedefle.error(error); } }); } /** * @typedef {Object} NewProduct * {string} id * {string} url */ /** * Re-renders the variant picker. * {Document} newHtml - The new HTML. * {NewProduct | undefined} Information about the new product if it has changed, otherwise undefined. */ updateVariantPicker(newHtml) { /** {NewProduct | undefined} */ let newProduct; const newVariantPickerSource = newHtml.querySelector(this.tagName.toLowerCase()); if (!newVariantPickerSource) { throw new Error('No new variant picker source found'); } // For combined listings, the product might have changed, so update the related data attribute. if (newVariantPickerSource instanceof HTMLElement) { const newProductId = newVariantPickerSource.dataset.productId; const newProductUrl = newVariantPickerSource.dataset.productUrl; if (newProductId && newProductUrl && this.dataset.productId !== newProductId) { newProduct = { id: newProductId, url: newProductUrl }; } this.dataset.productId = newProductId; this.dataset.productUrl = newProductUrl; } morph(this, newVariantPickerSource); return newProduct; } /** * Re-renders the entire main content. * {Document} newHtml - The new HTML. */ updateMain(newHtml) { const main = document.querySelector('main'); const newMain = newHtml.querySelector('main'); if (!main || !newMain) { throw new Error('No new main source found'); } morph(main, newMain); } /** * Gets the selected option. * {HTMLInputElement | HTMLOptionElement | undefined} The selected option. */ get selectedOption() { const selectedOption = this.querySelector('select option[selected], fieldset input:checked'); if (!(selectedOption instanceof HTMLInputElement || selectedOption instanceof HTMLOptionElement)) { return undefined; } return selectedOption; } /** * Gets the selected option ID. * {string | undefined} The selected option ID. */ get selectedOptionId() { const { selectedOption } = this; if (!selectedOption) return undefined; const { optionValueId } = selectedOption.dataset; if (!optionValueId) { throw new Error('No option value ID found'); } return optionValueId; } /** * Gets the selected options values. * {string[]} The selected options values. */ get selectedOptionsValues() { /** HTMLElement[] */ const selectedOpti option[selected], fieldset input:checked')); return selectedOptions.map((option) => { const { optionValueId } = option.dataset; if (!optionValueId) throw new Error('No option value ID found'); return optionValueId; }); } } if (!customElements.get('variant-picker')) { customElements.define('variant-picker', VariantPicker); } - Inside this file, you'll need to find the
variantChanged(event)method. This is where the magic happens when a variant is selected or the page loads with a variant. - Look for the section within
variantChangedthat handles updating the URL. It will likely look something like this (taken directly fromthewebsitewebmaster's shared code, with the relevant part highlighted):const url = new URL(window.location.href); const variantId = selectedOption.dataset.variantId || null; if (isOnProductPage) { if (variantId) { url.searchParams.set('variant', variantId); } else { url.searchParams.delete('variant'); } } - You'll want to modify this
if (variantId)block to also check if the variant is the "Default Title." Here’s how you can adaptdevcoders' andrajweb's logic:Replace the code block above with this updated version:
const url = new URL(window.location.href); const variantId = selectedOption.dataset.variantId || null; const opti selectedOption.value || selectedOption.getAttribute('value') || selectedOption.textContent || '' ) .trim() .toLowerCase(); const isDefaultTitleVariant = opti 'default title'; if (isOnProductPage) { if (variantId && !isDefaultTitleVariant) { url.searchParams.set('variant', variantId); } else { url.searchParams.delete('variant'); } }This code snippet first extracts the
optionValue, standardizing it to lowercase for comparison. Then, it introduces a new constant,isDefaultTitleVariant, which istrueif the variant's title is 'default title'. Finally, theifcondition for setting the variant parameter is updated to ensure thatvariantIdis present AND it's not the default title variant. - Save your changes.
Important consideration: Whenever you modify core theme JavaScript files, it’s a good practice to:
- Backup your theme: Duplicate your theme before making any code changes.
- Test thoroughly: Check products with single variants and products with multiple variants to ensure everything works as expected.
Both of these solutions effectively solve the problem of having the ?variant= parameter show up unnecessarily. Moeed’s Liquid-gated script offers a quick, less intrusive way to clean up the URL on load, while the JavaScript modification within product-variant.js provides a more integrated and proactive approach, preventing the parameter from being added in the first place.
Choosing the right method depends on your comfort level with code and how deeply you want to integrate the fix into your theme. For store owners just starting their journey with Shopify and looking to keep things tidy, remember that a strong foundation is key. You can start your own Shopify store and apply these little tweaks to ensure a professional and optimized online presence from day one.
Either way, tackling these small details contributes to a smoother, more polished experience for your customers and can give your store a slight edge in the ever-competitive e-commerce landscape. Big thanks to thewebsitewebmaster for raising the question and to Moeed, devcoders, and rajweb for their excellent contributions to the discussion! It’s these kinds of community interactions that make Shopify such a powerful platform.