Skip to main content

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.

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>
);
}