Applies to colibrity.filter-url-state, colibrity.filter-history-noise, colibrity.filter-indexability.

Covers colibrity.filter-url-state, colibrity.filter-history-noise, colibrity.filter-indexability.

Why

A filtered listing is a view of the site. If that view has no address, it cannot be shared, linked, bookmarked, reopened, or reached from search. Three things break at once when filter state lives only in a JavaScript variable:

The pattern

One named list of parameters. Read on load, write on change, delete when cleared.

const FILTER_NAMES = ["design_type", "country", "building_type"];

// On load: restore the UI and the results from the address bar.
function initFiltersFromURL(section) {
  const params = new URLSearchParams(window.location.search);
  let restored = false;

  FILTER_NAMES.forEach((name) => {
    const value = params.get(name);
    if (!value) return;
    const control = section.querySelector(`[data-filter-name="${name}"]`);
    if (!control) return;
    applyValueToControl(control, value);
    restored = true;
  });

  if (restored) {
    section.dispatchEvent(
      new CustomEvent("filter-change", { bubbles: true, detail: getActiveFilters(section) })
    );
  }
}

// On change: mirror state into the address bar without touching the back stack.
function syncFiltersToURL(filters) {
  const url = new URL(window.location);
  FILTER_NAMES.forEach((name) => {
    if (filters[name]) url.searchParams.set(name, filters[name]);
    else url.searchParams.delete(name);
  });
  history.replaceState(null, "", url);
}

The decisions inside it

replaceState, not pushState. A filter is a refinement of the current view, not a new page. With pushState, five filter clicks cost the visitor five presses of Back to leave the listing, which reads as a broken back button. Pagination is different and does deserve a history entry.

Delete, do not blank. Clearing a filter must remove the parameter. ?country= is not the same URL as no parameter, and it will be crawled, cached and canonicalised as a separate page.

Restore before the first paint of results. If the results render unfiltered and then filter, the visitor sees a flash of the wrong content and the layout shifts. Read the URL first.

One event, one direction. The filter UI owns the URL and emits an event; the results block listens. The results block never reads the URL itself. Two readers of the same state is how the UI and the results end up disagreeing.

Do not delay the event to fix an ordering problem. If the results block has not registered its listener by the time the filter UI fires, a queueMicrotask or a setTimeout around the dispatch will appear to fix it and will keep working right up until it does not. Initialise the results block before the filter UI, or have it read its own initial state directly. A delay is not an ordering guarantee.

The server-rendered case

When the server renders the filtered result, most of the above disappears. The controls arrive with their values already selected, so there is nothing to restore and no flash to avoid: the first paint IS the restored state, and "restore before the first paint of results" costs no code at all. A plain <form method="get"> gets you shareable URLs, working pagination and a listing that filters with JavaScript switched off.

Two things still need doing, and neither is optional.

A native GET form cannot clear a parameter. It serialises every control it owns, so an emptied select submits ?colour= rather than omitting it. Rebuild the URL with the empty parameters deleted before navigating - and because that only helps visitors running your JavaScript, the server has to redirect the empty form away as well. Otherwise the no-JavaScript path generates exactly the URLs the "delete, do not blank" rule exists to prevent.

replaceState cannot keep the back button clean here. The filter change is a real navigation, and the navigation pushes its own history entry after any state you write. location.replace() is what navigates without one.

Indexability, which is a separate decision

Writing filters to the URL creates URLs. Someone has to decide which of them should exist as far as a search engine is concerned, and that decision gets written into the project handover.

Filter kind Decision
Matches real search demand, for example a category or a city Indexable, canonical to itself, listed in the sitemap
Sort order Canonical to the unfiltered listing
Free-text search noindex
Two or more filters combined Canonical to the unfiltered listing, unless a specific combination is a known landing page

The failure mode is a crawl trap: multi-select filters generate combinations faster than any crawler can exhaust them, and the budget that should have gone to product pages goes to ?colour=red&size=m&sort=price-desc instead.

How it is checked

Automated, in the browser:

  1. Change a filter. Assert location.search changed.
  2. Reload that URL. Assert the same result set and the same visible filter UI.
  3. Clear the filter. Assert the parameter is gone, not empty.
  4. Change filters five times, press Back once. Assert you leave the listing.
  5. Open the filtered URL in a clean context. Assert it reproduces the view.