Faceted search
Multi-select filter sidebar (brand, category, price range) driven by AACsearch facet counts. Filters AND-combined; counts updated reactively.
A faceted search interface lets users narrow results by clicking checkboxes, sliders, and chips. AACsearch returns counts per facet value alongside results — you bind those to the UI.
What you build
A search page with:
- A sidebar of brand checkboxes, category checkboxes, and a price range slider.
- A result grid that updates as filters toggle.
- Facet counts that reflect "how many results would I see if I added this filter."
- URL state so filters are shareable and survive page reload.
Index schema requirements
For a field to be facetable, mark it facet: true in the index schema:
await admin.createIndex({
slug: "products",
displayName: "Products",
fields: [
{ name: "title", type: "string" },
{ name: "brand", type: "string", facet: true },
{ name: "categories", type: "string[]", facet: true },
{ name: "price", type: "float", facet: true },
{ name: "in_stock", type: "bool", facet: true },
],
defaultSortingField: "price",
});Server-side: build a search query from URL params
// app/search/page.tsx — Server Component
import { SearchClient } from "@aacsearch/client";
const client = new SearchClient({
baseUrl: process.env.NEXT_PUBLIC_AACSEARCH_BASE_URL!,
apiKey: process.env.NEXT_PUBLIC_AACSEARCH_SEARCH_KEY!,
indexSlug: "products",
});
type SearchParams = {
q?: string;
brand?: string | string[];
category?: string | string[];
priceMin?: string;
priceMax?: string;
page?: string;
};
function buildFilter(params: SearchParams): string {
const clauses: string[] = ["in_stock:=true"];
const brands = Array.isArray(params.brand) ? params.brand : params.brand ? [params.brand] : [];
if (brands.length) clauses.push(`brand:[${brands.join(",")}]`);
const cats = Array.isArray(params.category)
? params.category
: params.category
? [params.category]
: [];
if (cats.length) clauses.push(`categories:[${cats.join(",")}]`);
if (params.priceMin) clauses.push(`price:>=${Number(params.priceMin)}`);
if (params.priceMax) clauses.push(`price:<=${Number(params.priceMax)}`);
return clauses.join(" && ");
}
export default async function SearchPage({ searchParams }: { searchParams: SearchParams }) {
const result = await client.search({
q: searchParams.q ?? "*",
queryBy: "title,brand",
filterBy: buildFilter(searchParams),
facetBy: "brand,categories,in_stock",
page: Number(searchParams.page ?? 1),
perPage: 24,
});
return (
<div className="search-page">
<FacetSidebar facets={result.facetCounts} active={searchParams} />
<ResultGrid hits={result.hits} found={result.found} />
</div>
);
}Facet sidebar component
"use client";
import { useRouter, useSearchParams } from "next/navigation";
type FacetCounts = Array<{
field: string;
counts: Array<{ value: string; count: number }>;
}>;
export function FacetSidebar({ facets }: { facets: FacetCounts }) {
const router = useRouter();
const params = useSearchParams();
function toggle(field: string, value: string) {
const next = new URLSearchParams(params);
const current = next.getAll(field);
if (current.includes(value)) {
next.delete(field);
current.filter((v) => v !== value).forEach((v) => next.append(field, v));
} else {
next.append(field, value);
}
next.delete("page"); // reset pagination on filter change
router.push(`?${next}`);
}
const brand = facets.find((f) => f.field === "brand");
const cats = facets.find((f) => f.field === "categories");
return (
<aside className="facet-sidebar">
<section>
<h3>Brand</h3>
{brand?.counts.map((c) => (
<label key={c.value}>
<input
type="checkbox"
checked={params.getAll("brand").includes(c.value)}
onChange={() => toggle("brand", c.value)}
/>
{c.value} <span className="count">({c.count})</span>
</label>
))}
</section>
<section>
<h3>Category</h3>
{cats?.counts.map((c) => (
<label key={c.value}>
<input
type="checkbox"
checked={params.getAll("category").includes(c.value)}
onChange={() => toggle("category", c.value)}
/>
{c.value} <span className="count">({c.count})</span>
</label>
))}
</section>
</aside>
);
}Filter syntax recap
| Need | Filter expression |
|---|---|
| Single value equality | brand:=Nike |
| Multiple values OR | brand:[Nike,Adidas] (uses inverted index — fast) |
| Numeric range | price:>=10 && price:<=50 |
| Boolean | in_stock:=true |
| Negation | categories:!=Discontinued |
| Combine | Use && for AND, || for OR (single literal values only — see filter-sort-pagination) |
Reactive counts
When the user adds a brand filter, the facet count for brand itself does not shrink (otherwise the filter would hide its own options). The counts for categories do narrow because they reflect the post-filter result set.
This is the AACsearch default. To override (e.g., for "show all options regardless of filter"), use multi-search with one query for results and a second unfiltered query for facet counts.
URL state pattern
URL params are the single source of truth. The Server Component reads them, the FacetSidebar writes them. No useState — the URL drives everything. This:
- Survives reload.
- Makes filtered views shareable.
- Plays nicely with browser back/forward.
- Eliminates client-server desync.
Performance: cap facet count
By default, AACsearch returns up to 100 distinct values per facet field. For very high-cardinality fields (e.g., a product tag with 50,000 distinct values), cap explicitly:
client.search({
q: "*",
facetBy: "brand,categories",
maxFacetValues: 30, // top 30 by count, rest aggregated as "Other"
});Past ~10 facet fields per request, latency rises noticeably. If you need many filters, render the top fields on the first paint and lazy-load the rest with multi-search when the user expands them.
Related
- Filter, sort, pagination
- Product listing page
- Slow search — if facets feel slow
Autocomplete
Debounced search-as-you-type with a dropdown of suggestions. Uses `useDeferredValue` for React-idiomatic debounce and `multi-search` to fetch products + categories in one round trip.
Product listing page
A server-rendered ecommerce PLP with filters, sort, pagination, and SEO-friendly URLs. Combines the faceted search and pagination patterns into a complete page.