Filter state belongs in the URL

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) {
    // Let the results block register its listener before we fire.
    queueMicrotask(() => {
      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.

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.