Skip to content
Client-Side Routing with URL and History APIs
June 2, 2026· 10 min read
URLRoutingSPA

Client-Side Routing with URL and History APIs

Master the URL API, URLSearchParams query strings, and build a small pushState SPA router with back-button support.

Shareable links, working back buttons, and bookmarkable filters all come from the same foundation: correct URL handling and a small client-side router.

This guide builds that foundation step by step, so framework routers feel like conveniences rather than magic.

How to resolve URLs safely

Manual string concatenation creates double slashes, broken relative paths, and encoding bugs. The URL constructor resolves ../, leading slashes, and query strings correctly.

javascript
function toAbsolute(path, baseUrl = location.origin) {
  try {
    return new URL(path, baseUrl).href;
  } catch {
    return null;
  }
}

toAbsolute("../cart", "https://shop.example.com/catalog/");
toAbsolute("/search?q=shoes", "https://shop.example.com/catalog/");

Invalid input returns null instead of throwing through the UI. Callers can show a fallback link or an error state.

How to read and update query parameters

URLSearchParams manages encoding and duplicate keys. Remember the distinction: set replaces a parameter while append allows repeats like ?tag=a&tag=b.

javascript
function readFilters(urlString) {
  const url = new URL(urlString);
  return [...url.searchParams.entries()].map(([key, value]) => ({ key, value }));
}

function updateQuery(urlString, patch) {
  const url = new URL(urlString);
  for (const [key, value] of Object.entries(patch)) {
    if (value == null || value === "") url.searchParams.delete(key);
    else url.searchParams.set(key, String(value));
  }
  return url.toString();
}

function withoutParam(urlString, name) {
  const url = new URL(urlString);
  url.searchParams.delete(name);
  return url.toString();
}

What each helper does

  1. readFilters preserves duplicates as key-value pairs for filters.
  2. updateQuery adds, replaces, or removes parameters from a patch object.
  3. withoutParam removes one parameter while keeping everything else intact.

How a minimal SPA router works

Client-side routing has three jobs: intercept in-app link clicks, push the new URL with history state, and render on back or forward navigation.

javascript
const routes = {
  "/": renderHome,
  "/products": renderProducts,
  "/cart": renderCart,
};

function navigate(path) {
  history.pushState({}, "", path);
  render(path);
}

function render(path = location.pathname) {
  const view = routes[path] || renderNotFound;
  const app = document.getElementById("app");
  app.innerHTML = "";
  view(app);
}

document.addEventListener("click", (e) => {
  const anchor = e.target.closest("a[data-link]");
  if (!anchor) return;
  const url = new URL(anchor.href, location.origin);
  if (url.origin !== location.origin) return;
  e.preventDefault();
  navigate(url.pathname + url.search);
});

window.addEventListener("popstate", () => render());
render();

How navigation flows

  1. Clicks on data-link anchors are intercepted for same-origin URLs.
  2. navigate pushes the URL and calls the single render entry point.
  3. popstate handles browser back and forward with the same renderer.

Why URL state matters for users

Filters, pagination, and search belong in the query string. Users can refresh, bookmark, and share exactly what they see instead of losing state.

  • Listings: page, sort, and filter values encoded in the URL.
  • Search: debounced input reflected into shareable query params.
  • Checkout: steps that survive refresh without hidden component state.

Best practices for routing code

  • Route every entry point through one render function.
  • Only intercept same-origin data-link anchors; leave downloads and new tabs alone.
  • Keep route state serializable so refresh and sharing always work.
  • Handle unknown paths with a real not-found view, not a blank page.

Common mistakes to avoid

  • Building query strings by hand and forgetting encoding.
  • Using append where set was intended, creating duplicate parameters.
  • Hijacking external links and breaking expected browser behavior.

Frequently asked questions

Do I still need a framework router?

For nested layouts, code-splitting, scroll restoration, and data loading, yes. This minimal version teaches the model those libraries implement.

Where should filter state live?

In the URL when users benefit from sharing or refresh persistence. Keep purely visual state like hover or focus in components.

Key takeaways

  • Parse with URL, mutate with URLSearchParams, navigate with pushState.
  • Store shareable state in the URL and visual state in components.
  • One render entry point keeps navigation predictable.