Update the URL Without Reloading the Page: pushState, replaceState and jQuery

Quick Summary
- history.pushState() changes the address bar and adds a history entry without a reload; history.replaceState() rewrites the current entry, so Back leaves the page.
- Build the new URL with new URL(location.href) and searchParams.set() to keep other parameters and the hash, and restore the page in a popstate handler when the visitor presses Back.
- The History API needs no jQuery; on a WordPress site that already loads jQuery, wrap the code in jQuery(function ($) { ... }) and run it in a change handler.
To change the URL in the address bar without reloading the page, call history.pushState() or history.replaceState() with the new URL. The browser doesn’t request anything from the server. pushState() adds a new entry to the browser history, and replaceState() overwrites the current one. Both are built into the browser, so you don’t need jQuery. When the visitor presses Back or Forward, the browser fires a popstate event, and that’s where you put the page back in the state that matches the URL.
That’s handy when the page changes through JavaScript, for example when a customer picks a product variant and you want the URL to include it, so the link can be shared or bookmarked.
Update the URL when a variant is picked
The example comes from a Shopify-style product page. When the customer picks an option in select.product-single__variants, its value is added to the URL as ?variant=. Shopify reads that parameter on product pages to select the variant (see product.selected_variant), so the new URL also works when someone opens it later.
const variantSelect = document.querySelector('select.product-single__variants');
// Store the variant the page loaded with, so Back can return to it.
history.replaceState({ variant: variantSelect.value }, '');
variantSelect.addEventListener('change', function () {
const url = new URL(window.location.href);
url.searchParams.set('variant', variantSelect.value);
history.pushState({ variant: variantSelect.value }, '', url);
});
window.addEventListener('popstate', function (event) {
if (event.state && event.state.variant) {
variantSelect.value = event.state.variant;
}
});new URL(window.location.href)takes the current URL apart.searchParams.set()adds thevariantparameter, or replaces its value if it’s already there, and encodes it. Other parameters, such asutm_source, and the#hashstay as they are.history.pushState(state, '', url)changes the address bar and adds a history entry. The first argument is stored with the entry and comes back in thepopstateevent. The second argument is no longer used, but it can’t be left out; MDN recommends an empty string.- The
replaceState()call at the top stores the variant the page loaded with in the current entry, so thepopstatehandler has something to restore when the visitor goes all the way back. - In the
popstatehandler,event.stateis the object stored with the entry the visitor went back or forward to. Setting the select’svaluefrom script doesn’t fire achangeevent, so going back doesn’t push a new entry.
Tested in headless Chrome 153 with a page opened as /product?utm_source=news#reviews: choosing variant 102 changed the address to /product?utm_source=news&variant=102#reviews without a reload. After choosing 103, Back showed 102 in both the URL and the select, a second Back returned to the original URL with the first variant selected, and Forward went to 102 again. Neither pushState() nor replaceState() fired popstate on its own; only Back and Forward did.
If other parts of the page depend on the variant, such as the price or the image, update them in both handlers. Reloading a section of the page shows how to fetch the new price.
pushState or replaceState?
history.pushState()adds an entry: in the test,history.lengthwent up by one. Back then returns to the previous URL on the same page. That suits changes a visitor thinks of as a new page, such as tabs, filters or pages of search results.history.replaceState()rewrites the current entry, andhistory.lengthstayed the same. Back leaves the page, however many variants the customer tried. If that’s what you want on a product page, usereplaceState()in thechangehandler. It is also how you stop a refresh from resubmitting a form.
The jQuery version
The original version of this example uses jQuery and runs once, when the page loads:
jQuery(function ($) {
var variant = $('select.product-single__variants :selected').val();
console.log( variant );
var loc = window.location;
var origin = location.origin;
var pathname = location.pathname;
var finalUrl = origin + pathname + "?variant=" + variant;
//updating URL
history.pushState({}, null, finalUrl);
});jQuery(function ($) { … })runs the code once the page is ready and passes jQuery in as$, so$works even in WordPress, where jQuery runs in no-conflict mode. ThejQuery(document).on('ready', …)form seen in older examples was removed in jQuery 3.0 and only runs while the jQuery Migrate plugin is loaded.$('select.product-single__variants :selected').val()gets the value of the selected option, which is also logged to the console.location.origin + location.pathnamerebuilds the current URL without its query string or hash, then?variant=and the value are added. In the test,/product?utm_source=news#reviewsbecame/product?variant=101. The value isn’t encoded either, which doesn’t matter for Shopify’s numeric variant IDs but breaks values that contain&or spaces.var locisn’t used by the rest of the code.
Because this runs on page load, pushState() adds a second history entry for the same page: in the test, the first press of Back stayed on the page and only brought back the original URL. For a change you make as the page loads, replaceState() is the better fit.
To follow the customer’s choice, run the same code in a change handler on the select:
jQuery(function ($) {
$('select.product-single__variants').on('change', function () {
var variant = $(this).val();
var finalUrl = location.origin + location.pathname + "?variant=" + variant;
//updating URL
history.pushState({}, null, finalUrl);
});
});In the test, choosing variant 102 changed the address to /product?variant=102. Add the popstate handler from the first example if Back should switch the select back as well.
When is jQuery still the practical choice?
pushState(), replaceState() and popstate work the same with or without jQuery; jQuery only finds the select and binds the event. On a WordPress site whose theme or plugins already load jQuery, writing the handler in jQuery costs nothing extra. On a WooCommerce variable product page it’s always there, because WooCommerce’s variation script, add-to-cart-variation.js, depends on it. Anywhere else, the plain JavaScript version does the same job without loading a library.
Things to check
- The server must understand the new URL. Changing it requests nothing, but a reload, a bookmark or a shared link does. Shopify selects the variant from
?variant=. In WooCommerce, the variation dropdowns preselect a value from anattribute_parameter, such as?attribute_pa_color=bluefor a global Color attribute;wc_dropdown_variation_attribute_options()reads it from the request in WooCommerce 11.1. - The URL must stay on the same origin.
pushState()to another domain throws an error. In Chrome it wasSecurityError: Failed to execute 'pushState' on 'History'. - Relative URLs work.
history.pushState({}, '', '?variant=102')keeps the current path, because the URL is resolved against the current one. It replaces the whole query string, though, and drops the hash.
Related articles
- reloading the price section for the new variant: the next step of the same example, with jQuery .load() or fetch()
- updating a WooCommerce product title on variation change: a similar change handler for WooCommerce
