AACsearch
SDKsCookbook

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

NeedFilter expression
Single value equalitybrand:=Nike
Multiple values ORbrand:[Nike,Adidas] (uses inverted index — fast)
Numeric rangeprice:>=10 && price:<=50
Booleanin_stock:=true
Negationcategories:!=Discontinued
CombineUse && 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.

On this page