JavaScript URL, URLSearchParams and the History API: Managing the Address Without a Reload
We take addresses apart with URL and URLSearchParams, look at the hash, compare pushState and replaceState, learn when popstate fires, and write a small framework-free SPA router.
Version note: The code in this post targets ES2026 and current browsers (Chrome, Edge, Firefox, Safari).
Predict First
Open the console on any page. First we add a listener for history navigation, then we change the URL in the address bar from code:
window.addEventListener("popstate", () => console.log("got popstate"));
history.pushState({ page: 2 }, "", "?page=2");
console.log(location.search);
What does the console print, what changes in the address bar, and does the page reload? Make a guess before you read on. Then press the browser's Back button and watch the console. We'll go over the result of both steps together once we've covered the History API.
The URL Object: Taking an Address Apart
A web address is like the postal address on an envelope. Country, city, street, house number and an "attention: Ayşe" note. Trying to pull those apart with string operations, split and indexOf, is both tedious and error-prone. The browser gives you a ready-made tool for the job: URL. Paste this into the console and try it:
const url = new URL("https://shop.example.com:8080/products/shoes?color=red&size=42#reviews");
url.protocol; // "https:"
url.hostname; // "shop.example.com"
url.port; // "8080"
url.host; // "shop.example.com:8080"
url.origin; // "https://shop.example.com:8080"
url.pathname; // "/products/shoes"
url.search; // "?color=red&size=42"
url.hash; // "#reviews"
These properties are writable too. Set url.pathname = "/cart" and url.href updates by itself. Give it a base address as the second argument and it resolves relative addresses as well: new URL("../api", "https://example.com/docs/page/") gives you https://example.com/docs/api. The location object we saw earlier has the same properties; it's simply the address of the page that's open right now.
Pass an invalid address and new URL throws a TypeError. When checking text that came from the user, instead of wrapping it in try/catch you can use two static methods: URL.canParse(text) returns true or false, and URL.parse(text) gives you a URL object if it's valid and null if it isn't. URL.parse has been in all current browsers since 2024.
URLSearchParams: Making Peace with the Query String
The part of the address after the ?, the query string, carries things like filters, search terms and page numbers. URLSearchParams lets you read and write that part like a dictionary:
const params = new URLSearchParams("q=coffee mug&tag=porcelain&tag=gift");
params.get("q"); // "coffee mug"
params.get("missing"); // null
params.getAll("tag"); // ["porcelain", "gift"]
params.has("q"); // true
params.size; // 3
params.set("page", "2"); // replaces if present, adds if not
params.append("tag", "sale");
params.delete("q");
params.toString(); // "tag=porcelain&tag=gift&page=2&tag=sale"
for (const [key, value] of params) {
console.log(key, value);
}
The real comfort is in the encoding. new URLSearchParams({ q: "tea & café" }).toString() gives you q=tea+%26+caf%C3%A9; it encodes accented characters, spaces and the & sign for you. That's exactly the most common mistake when you build a URL by gluing strings together: a search term containing & breaks the rest of the query.
Every URL object has a live searchParams property. Change it and the URL itself changes. To read the current page's parameters, new URLSearchParams(location.search) is all you need:
const link = new URL("https://example.com/products?page=1");
link.searchParams.set("page", "2");
link.searchParams.append("sort", "price");
console.log(link.href); // "https://example.com/products?page=2&sort=price"
const current = new URLSearchParams(location.search);
const page = Number(current.get("page") ?? 1);
Notice the Number on the last line. Every value that comes from the URL is a string; convert it to a number yourself so you don't fall into the "2" + 1 trap one more time.
Hash: The Address Inside the Page
The part after the #, the hash or fragment, is never sent to the server. The browser uses it to mark a spot inside the page: say #reviews and it scrolls to the element with id="reviews". You can read and write it from JavaScript with location.hash, and when the hash changes, a hashchange event fires on window.
window.addEventListener("hashchange", () => {
console.log("New hash:", location.hash);
});
location.hash = "settings"; // no reload, a new history entry is added
Before the History API caught on, single-page applications (SPAs) did all their routing with the hash: things like #/products/42. It still works, because it needs no setup on the server. But the addresses look ugly, and it clashes with the hash's real job of in-page linking. Today the History API does this job.
The History API: pushState and replaceState
Think of the browser history as a stack of cards. Each card has an address on it, and the Back button removes the top card and returns to the one below. The history.back() we saw earlier was a way of moving through that stack. history.pushState puts a new card on top of the stack, and history.replaceState rewrites what's on the top card. Neither sends a request to the server or reloads the page; they only change the address bar and the history.
// history.pushState(state, unused, url)
history.pushState({ view: "products" }, "", "?view=products");
history.state; // { view: "products" }
// While typing a filter, update the current entry instead of adding one per keystroke
history.replaceState({ view: "products", q: "mug" }, "", "?view=products&q=mug");
The first argument is the state object: a small note you attach to that history entry. The browser copies it with the same algorithm as the structuredClone we saw earlier, so you can't put functions or DOM elements in it. The second argument is there for historical reasons and browsers ignore it; passing an empty string is the convention. The third is the new address. It has to be on the same origin as the current page; give it a URL that belongs to another site and you get a SecurityError. In other words, no page can pass itself off as another site in the address bar.
Which one when? Anything the user feels as "I went to a new page" is pushState, like going from the product list to a product's details. Fine-tuning the same page is replaceState: every letter typed into a search box, a slider being dragged. Otherwise, when the user presses Back, they have to delete the word they typed one letter at a time.
popstate: Hearing the Back Button
Back to the opening experiment. After pushState you only saw ?page=2 in the console; the address changed, the page didn't reload, and "got popstate" never appeared. This is a very important rule: pushState and replaceState don't fire the popstate event. When you pressed Back, though, "got popstate" showed up.
popstate arrives when the user or the code moves through history: the Back and Forward buttons, history.back(), history.forward(), history.go(n). When the event fires, location already shows the new address, and event.state gives you the note you attached to that entry. If you return to the page's first entry, event.state is null, since you never gave it any state.
window.addEventListener("popstate", (event) => {
console.log("Address:", location.search, "State:", event.state);
});
history.pushState({ page: 1 }, "", "?page=1");
history.pushState({ page: 2 }, "", "?page=2");
history.back(); // Address: ?page=1 State: { page: 1 }
history.back(); // Address: (first address) State: null
The mental model that falls out of this: wherever you call pushState, you update the screen yourself, because you made the change. When popstate arrives, the user made the change, and you need to redraw the screen based on the new address. If both call the same render function, the logic stays in one place.
A Small Router Without a Framework
Now let's put it all together and build a simple SPA router. We catch link clicks with the event delegation we saw earlier, prevent the page reload, change the address with pushState and draw the content by looking at the address. The example uses the query string rather than the path, so it also works when you open the file straight from disk:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Mini router</title>
</head>
<body>
<nav>
<a href="?view=home">Home</a>
<a href="?view=products">Products</a>
<a href="?view=about">About</a>
</nav>
<main id="app"></main>
<script>
const app = document.querySelector("#app");
const products = { 1: "Coffee mug", 2: "Teapot", 3: "Tea glass" };
function render() {
const params = new URLSearchParams(location.search);
const view = params.get("view") ?? "home";
const title = document.createElement("h2");
const body = document.createElement("p");
if (view === "home") {
title.textContent = "Home";
body.textContent = "Welcome. Pick a page from the menu.";
} else if (view === "products") {
title.textContent = "Products";
for (const [id, name] of Object.entries(products)) {
const link = document.createElement("a");
link.href = `?view=product&id=${id}`;
link.textContent = name;
body.append(link, " ");
}
} else if (view === "product") {
const name = products[params.get("id")];
title.textContent = name ?? "Product not found";
body.textContent = name ? `Product number: ${params.get("id")}` : "Go back to the list.";
} else if (view === "about") {
title.textContent = "About";
body.textContent = "This page runs on the History API.";
} else {
title.textContent = "Page not found";
}
app.replaceChildren(title, body);
document.title = title.textContent;
}
document.addEventListener("click", (event) => {
const link = event.target.closest("a");
if (!link || link.origin !== location.origin) return;
if (event.ctrlKey || event.metaKey || event.shiftKey) return; // let "open in new tab" work
event.preventDefault();
if (link.href !== location.href) {
history.pushState(null, "", link.href);
}
render();
});
window.addEventListener("popstate", render);
render();
</script>
</body>
</html>
Open the file, move around the menu, go into a product, then use the Back and Forward buttons. The page never reloaded once, yet the address is right at every step and the history works. Copy a product's address and open it in a new tab and you land directly on that product, because render reads everything from the address. Writing the product name and number with textContent matters too: the address is in the user's hands and you can't know what they'll put in it; with innerHTML we'd be opening a door to XSS through the URL. Leaving Ctrl- or Cmd-clicks alone is about not breaking the user's "open in new tab" habit.
Path-Based Addresses in Real Projects
In real applications, addresses are usually clean paths like /products/3. The logic is the same; instead of params.get("view"), you split up location.pathname. But here a requirement outside the browser kicks in: when the user reloads the page at /products/3, the browser really does request that path from the server. If there's no such file on the server, you get a 404. That's why servers hosting SPAs are configured to return the same HTML file for any path they don't recognize; once the page has loaded, JavaScript takes over the routing. In a file opened from disk, trying to pushState to a different path throws an error, while changing the query string and the hash is fine. That's why our example uses the query string.
Two more small courtesies. Update document.title on every navigation, because that's what shows in the history list and on the tab. And for people using screen readers, consider moving focus to the new heading when the content changes; you're now doing things the browser used to do for you on a full page load.
Common Pitfalls
Expecting popstate after pushState. It won't come. Update the screen yourself right after pushState.
pushState on every keystroke. The history fills up with hundreds of entries and the Back button becomes useless. Use replaceState for small changes on the same page.
Building the URL by gluing strings. When you write "?q=" + term, a search term containing & or # breaks the address. URLSearchParams or url.searchParams do this correctly for you.
Putting everything into state. State should be a small, cloneable note. Put a function in it and you get a DataCloneError; put large data in it and you bloat the history. Let the address always be the source of truth, with state carrying only helper information.
A Small Experiment
★☆☆ In the console, create a URL with new URL("https://example.com/search?q=js"). Through searchParams, set q to "tea & café", add page=3 and print the final href.
★★☆ Predict the output, then try it: on a page, after adding a popstate listener, run history.pushState(null, "", "?a"), history.replaceState(null, "", "?b") and history.back() in that order. How many times does the listener run, and what address do you end up at?
★★★ Add a search box to the products page in the router example. Write what's typed into the address as ?view=products&q=... with replaceState and filter the list. When you reload the page, the search box and the list should come back exactly as they were.
As our router grows, it stops fitting into a single script tag: the views want to live in one file, the product data in another, the helper functions in a third. In the next post we look at the browser's built-in module system: import, export, dynamic import(), import maps and pulling JSON files straight in.