Building a product listing page
A product listing page (PLP) combines the search hooks from @archibald/search with URL-driven refinement: the category comes from the route, and facets, sorting, and pagination live in the query string — so every listing state is a shareable URL and each change re-keys the search. Backend specifics are covered in the commerce search guide.
Running the search
useSearchCategory wraps useSearch (which itself wraps useFetch, so it returns the same data/isLoading/error shape). Feed it the category from the route and the current query string:
import { useLocation, useParams } from '@archibald/core';
import { useSearchCategory } from '@archibald/search';
function useCategorySearch() {
const { search } = useLocation();
const { categoryId } = useParams<{ categoryId: string }>();
return useSearchCategory({ categoryId: categoryId ?? '', search, group: true });
}
The response is a SearchResponse: products, facets, pagination, sorts, and the applied-facet breadcrumbs:
function ProductListing() {
const { data, isLoading } = useCategorySearch();
const { products, facets, pagination, breadcrumbs } = data ?? {};
if (isLoading) {
return <ListingSkeleton />;
}
if (!products?.length) {
return <EmptyListing />;
}
return (
<>
<AppliedFacets breadcrumbs={breadcrumbs} />
{facets?.map((facet) => <FacetGroup key={facet.id} facet={facet} />)}
<ProductGrid products={products} />
<Pagination pagination={pagination} />
</>
);
}
Facets via URL query parameters
Each Facet has an id (its query-string key), a type (multiSelect, singleSelect, numericalRange, …), and values with name, count, and selected. Toggle a value by writing it into the URL — the query string is part of the search key, so the listing refetches on its own:
import { useSearchParams } from '@archibald/core';
import type { Facet } from '@archibald/search';
function FacetGroup({ facet }: { readonly facet: Facet }) {
const [getParam, { append, delete: remove }] = useSearchParams({ getAll: true });
const selected = getParam(facet.id!) ?? [];
function toggle(value: string) {
const next = selected.includes(value) ? selected.filter((entry) => entry !== value) : [...selected, value];
remove(facet.id!);
next.forEach((entry) => append(facet.id!, entry));
}
return (
<fieldset>
<legend>{facet.name}</legend>
{facet.values.map((value) => (
<label key={value.name}>
<input checked={value.selected} onChange={() => toggle(value.id ?? value.name)} type="checkbox" />
{value.name} ({value.count})
</label>
))}
</fieldset>
);
}
Pagination
Same mechanism — currentPage is a query parameter, and pagination.totalPages from the response bounds the controls:
import { useLocation, useNavigate } from '@archibald/core';
function usePagination() {
const navigate = useNavigate();
const { search } = useLocation();
const params = new URLSearchParams(search);
const currentPage = Number(params.get('currentPage') ?? 1);
function goToPage(page: number) {
params.set('currentPage', String(page));
navigate({ search: params.toString() });
}
return { currentPage, goToPage };
}
Type-ahead suggestions
useSearchQuerySuggestions keys its fetch by the typed term, so it refetches as the user types and caches per term:
import { useSearchQuerySuggestions } from '@archibald/search';
function SuggestionList({ searchTerm, onPick }: { readonly searchTerm: string; readonly onPick: (value: string) => void }) {
const { data } = useSearchQuerySuggestions(searchTerm);
if (!data?.suggestions?.length) {
return <NoResults term={searchTerm} />;
}
return (
<ul role="listbox">
{data.suggestions.map((suggestion) => (
<li key={suggestion.value} onClick={() => onPick(suggestion.value)} role="option">
{suggestion.value}
</li>
))}
</ul>
);
}