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 visitor loses their place. They filter, open a result, press Back, and land on the unfiltered listing. On a long list this is the difference between browsing and giving up.
- Nobody can send anyone a link. "Look at the ones in Paris" becomes a set of instructions instead of a URL.
- Search engines see one page where the client thinks they have thirty.
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:
- Change a filter. Assert
location.searchchanged. - Reload that URL. Assert the same result set and the same visible filter UI.
- Clear the filter. Assert the parameter is gone, not empty.
- Change filters five times, press Back once. Assert you leave the listing.
- Open the filtered URL in a clean context. Assert it reproduces the view.