· ·

Faceted Search with Content SDK 2.3

Alex Lentz

Title card: Faceted Search with Content SDK 2.3. Content SDK post on Sitecore Saga.

Search landed in Content SDK back in 2.0 with the @sitecore-content-sdk/search package and the useSearch / useInfiniteSearch hooks. It was good for a search box and a results list, but every real search page I’ve built has needed facets.

As of Content SDK 2.3 (released August 19th), facets are in, along with multi-locale search. Let’s build a proper faceted search page.

What 2.3 added

The search package (@sitecore-content-sdk/search 0.4.0, shipped with Content SDK 2.3) added:

  • A locale option on search parameters, for multi-locale index configurations
  • A facet option to request facet counts and filter by facet values
  • A facets array on the response
  • New types: FacetRequest, FacetField, FacetFilter, FacetFilterOperator, FacetValue, FacetResult

And the React hooks pass all of that through, so useSearch now gives you facets alongside results.

The facet request, explained

There are two ways to ask for facets, and you can combine them:

facet: {
  // Return value counts for every facet enabled in the index config
  all: true,
  // Narrow the results by specific facet values
  fields: [
    { name: 'Topic', filters: [{ operator: 'eq', value: ['Security', 'Headless'] }] },
    { name: 'Year', filters: [{ operator: 'ge', value: 2025 }] },
  ],
}

A few rules worth knowing up front:

  • name is the facet’s display name as configured in the search index, not the raw field name. This one is easy to get wrong.
  • String, tag and boolean facets only support eq. Numeric and date facets also support gt, lt, ge and le.
  • Passing an array of values means OR. ['Security', 'Headless'] matches either one. Arrays only work with eq.

The component

Here’s a complete faceted search component – a search box, checkbox facets with counts, results and paging:

'use client';
// src/components/search/FacetedSearch.tsx
import { useState } from 'react';
import { useSearch } from '@sitecore-content-sdk/nextjs/search';
import type { FacetField } from '@sitecore-content-sdk/search';

type Article = {
  id: string;
  name: string;
  url: string;
  description?: string;
};

const INDEX_ID = process.env.NEXT_PUBLIC_SEARCH_INDEX_ID ?? '';

export default function FacetedSearch() {
  const [query, setQuery] = useState('');
  const [page, setPage] = useState(1);
  // facet display name -> selected values, e.g. { "Topic": ["Security"] }
  const [selected, setSelected] = useState<Record<string, string[]>>({});

  const fields: FacetField[] = Object.entries(selected)
    .filter(([, values]) => values.length > 0)
    .map(([name, values]) => ({ name, filters: [{ operator: 'eq', value: values }] }));

  const { results, facets, total, totalPages, isLoading, isError } = useSearch<Article>({
    searchIndexId: INDEX_ID,
    query,
    page,
    pageSize: 10,
    keepPreviousData: true,
    facet: { all: true, fields },
  });

  const toggle = (facet: string, value: string) => {
    setPage(1);
    setSelected((prev) => {
      const current = prev[facet] ?? [];
      const next = current.includes(value) ? current.filter((v) => v !== value) : [...current, value];
      return { ...prev, [facet]: next };
    });
  };

  return (
    <div className="search">
      <input
        type="search"
        value={query}
        placeholder="Search articles"
        onChange={(e) => {
          setQuery(e.target.value);
          setPage(1);
        }}
      />

      <div className="search__layout">
        <aside className="search__facets">
          {facets?.map((facet) => (
            <fieldset key={facet.name}>
              <legend>{facet.name}</legend>
              {facet.value.map((v) => {
                const value = String(v.text);
                return (
                  <label key={value}>
                    <input
                      type="checkbox"
                      checked={selected[facet.name]?.includes(value) ?? false}
                      onChange={() => toggle(facet.name, value)}
                    />
                    {value} ({v.count})
                  </label>
                );
              })}
            </fieldset>
          ))}
        </aside>

        <section aria-busy={isLoading}>
          {isError && <p>Search is having a moment. Try again in a bit.</p>}
          <p>{total} results</p>
          <ul>
            {results.map((item) => (
              <li key={item.id}>
                <a href={item.url}>{item.name}</a>
                {item.description && <p>{item.description}</p>}
              </li>
            ))}
          </ul>
          <button disabled={page <= 1} onClick={() => setPage((p) => p - 1)}>
            Previous
          </button>
          <button disabled={page >= totalPages} onClick={() => setPage((p) => p + 1)}>
            Next
          </button>
        </section>
      </div>
    </div>
  );
}

A few things in there worth pointing out:

  • facet: { all: true, fields } gets the counts for every facet and applies the current selections in one request.
  • keepPreviousData: true keeps the old results on screen while new ones load. Without it the list flashes empty on every checkbox click.
  • Every filter or query change resets to page 1. Forget that and people end up on page 4 of a result set that now only has 2 pages.
  • Facet values can be strings, numbers or booleans, so I normalize them with String(v.text) for keys and labels.

The hook gets its Edge context from SitecoreProvider, so it needs to render inside your normal Content SDK app tree. Put the index ID wherever your team keeps that kind of config – I used an env var here to keep the example short.

Multi-locale indexes

If your index is configured for multiple locales, pass the page’s locale through:

const { page } = useSitecore();

useSearch<Article>({
  searchIndexId: INDEX_ID,
  query,
  locale: page.locale, // e.g. 'en', 'fr-FR'
  facet: { all: true, fields },
});

Leave locale out for single-locale indexes. And note the format – letters and hyphens only.

Things I’d add before shipping

  • Put the state in the URL. Query, page and selected facets in the query string means shareable, bookmarkable searches and a working back button.
  • Debounce the input. The hook cancels in-flight requests, but you still don’t need a request on every keystroke.
  • Don’t render empty facets. If a facet comes back with no values for the current query, hide it.
  • Accessibility. Facets are fieldsets with legends, the results area has aria-busy, and you should announce the result count to screen readers when it changes.

Resources

Alex Lentz

Alex Lentz

Senior Solutions Architect at Americaneagle.com and two-time Sitecore Technology MVP. Building on Sitecore since 2013, mostly SitecoreAI and headless Next.js these days.

LinkedIn    GitHub

Keep reading