Filtering, Sorting, and Pagination
In this guide, you'll learn how to let customers refine a storefront's search results by a field's values, change the order of the results, and move through them a page at a time.
Integrate with an AI Agent#
If you use an AI coding agent, pass it the following prompt to add filters, sorting, and pagination to your storefront. Then, use the rest of this guide to understand the changes and customize them.
<role>You are a front-end engineer adding faceted filtering, sorting, and pagination to a Medusa storefront's search results.</role> <task>Let customers narrow a list of search results by a field's values, change the order of the results, and move through them a page at a time.</task> <context>- The storefront already has the InstantSearch adapter installed, and exports a search client and an index name constant from a shared module, such as `src/lib/search-client.ts`.- Every component that reads or changes the search must sit inside the same `InstantSearch` provider as the results it refines.- An empty query matches every document in the index, and the adapter searches on an empty query by default, through its `placeholderSearch` option. This is the hinge the whole UI turns on: a surface with no query renders the entire catalogue, and the "previous results" of an empty query are the entire catalogue too. A surface that lists results without a query, such as a store or category page, relies on exactly this, and the same client serves every search surface in the storefront.- InstantSearch keeps the previous response in `results` while the next search runs, and `results.query` is the query that response was fetched for. Keep those previous results on screen, except when `results.query` is empty and the customer has typed, such as `hasInput && !results.query`. That's the one case where the previous results are the entire catalogue, flashing on screen for a beat before the matches arrive. Don't treat every difference between `results.query` and the query last sent as stale: after a debounce the two differ on nearly every keystroke, so the list blanks as the customer types.- A facet is a field the search engine groups and counts. The adapter asks the engine for a field's values, and turns a customer's selection into a filter on the search query.- A field only works with a filter widget if the Medusa application's search index definition marks it `facetable()`. Range refinements additionally need `facetable({ types: ["stats"] })`, and the field must be listed in the search client's `numericAttributes` option.- `react-instantsearch` exports the `RangeInput` component and the `useRange` and `useNumericMenu` hooks. `RangeSlider`, `NumericMenu`, and `RatingMenu` are instantsearch.js widgets, and importing those names from `react-instantsearch` fails. For a refinement with no React hook, such as a rating menu, build one with `react-instantsearch`'s `useConnector` and the instantsearch.js connector.- The adapter reads `facetFilters` and `numericFilters`. It ignores an Algolia `filters` string silently, so a filter passed that way never reaches the search query.- The adapter is a search client, not UI components, and the supported widgets each need configuration on it: - A range or stats widget needs the field in the client's `numericAttributes`, or the adapter requests value facets instead of stats and the widget never receives `facets_stats`. - A product index often holds a price field per currency, such as `min_price_usd` and `min_price_eur`. Build each price widget's attribute from the region's currency code, and list every currency's range fields in `numericAttributes`. An `on_sale_{currency}` boolean, when the index holds one, is a toggle refinement rather than a range. - A sort option's index name is `{index}/sort/{field}:{direction}`, such as `product/sort/created_at:desc`. The adapter parses the `/sort/` suffix off the name before it queries. A custom route that serves an index must parse it off too, or it looks up an entity named `product/sort/created_at:desc` and fails. - `transformQuery` receives the adapted query and the original InstantSearch request and returns the query to send. It's the escape hatch when a widget-level option can't express the filter you need.- `Configure`'s `facetFilters` doesn't sit beside the array the refinement widgets build; InstantSearch merges the two. A pinned filter can end up inside the same inner array as a customer's ticked value, which makes it an `OR` group and silently stops it from scoping the results. Verify a pinned filter still narrows the list once a refinement is ticked, and if it doesn't, apply it in `transformQuery` instead, where nothing merges into it.- InstantSearch's sort control switches between indexes, while the Search Module sorts through the query's `pagination.order`. The adapter bridges the two by reading the sort from the index name, in the format `{index}/sort/{field}:{direction}`. The bare index name sorts by relevance. A field must be `sortable()` in the index definition to appear in a sort value.- InstantSearch works in pages. The adapter turns the page number and page size into the search query's `skip` and `take`.- The number of pages comes from the total count the search provider reports. The query's `search_options.count` chooses the counting strategy: `exact`, `estimated`, or `none`.- Consult the Medusa documentation at https://docs.medusajs.com, or the Medusa MCP server if it's available, for anything this prompt doesn't cover.</context> <input>You have access to the storefront's codebase, and to the Medusa application's index definition if it's available. Before writing code, determine:- The pages that list search results, and the component each one renders.- Which fields the index marks as `facetable()`, which of those allow `stats`, and which are `sortable()`.- The storefront's existing result card or list component, and the page size its other lists use.</input> <steps>1. List the facetable and sortable fields available on the index. If you can't read the index definition, ask the user which fields to use before writing any code.2. Ask the user which pages to cover, which fields to expose as filters, which sort options to offer, and whether they want numbered pages or an endless list. Ask in the same message what each surface should show when the query is empty: every document, or nothing until the customer types. It's a product decision that differs per surface, so never default it. A store or category listing usually shows everything, a search drawer usually shows nothing. Don't start writing code until they answer.3. Wrap each page's contents in an `InstantSearch` provider using the shared search client, with the filters beside the results. Enable `routing` on the provider so the filters, sort, and page appear in the URL. Replace the page's existing data fetching, don't run both.4. Add a filter for each field the user chose. Use a checkbox list for a field with many values, a single-select list for a field where only one value applies at a time, and a toggle for an on/off condition.5. Add controls that show the applied filters and clear them.6. If the user asked for a numeric range filter, confirm the field allows stats facets and appears in the search client's `numericAttributes`. If either is missing, add the field to `numericAttributes`, and report that the index definition needs `facetable({ types: ["stats"] })`.7. Add the sort control. Build each option's value from the index name constant. Include the bare index name, relevance, among the options and list it first, so the control matches the provider's `indexName`, which is what makes relevance the default.8. Set the page size to match the storefront's other lists.9. Add the pagination the user chose. Use one approach or the other, never both on the same surface.10. Implement the empty-query behavior each surface chose. For a surface that shows nothing, render nothing until there's a query, rather than searching and hiding the hits.11. Account for each state the list can be in: no query yet, results, no results, and a failed search. These are states to account for, not a mandate for four visuals. A search in flight may legitimately have no visual of its own when valid results are already on screen, and "renders nothing" is a valid treatment for a state. A skeleton over an empty list in a debounced drawer is noise, since the state it covers is barely reachable. What isn't acceptable is one treatment silently standing in for another, such as an empty list for a failed search.12. Check that the query, filters, and sort still apply after moving to another page, and that changing a filter or the sort returns the customer to the first page.13. Report what you changed and how to test it.</steps> <constraints>- Never add a filter widget for a field you haven't confirmed is facetable, or a sort option for a field you haven't confirmed is sortable. Both render empty or have no effect otherwise.- Never filter, sort, or paginate the returned hits in the browser. Every refinement must reach the search query, or the customer never sees the results it excluded.- Never place a filter in the storefront that exists to protect data, such as the sales channel or a publication status. Enforce those in the Medusa application's search route.- Never hardcode the index name in a sort value. Build it from the exported constant.- Never pass a pinned filter as an Algolia `filters` string on `Configure`. Use `facetFilters`, with one inner array per filter, which the adapter combines with AND.- Never render hits from a response whose `results.query` is empty once the customer has typed.- Never blank the results because the query moved on. Only the empty query's results are stale.- Never decide what an empty query shows. That's the user's call in step 2.- Never add a loading visual that replaces results still valid for the current query.- Never make a field-based sort the default. Relevance is the default for search results.- Never call a range refinement's `refine` from a slider's continuous change event. Track the dragged value in local state and refine once, when the customer releases it, or every pixel of the drag sends a search.- Never render two pagination approaches on the same surface. They fight over the same page state.- Never assume the total result count is exact. Check the provider's counting strategy before rendering a "page X of Y" label.- Never create a second search client or a second `InstantSearch` provider. Every surface imports the one exported from the shared module.- Never leave a page's old data fetching in place beside the search. The list must come from one source.- Never add a CSS framework or component library the storefront doesn't already use.</constraints> <error_handling>- If a filter widget renders no values, report that the field may not be facetable in the index definition rather than removing the widget silently.- If a range refinement renders no bounds, report that the field needs `facetable({ types: ["stats"] })` and an entry in `numericAttributes`.- If a pinned filter has no effect and every document still renders, check that it's passed as `facetFilters` rather than as a `filters` string, and that a ticked refinement hasn't merged into the pin's inner array. Move the pin to `transformQuery` if it has.- If the full list flashes before a query's results arrive, the gate misses the empty-query case. Fix the gate; don't hide it with a loading state.- If the list blanks between keystrokes, the gate is comparing `results.query` to the current query rather than checking for the empty query. Narrow it.- If a sort option has no effect on the results, report that the field may not be sortable rather than sorting in the browser.- If the last page number changes as the customer moves through pages, report that the provider is counting approximately rather than hiding the pagination.- If you can't read the index definition, ask the user for the list of facetable and sortable fields.- If the results reset to the first page when a filter or the sort changes, that's correct behavior. Don't work around it.</error_handling> <output_format>Report in this structure: ## ChangesA list of the files you created or modified, each with a one-line description. ## FiltersEach field you added a filter for, with the widget type. ## Sort OptionsEach option you added, with its label and index name value. ## PaginationWhich approach you used, the page size, and whether the total count is exact. ## Index Changes NeededAny modifier the Medusa application's index definition is missing. Write "None" if there are none. ## How to TestNumbered steps that end in an observable result, including one that checks the list renders results before any filter is applied, one that types the first query and confirms the full catalogue never flashes, and one that checks a filter and a sort both still apply on the second page.</output_format> <success_criteria>- Each surface's empty query behaves the way the user described in step 2.- The list renders every document when no filter is applied, on a surface that shows everything.- Typing the first query never renders the empty query's hits.- A pinned filter still narrows the results after the customer ticks a refinement.- Selecting a filter value narrows the results and updates the result count.- The filter values show counts, and the counts change as other filters are applied.- Clearing all filters restores the unfiltered results.- The sort control defaults to relevance, and choosing another option reorders the results.- Moving to another page shows different results, with the query, filters, and sort still applied.- Changing a filter or the sort returns the customer to the first page.- The filters, sort, and current page appear in the URL and survive a reload.- Every field with a filter widget is facetable, and every field in a sort value is sortable.- The storefront builds and type-checks with no new errors.</success_criteria>
Field Modifiers a Refinement Requires#
A field supports a refinement only if your index definition marks it with the matching modifier:
Refinement | Required Modifier |
|---|---|
Filter by a field's values |
|
Filter by a numeric range |
|
Sort by a field |
|
The field names in this guide's examples, such as collection_title or option_values, are the ones a typical product index holds. Use the names your own index definition declares instead, as a widget pointed at a field the index doesn't hold renders nothing.
Set Up the Search Context#
Every component in this guide reads from and writes to one search state, so wrap the page's contents in a single InstantSearch provider. The filters, the sort, and the pagination then stay in step without wiring them together.
For example:
1import { Configure, InstantSearch } from "react-instantsearch"2 3import {4 PRODUCT_INDEX_NAME,5 searchClient,6} from "../lib/search-client"7// Components that show filters and results8import Filters from "./filters"9import ProductHits from "./product-hits"10 11const ProductList = () => (12 <InstantSearch13 indexName={PRODUCT_INDEX_NAME}14 searchClient={searchClient}15 routing16 >17 <Configure hitsPerPage={12} />18 <Filters />19 <ProductHits />20 </InstantSearch>21)22 23export default ProductList
Where:
The page no longer fetches its own data. Replace its existing fetching rather than running both, as two sources produce two different lists.
Filter by a Field's Values#
The useRefinementList hook returns a field's values, their counts, and a refine function that toggles a value in the search query's filters. Use it for any field whose values the customer picks from a list, such as a collection, a category, a type, or a tag.
The following component takes the field as a prop, so you can render it once per field:
1import { useRefinementList } from "react-instantsearch"2 3type Props = {4 attribute: string5 title: string6}7 8const RefinementGroup = ({ attribute, title }: Props) => {9 const { items, refine } = useRefinementList({10 attribute,11 limit: 20,12 sortBy: ["count:desc", "name:asc"],13 })14 15 if (!items.length) {16 return null17 }18 19 return (20 <div>21 <span>{title}</span>22 {items.map((item) => (23 <label key={item.value}>24 <input25 type="checkbox"26 checked={item.isRefined}27 onChange={() => refine(item.value)}28 />29 {item.label} ({item.count})30 </label>31 ))}32 </div>33 )34}35 36export default RefinementGroup
Parameters#
useRefinementList accepts an object with the following properties:
Returns#
It returns an object with the following properties:
A field returns no values when nothing in the current results carries it, so the component renders nothing in that case.
Render a Filter per Field#
Render the component once per field you want to expose. For a product index, that's typically the collection, the categories, and the tags:
1import RefinementGroup from "./refinement-group"2 3const Filters = () => (4 <div>5 <RefinementGroup6 attribute="collection_title"7 title="Collection"8 />9 <RefinementGroup10 attribute="category_names"11 title="Category"12 />13 <RefinementGroup attribute="tags" title="Tags" />14 </div>15)16 17export default Filters
Each filter reads from the same provider, so selecting a collection updates the category and tag counts in the same response.
limit caps how many values the filter requests. Pair it with the showMore option to let customers expand the list.Choose the Widget for the Field#
useRefinementList suits a field whose values combine, but not every field does. Choose the hook by how the customer picks a value:
Hook | Use it for |
|---|---|
A field whose values combine, such as tags or categories. The customer selects any number of them. | |
A field where one value applies at a time, such as a product type. Selecting a value replaces the previous one. | |
An on/off condition, such as "In stock only". The customer switches one value on. | |
A nested category tree. The index must hold the level fields the hook expects, such as |
Each of these hooks writes to the same search state, so they all work beside each other in one provider.
Filter by Product Options#
A product's options, such as size and color, live in one index field. Indexing them as Size:Large and Color:Red keeps them filterable as one facet, so split each value on the colon and group by the part in front of it.
For example, the following component groups the values and renders a button for each one:
1import { useRefinementList } from "react-instantsearch"2 3type Item = ReturnType<4 typeof useRefinementList5>["items"][0]6 7function groupItems(items: Item[]) {8 const groups = new Map<string, Item[]>()9 10 for (const item of items) {11 const separator = item.label.indexOf(":")12 13 if (separator < 1) {14 continue15 }16 17 const title = item.label.slice(0, separator)18 19 groups.set(title, [20 ...(groups.get(title) ?? []),21 item,22 ])23 }24 25 return Array.from(groups.entries())26}27 28const OptionRefinements = () => {29 const { items, refine } = useRefinementList({30 attribute: "option_values",31 limit: 200,32 sortBy: ["name:asc"],33 })34 35 const groups = groupItems(items)36 37 if (!groups.length) {38 return null39 }40 41 return (42 <div>43 {groups.map(([title, values]) => (44 <div key={title}>45 <span>{title}</span>46 {values.map((value) => (47 <button48 key={value.value}49 onClick={() => refine(value.value)}50 aria-pressed={value.isRefined}51 >52 {value.label.slice(53 value.label.indexOf(":") + 154 )}55 <span>({value.count})</span>56 </button>57 ))}58 </div>59 ))}60 </div>61 )62}63 64export default OptionRefinements
The search results return one facet holding every option value, so limit must cover them all. Ten options of ten values each need a limit of a hundred.
name:asc keeps the values in a fixed order, rather than reordering them as counts change.
Filter by Price Ranges#
To filter by price ranges, use range refinements. A range refinement needs a field's lowest and highest values before it can render. The Search Module Provider reports those as a stats facet, which is opt-in on both sides.
First, in the index definition in the Medusa backend, mark the field as a stats facet:
1import {2 defineSearchIndex,3 search,4} from "@medusajs/framework/utils"5 6export const productIndex = defineSearchIndex({7 name: "product",8 entity: "product",9 fields: search.define({10 min_price: search11 .float()12 .filterable()13 .facetable({ types: ["stats"] }),14 // ...15 }),16 // ...17})
Then, in the storefront, list the field in the search client's numericAttributes option, so the adapter requests it as a stats facet rather than a value facet:
Finally, add the widget to your filters. RangeInput is the only range widget react-instantsearch ships, and it renders two number fields the customer types a minimum and a maximum into:
The adapter turns the selected range into $gte and $lte operators on the field.
Price Fields per Currency#
A search engine can't calculate a price at query time, so a product index holds a set of price fields per currency, such as min_price_usd and min_price_eur. Refer to the Product Search Index Examples guide for the index side of that.
A widget then points at the field of the currency the customer is browsing in. So, export a helper that builds the attribute name, and list every currency's fields in numericAttributes:
1export const SEARCH_PRICE_CURRENCIES = ["usd", "eur"]2 3export type PriceField =4 | "min_price"5 | "max_price"6 | "original_price"7 | "on_sale"8 9// A region whose currency the index doesn't hold10// falls back to the first one, so the listing still11// shows a price rather than none.12export const indexedCurrency = (currencyCode: string) => {13 const code = currencyCode.toLowerCase()14 15 return SEARCH_PRICE_CURRENCIES.includes(code)16 ? code17 : SEARCH_PRICE_CURRENCIES[0]18}19 20export const priceAttribute = (21 field: PriceField,22 currencyCode: string23) => `${field}_${indexedCurrency(currencyCode)}`24 25export const { searchClient } = createInstantSearchAdapter({26 sdk,27 path: "/store/search",28 numericAttributes: SEARCH_PRICE_CURRENCIES.flatMap(29 (currency) => [30 `min_price_${currency}`,31 `max_price_${currency}`,32 ]33 ),34})
Every price widget then receives the region's currency code and resolves its own attribute:
numericAttributes must list the fields of every currency the index holds, since the customer's region decides which one a request asks for. A missing field renders the widget without bounds.
Example: Build a Price Slider with useRange#
react-instantsearch doesn't ship a slider. You can build one on the useRange hook. It returns the field's bounds from the stats facet, the current selection, and a refine function that sets a new one.
For example, the following component renders a two-thumb slider, such as Radix's, over the price field of the region's currency:
1import * as Slider from "@radix-ui/react-slider"2import { useEffect, useState } from "react"3import { useRange } from "react-instantsearch"4 5import { priceAttribute } from "../lib/search-client"6 7const PriceSlider = ({8 currencyCode,9}: {10 currencyCode: string11}) => {12 const { range, start, refine, canRefine } = useRange({13 attribute: priceAttribute("min_price", currencyCode),14 })15 16 const min = Math.floor(range.min ?? 0)17 const max = Math.ceil(range.max ?? 0)18 const from = Number.isFinite(start[0])19 ? (start[0] as number)20 : min21 const to = Number.isFinite(start[1])22 ? (start[1] as number)23 : max24 25 const [value, setValue] = useState([from, to])26 27 useEffect(() => {28 setValue([from, to])29 }, [from, to])30 31 // No stats yet, or every product costs the same.32 if (!canRefine || min >= max) {33 return null34 }35 36 return (37 <Slider.Root38 value={value}39 min={min}40 max={max}41 step={1}42 onValueChange={setValue}43 onValueCommit={(committed) =>44 refine([committed[0], committed[1]])45 }46 aria-label="Price range"47 >48 <Slider.Track>49 <Slider.Range />50 </Slider.Track>51 {value.map((_, index) => (52 <Slider.Thumb key={index} />53 ))}54 </Slider.Root>55 )56}57 58export default PriceSlider
onValueChange moves the thumbs in local state, so dragging doesn't send a search per pixel, and onValueCommit refines once the customer releases a thumb. The useEffect keeps the thumbs in step with a refinement that changed elsewhere, such as clearing all filters or a bound arriving from the URL on load.
The slider reads the attribute of the region's currency, so a customer browsing in another currency filters on that currency's prices without the component changing.
useRange returns an object with the following properties:
Range React Widgets#
Of the range refinements InstantSearch offers, react-instantsearch exports the RangeInput component, and the useRange and useNumericMenu hooks. RangeSlider, NumericMenu, and RatingMenu are instantsearch.js widgets, so importing those names from react-instantsearch fails.
Any instantsearch.js connector still works in React through the useConnector hook, which turns a connector into a hook you own. That's how you cover a refinement react-instantsearch ships no hook for:
Refinement | Use it for | In react-instantsearch |
|---|---|---|
Two number fields the customer types a minimum and maximum into. | Yes, as a component. | |
Any range control you build yourself, such as a draggable slider between the field's lowest and highest values. | Yes, as a hook. | |
Ranges you define yourself, such as "Under $50" and "$50 to $100". | Yes, as a hook. There's no | |
A star rating, filtering by "this rating and above". | Not as an export. Build the hook yourself with |
Filter Products on Sale#
An index that holds an on_sale field per currency, as the Product Search Index Examples guide shows, can filter down to the discounted products with a single checkbox.
Use the useToggleRefinement hook, which filters on one value of a field and reports how many documents carry it:
1import { useToggleRefinement } from "react-instantsearch"2 3import { priceAttribute } from "../lib/search-client"4 5const OnSaleToggle = ({6 currencyCode,7}: {8 currencyCode: string9}) => {10 const { value, refine, canRefine } = useToggleRefinement({11 attribute: priceAttribute("on_sale", currencyCode),12 on: true,13 })14 15 if (!canRefine) {16 return null17 }18 19 return (20 <label>21 <input22 type="checkbox"23 checked={value.isRefined}24 onChange={() => refine(value)}25 />26 <span>On sale only</span>27 {typeof value.count === "number" && (28 <span>({value.count})</span>29 )}30 </label>31 )32}33 34export default OnSaleToggle
The field must be filterable() and facetable() in the index definition, since the count comes from a facet. Until a promotion or a price list reduces a price, no product carries true, canRefine is false, and the toggle hides itself rather than rendering a filter that empties the list.
Show and Clear the Applied Filters#
Once a customer applies more than one filter, they need to see what's applied and undo it without hunting through the sidebar.
The useCurrentRefinements hook returns every applied refinement, grouped by field, with a refine function that removes one:
1import { useCurrentRefinements } from "react-instantsearch"2 3const CurrentRefinements = () => {4 const { items } = useCurrentRefinements()5 6 return (7 <div>8 {items.map((item) =>9 item.refinements.map((refinement) => (10 <button11 key={refinement.label}12 onClick={() => item.refine(refinement)}13 >14 {refinement.label} ×15 </button>16 ))17 )}18 </div>19 )20}21 22export default CurrentRefinements
The useClearRefinements hook removes every applied filter at once. Its canRefine flag is false when there's nothing to clear:
1import { useClearRefinements } from "react-instantsearch"2 3const ClearRefinements = () => {4 const { canRefine, refine } = useClearRefinements()5 6 if (!canRefine) {7 return null8 }9 10 return (11 <button onClick={refine}>Clear all filters</button>12 )13}14 15export default ClearRefinements
It removes the filters but keeps the sort and the query.
Sort the Results#
InstantSearch and the Search Module express sorting differently:
- InstantSearch's sort switches between indexes since, in Algolia, each sort order is a separate index.
- The Search Module sorts within one index, through the search query's
pagination.order.
The adapter bridges the two by reading the sort from the index name. An index name of the format {index}/sort/{field}:{direction} becomes a query on {index} ordered by {field}.
The useSortBy hook takes the options and returns the current one used for sorting, with a refine function that changes it, so you can render them with any control, such as a select or a radio group:
1import { useSortBy } from "react-instantsearch"2 3import { PRODUCT_INDEX_NAME } from "../lib/search-client"4 5const SORT_OPTIONS = [6 { value: PRODUCT_INDEX_NAME, label: "Relevance" },7 {8 value: `${PRODUCT_INDEX_NAME}/sort/created_at:desc`,9 label: "Latest Arrivals",10 },11 {12 value: `${PRODUCT_INDEX_NAME}/sort/title:asc`,13 label: "Title: A to Z",14 },15 {16 value: `${PRODUCT_INDEX_NAME}/sort/title:desc`,17 label: "Title: Z to A",18 },19]20 21const SortProducts = () => {22 const { currentRefinement, refine } = useSortBy({23 items: SORT_OPTIONS,24 })25 26 return (27 <select28 value={currentRefinement}29 onChange={(e) => refine(e.target.value)}30 >31 {SORT_OPTIONS.map((option) => (32 <option key={option.value} value={option.value}>33 {option.label}34 </option>35 ))}36 </select>37 )38}39 40export default SortProducts
Build every value from the exported index name constant, so renaming the index changes one line.
To break ties, list more fields in the suffix, separated by commas. The adapter applies them in the order you list them.
For example, the following value sorts by min_price ascending, then by title ascending:
If the index holds a price field per currency, build the sort values with the same priceAttribute helper, so the sort follows the region the customer is browsing in:
1import {2 PRODUCT_INDEX_NAME,3 priceAttribute,4} from "../lib/search-client"5 6const getSortOptions = (currencyCode: string) => {7 const minPrice = priceAttribute(8 "min_price",9 currencyCode10 )11 12 return [13 { value: PRODUCT_INDEX_NAME, label: "Relevance" },14 {15 value: `${PRODUCT_INDEX_NAME}/sort/${minPrice}:asc`,16 label: "Price: Low to High",17 },18 {19 value: `${PRODUCT_INDEX_NAME}/sort/${minPrice}:desc`,20 label: "Price: High to Low",21 },22 ]23}
Pass the options to useSortBy the same way, and a product without a price in that currency drops out of the price sorts, since the index holds no value to sort it by.
The chosen sort appears in the URL alongside the filters, and it stays applied as the customer moves through pages.
title and title:asc mean the same thing.Paginate the Results#
The usePagination hook returns the page numbers to render, the current page, and a refine function that moves between pages:
1import { usePagination } from "react-instantsearch"2 3const SearchPagination = () => {4 const { pages, currentRefinement, nbPages, refine } =5 usePagination({ padding: 2 })6 7 if (nbPages <= 1) {8 return null9 }10 11 const lastPage = nbPages - 112 13 const renderPage = (page: number) => (14 <button15 key={page}16 disabled={page === currentRefinement}17 onClick={() => refine(page)}18 >19 {page + 1}20 </button>21 )22 23 return (24 <div>25 {!pages.includes(0) && renderPage(0)}26 {pages.map(renderPage)}27 {!pages.includes(lastPage) && renderPage(lastPage)}28 </div>29 )30}31 32export default SearchPagination
pages holds the window of page numbers around the current one, sized by padding. Render the first and last page outside that window so customers can jump to either end.
The numbers are zero-based, so add one when you render them.
Load More Instead of Pages#
For an endless list, use the useInfiniteHits hook rather than useHits. It appends each page to the results already rendered:
1import { useInfiniteHits } from "react-instantsearch"2 3const ProductHits = () => {4 const { items, showMore, isLastPage } = useInfiniteHits()5 6 return (7 <>8 <ul>9 {items.map((hit) => (10 <li key={hit.objectID}>{hit.title}</li>11 ))}12 </ul>13 {!isLastPage && (14 <button type="button" onClick={showMore}>15 Load more16 </button>17 )}18 </>19 )20}21 22export default ProductHits
To load the next page as the customer scrolls, call showMore from an intersection observer on a sentinel element at the end of the list, rather than from a button.
Apply a Filter the Customer Can't Change#
Some filters aren't a customer's choice, such as a category page listing only that category's products, or a collection page listing only that collection's. Pass those to the Configure widget alongside the page size, rather than adding a filter component for them.
So, a category page is the same component as the store page, with one extra prop on Configure:
1import { Configure, InstantSearch } from "react-instantsearch"2 3import {4 PRODUCT_INDEX_NAME,5 searchClient,6} from "../lib/search-client"7import Filters from "./filters"8import ProductHits from "./product-hits"9 10type Props = {11 categoryName: string12}13 14const CategoryProducts = ({ categoryName }: Props) => (15 <InstantSearch16 indexName={PRODUCT_INDEX_NAME}17 searchClient={searchClient}18 routing19 >20 <Configure21 hitsPerPage={12}22 facetFilters={[[`category_names:${categoryName}`]]}23 />24 <Filters />25 <ProductHits />26 </InstantSearch>27)28 29export default CategoryProducts
The provider, the filters, the sort, and the pagination stay the same on every listing page. Only the pinned filter differs, so one set of components serves the store page, the category pages, and the collection pages.
To pin more than one filter, add an inner array per filter. The adapter combines the inner arrays with AND, and the values inside one inner array with OR. For example, facetFilters={[[`category_names:${categoryName}`], ["tags:sale"]]} narrows to products that are in the category and carry the sale tag.
Exact Result Counts#
The number of pages depends on the total count the search provider reports. Providers can count in three ways, which the query's search_options.count chooses:
estimated(default): the engine approximates the total. The last page number may shift as the customer moves through the list.exact: the engine counts every match. The page count is right, at the cost of a slower search on large indexes.none: the engine returns no total. The adapter then estimates from the current page, so InstantSearch only knows whether another page exists.
To count exactly, set it in the search client's additionalSearchParameters: