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
localeoption on search parameters, for multi-locale index configurations - A
facetoption to request facet counts and filter by facet values - A
facetsarray 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:
nameis 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 supportgt,lt,geandle. - Passing an array of values means OR.
['Security', 'Headless']matches either one. Arrays only work witheq.
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: truekeeps 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 withlegends, the results area hasaria-busy, and you should announce the result count to screen readers when it changes.
Resources
- Content SDK 2.3 changelog: https://developers.sitecore.com/changelog/sitecoreai/19082026/content-sdk-2.3-released-with-new-search-capabilities
- Content SDK search package source: https://github.com/Sitecore/content-sdk/tree/main/packages/search
- Content SDK 2.0 (search introduced): https://developers.sitecore.com/changelog/sitecoreai/19032026/content-sdk-v2.0-released—next.js-16%2c-agent-skills%2c-events%2c-tracking%2c-personalization%2c-and-more



