Guide 11 · Developer docs

Search, filtering and faceted navigation

Site search on Core dna is an Elasticsearch index that the platform fills and queries, and a page you build. What reaches the index, search views, the search page, product facets, filtered listings that need no index, and when to go beyond the built-in index.

L2
Unit 03 · Faceted navigation for product finders

A filter interface that keeps its state in the URL

Why facet selections belong in the URL, a filter form in both engines that works with no script at all, and a script that updates the results without a reload while keeping the address bar, the back button and keyboard focus right.

Who this is for. The theme developer building a product finder's filter panel, and the reviewer testing it in acceptance. Afterwards you can render a Faceted Search view's facets as an accessible form whose every state is a shareable URL, and enhance it so that results update as boxes are ticked.

Facet state belongs in the URL

This is a requirement, not a preference. Every selection must be in the address bar, so that:

  • a filtered result can be bookmarked, shared and opened in a new tab;
  • the back button undoes the last filter instead of leaving the page;
  • a reload keeps the selection, and the page works with scripts off;
  • analytics can see what visitors filter by.

An interface that keeps its state only in JavaScript fails all four, and is routinely rejected in user acceptance testing. On Core dna the URL form is also the natural one, because a view reads its filters from the query string. Each facet's parameter is fields.<identifier>, which $facet->getFilter() returns. Several values for one facet are given by repeating the parameter with [] or by separating them with commas, and mean any of them; different facets must all match:

HTTP
GET /search/products?q=boots&fields.brand[]=acme&fields.brand[]=northwind&fields.price-band[]=under-50

That URL finds boots by Acme or Northwind, under $50. Because a comma separates values, a facet value that contains a comma, such as Black, matte, can never be selected: keep commas out of facet values in the mapping template.

The filter form

An ordinary GET form, one fieldset per facet and one checkbox per option, works without any script and produces exactly the URL above:

Template code search/search.html
<p id="search-status" role="status">{$response->getTotalCount()} products</p>
<div id="search-app">
  <form class="search-filters" method="get" action="/search/{$view->getSlug()|escape:'url'}">
    {if $hasQuery}<input type="hidden" name="q" value="{$query|escape}">{/if}
    {foreach $response->getFacets() as $facet}
      <fieldset{if $facet->isDisabled()} disabled{/if}>
        <legend>{$facet->getName()|escape}</legend>
        {$facetHasSelection = false}
        {foreach $facet->getValues() as $option}{if $option->isSelected()}{$facetHasSelection = true}{/if}{/foreach}
        {foreach $facet->getValues() as $option}
          <label>
            <input type="checkbox" name="{$facet->getFilter()|escape}[]" value="{$option->getValue()|escape}"{if $option->isSelected()} checked{/if}{if !$facetHasSelection && !$option->isAvailable()} disabled{/if}>
            {$option->getLabel()|escape}{if $facet->getUnit()} {$facet->getUnit()|escape}{/if}
            {if !$facetHasSelection || $option->isSelected()}<span class="facet-count">({$option->getCount()})</span>{/if}
          </label>
        {/foreach}
      </fieldset>
    {/foreach}
    <button type="submit">Apply filters</button>
    <a href="/search/{$view->getSlug()|escape:'url'}{if $hasQuery}?q={$query|escape:'url'}{/if}">Clear all filters</a>
  </form>
  {* the result list and the pagination from the search page go here *}
</div>
<p id="search-status" role="status">{{ response.getTotalCount() }} products</p>
<div id="search-app">
  <form class="search-filters" method="get" action="/search/{{ view.getSlug()|e('url') }}">
    {% if hasQuery %}<input type="hidden" name="q" value="{{ query|e }}">{% endif %}
    {% for facet in response.getFacets() %}
      <fieldset{{ facet.isDisabled() ? ' disabled' : '' }}>
        <legend>{{ facet.getName()|e }}</legend>
        {% set facetHasSelection = false %}
        {% for option in facet.getValues() %}{% if option.isSelected() %}{% set facetHasSelection = true %}{% endif %}{% endfor %}
        {% for option in facet.getValues() %}
          <label>
            <input type="checkbox" name="{{ facet.getFilter()|e }}[]" value="{{ option.getValue()|e }}"{{ option.isSelected() ? ' checked' : '' }}{{ not facetHasSelection and not option.isAvailable() ? ' disabled' : '' }}>
            {{ option.getLabel()|e }}{{ facet.getUnit() ? ' ' ~ facet.getUnit()|e : '' }}
            {% if not facetHasSelection or option.isSelected() %}<span class="facet-count">({{ option.getCount() }})</span>{% endif %}
          </label>
        {% endfor %}
      </fieldset>
    {% endfor %}
    <button type="submit">Apply filters</button>
    <a href="/search/{{ view.getSlug()|e('url') }}{{ hasQuery ? '?q=' ~ query|e('url') : '' }}">Clear all filters</a>
  </form>
  {# the result list and the pagination from the search page go here #}
</div>

$query and $hasQuery are set as on the minimal search page, which also prevents caching. Each option offers getValue() (what goes in the URL), getLabel() (its predefined label, or the value), getCount(), isSelected(), isAvailable() (whether any current result has it) and isPredefined(). On a facet whose Case Sensitive is off, values come back in lower case without accents, so give them labels through predefined values if they are shown as they are.

For accessibility: a fieldset and legend name each group, and real checkboxes give keyboard and screen-reader behaviour for free. The count sits inside the label, so it is read with the option. The result count is in an element with role="status", outside the region a script replaces, so the new count is announced after each change. In a facet with nothing selected, an option with no results is disabled; once anything in a facet is selected, every option in it stays enabled and only the selected ones show a count, for the reason given in How counts follow a selection.

A filter interface that survives navigation

The form reloads the page on Apply. To update as each box is ticked, without losing anything the URL gives you, enhance it with a script that does three things: write the new state to the URL with history.pushState, fetch that URL and swap in the new results and facets, and do the same when the back button fires popstate. It fetches the HTML page rather than JSON, so the template stays the only place results and facets are rendered. Put it in the site's bundle; an inline script needs the CSP nonce (Assets, CSP and the build pipeline).

JavaScript src/js/search-filters.js
const app = document.getElementById('search-app');
const status = document.getElementById('search-status');

async function show(url, push, focus) {
  app.setAttribute('aria-busy', 'true');
  const res = await fetch(url, { credentials: 'same-origin' });
  const doc = new DOMParser().parseFromString(await res.text(), 'text/html');
  const next = doc.getElementById('search-app');
  if (!res.ok || !next) { location.assign(url); return; }
  app.innerHTML = next.innerHTML;
  app.removeAttribute('aria-busy');
  status.textContent = doc.getElementById('search-status')?.textContent ?? '';
  if (push) history.pushState({ search: true }, '', url);
  if (focus) {
    app.querySelector(`input[name="${CSS.escape(focus.name)}"][value="${CSS.escape(focus.value)}"]`)?.focus();
  }
}

if (app) {
  app.addEventListener('change', event => {
    const input = event.target;
    if (!input.form || !input.form.matches('.search-filters')) return;
    const url = new URL(input.form.action, location.href);
    url.search = new URLSearchParams(new FormData(input.form)).toString();
    show(url.href, true, { name: input.name, value: input.value });
  });
  window.addEventListener('popstate', () => show(location.href, false));
}
  • Restoring state on return needs no code: the server renders the selection from the URL, so a reload, a shared link and the back button all arrive at the same page.
  • Focus is put back on the checkbox that changed, found by its name and value, because replacing the markup would otherwise drop it to the top of the page.
  • Failure falls back to an ordinary page load, so a 404 for a page past the end, or an error, never leaves the visitor stuck.
  • Keep the Apply button. It is what works with scripts off.

What a text query does to facets, how counts follow a selection, and what to show when a selection excludes every result are on Facets with a text query.

Why facet counts get slow on a large catalogue, and the two ways to keep them fast, are on Facet counts at scale.

Go deeper

Was this page helpful?

Your answer goes to the docs team.