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) {
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:
- 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.