|
| 1 | +# Search Results Page (React) |
| 2 | + |
| 3 | +A search results page is the page users land on after submitting a query (from autocomplete, a header search box, or a deep link). It typically combines: a search input, a hits list, pagination, refinements (facets), sort, stats, and a no-results state. It is also the natural home for faceted search. |
| 4 | + |
| 5 | +This file scaffolds the structural skeleton. **Prop details and any prop you have not used before must come from the [Source-of-truth check](../source-of-truth.md), not from this file.** |
| 6 | + |
| 7 | +## Discovery (in addition to SKILL.md Discover step) |
| 8 | + |
| 9 | +Confirm before scaffolding: |
| 10 | + |
| 11 | +- **Route**: which URL renders this page? Verify it actually reads query params and renders dynamic results (not a static listing). |
| 12 | +- **Refinement attributes**: which record fields should become facets? They must be in the index's `attributesForFaceting`. If the user is unsure, ask before adding refinement widgets to avoid a "facet is not configured" error at runtime. |
| 13 | +- **Sort replicas**: does the index have replicas (e.g., `<index>_price_asc`, `<index>_price_desc`)? If yes, ask the user to confirm the replica names and labels for the sort dropdown. If no, do not add `<SortBy>` (or ask whether they want to set replicas up via the `algolia-cli` skill first). |
| 14 | +- **Pagination vs. infinite scroll**: ask the user. Both are supported. Pagination is the default for shareable URLs; infinite scroll is common for catalog-style UX. |
| 15 | +- **Empty/error states**: what should the page show when there are no hits, no query, or the network fails? Ask if not obvious from the design. |
| 16 | +- **Mobile refinements**: how should refinements appear on mobile? Common pattern: drawer or modal, opened by a "Filters" button. Ask if the design isn't explicit. |
| 17 | + |
| 18 | +## Canonical widget tree |
| 19 | + |
| 20 | +This is the structural skeleton. Wire props from types and live docs. |
| 21 | + |
| 22 | +```tsx |
| 23 | +import { |
| 24 | + Configure, |
| 25 | + CurrentRefinements, |
| 26 | + ClearRefinements, |
| 27 | + HierarchicalMenu, |
| 28 | + Hits, |
| 29 | + HitsPerPage, |
| 30 | + Pagination, |
| 31 | + RangeInput, |
| 32 | + RefinementList, |
| 33 | + SearchBox, |
| 34 | + SortBy, |
| 35 | + Stats, |
| 36 | + ToggleRefinement, |
| 37 | + useInstantSearch, |
| 38 | +} from "react-instantsearch"; |
| 39 | + |
| 40 | +function SearchResultsPage() { |
| 41 | + return ( |
| 42 | + <> |
| 43 | + <Configure |
| 44 | + hitsPerPage={20} |
| 45 | + attributesToHighlight={["name", "brand"]} |
| 46 | + attributesToSnippet={["description:30"]} |
| 47 | + /> |
| 48 | + <header> |
| 49 | + <SearchBox /> |
| 50 | + <Stats /> |
| 51 | + <HitsPerPage items={[/* read SortBy/HitsPerPage type for shape */]} /> |
| 52 | + <SortBy items={[/* { label, value } pairs; value is the replica index name */]} /> |
| 53 | + </header> |
| 54 | + |
| 55 | + <aside aria-label="Refinements"> |
| 56 | + <CurrentRefinements /> |
| 57 | + <ClearRefinements /> |
| 58 | + <RefinementList attribute="brand" /> |
| 59 | + <HierarchicalMenu attributes={["categories.lvl0", "categories.lvl1", "categories.lvl2"]} /> |
| 60 | + <RangeInput attribute="price" /> |
| 61 | + <ToggleRefinement attribute="free_shipping" label="Free shipping" /> |
| 62 | + </aside> |
| 63 | + |
| 64 | + <main> |
| 65 | + <NoResultsBoundary fallback={<NoResults />}> |
| 66 | + <Hits hitComponent={Hit} /> |
| 67 | + <Pagination /> |
| 68 | + </NoResultsBoundary> |
| 69 | + </main> |
| 70 | + </> |
| 71 | + ); |
| 72 | +} |
| 73 | + |
| 74 | +function NoResultsBoundary({ children, fallback }: { children: React.ReactNode; fallback: React.ReactNode }) { |
| 75 | + const { results } = useInstantSearch(); |
| 76 | + if (!results.__isArtificial && results.nbHits === 0) { |
| 77 | + return <>{fallback}</>; |
| 78 | + } |
| 79 | + return <>{children}</>; |
| 80 | +} |
| 81 | + |
| 82 | +function NoResults() { |
| 83 | + const { indexUiState } = useInstantSearch(); |
| 84 | + return <div>No results for "{indexUiState.query}".</div>; |
| 85 | +} |
| 86 | + |
| 87 | +function Hit({ hit }: { hit: Record<string, unknown> & { objectID: string } }) { |
| 88 | + // Render with <Highlight attribute="name" hit={hit} /> per technology-rules.md |
| 89 | + return null; |
| 90 | +} |
| 91 | +``` |
| 92 | + |
| 93 | +The `<NoResultsBoundary>` shape is the version-stable pattern from the official guide. Confirm `useInstantSearch` return fields against installed types before destructuring. |
| 94 | + |
| 95 | +## Refinement widgets: which one when |
| 96 | + |
| 97 | +| Use case | Widget | Notes | |
| 98 | +| ------------------------------------- | --------------------------------------- | ---------------------------------------------------------------------------------------------------- | |
| 99 | +| Multi-select categorical filter | `<RefinementList attribute="brand" />` | For long lists, look up `searchable`, `searchablePlaceholder`, `limit`, `showMore`, `showMoreLimit`. | |
| 100 | +| Single-select categorical filter | `<Menu attribute="brand" />` | Mutually exclusive selection. Often used with `transformItems` for ordering. | |
| 101 | +| Hierarchical / nested categories | `<HierarchicalMenu attributes={[...]} />` | Requires `lvl0/lvl1/lvl2` attributes in the records. Read the guide. | |
| 102 | +| Numeric range with input fields | `<RangeInput attribute="price" />` | Numeric attribute. Two inputs (min/max). | |
| 103 | +| Numeric range with a slider | (no built-in) | Use `useRange` and your component library's slider. Read [custom-widgets.md](../custom-widgets.md). | |
| 104 | +| Boolean toggle (free shipping, etc.) | `<ToggleRefinement attribute="..." />` | Toggles a single facet value on/off. | |
| 105 | +| Active-filter chips | `<CurrentRefinements />` | Renders all active refinements as removable chips. Useful at the top of results. | |
| 106 | +| Clear-all button | `<ClearRefinements />` | Pairs with `<CurrentRefinements />`. Look up `excludedAttributes` to keep some refinements pinned. | |
| 107 | + |
| 108 | +For every prop beyond `attribute` / `attributes`, **read the widget's `.d.ts`** before writing. Names and accepted shapes change across versions. |
| 109 | + |
| 110 | +## Pagination vs. infinite scroll |
| 111 | + |
| 112 | +Two mutually exclusive options: |
| 113 | + |
| 114 | +- `<Pagination />`: shareable URL state per page. Default for results pages. |
| 115 | +- `<InfiniteHits />`: replaces both `<Hits>` and `<Pagination>`. Loads more on scroll or on a "Load more" button. Read `<InfiniteHits>`'s `.d.ts` for `showPrevious`, `translations`, and how it interacts with `routing`. |
| 116 | + |
| 117 | +Do not combine both. |
| 118 | + |
| 119 | +## Sort |
| 120 | + |
| 121 | +`<SortBy items={...} />` switches the active index between the primary and its replicas. The replicas must already exist on the Algolia side; this widget only chooses among them. |
| 122 | + |
| 123 | +```tsx |
| 124 | +<SortBy |
| 125 | + items={[ |
| 126 | + { label: "Featured", value: "products" }, |
| 127 | + { label: "Price (low to high)", value: "products_price_asc" }, |
| 128 | + { label: "Price (high to low)", value: "products_price_desc" }, |
| 129 | + ]} |
| 130 | +/> |
| 131 | +``` |
| 132 | + |
| 133 | +If replicas don't exist, do not fabricate them. Tell the user they need to be created first (point them to the `algolia-cli` skill). |
| 134 | + |
| 135 | +## URL state, sharing, deep linking |
| 136 | + |
| 137 | +`routing={true}` on the provider already syncs SearchBox query, refinements, page, and sort to the URL. Verify by changing a refinement and reloading; state should persist. |
| 138 | + |
| 139 | +For custom URL formats (e.g., `/search/brand/nike` rather than `?brand=nike`), pass `routing={{ router, stateMapping }}` and read both types. This is non-trivial; consult the live [Routing guide](https://www.algolia.com/doc/guides/building-search-ui/going-further/routing-urls/react) before scaffolding. |
| 140 | + |
| 141 | +## Multi-index results pages |
| 142 | + |
| 143 | +If the page shows results from more than one index (e.g., products + articles), nest `<Index indexName="...">` blocks. Each index gets its own `<Configure>`, refinements, hits, and pagination, scoped to that index. |
| 144 | + |
| 145 | +```tsx |
| 146 | +<InstantSearch searchClient={searchClient} indexName="products" routing insights> |
| 147 | + <Configure hitsPerPage={20} /> |
| 148 | + <SearchBox /> |
| 149 | + |
| 150 | + <Index indexName="products"> |
| 151 | + <Configure hitsPerPage={20} /> |
| 152 | + <Hits hitComponent={ProductHit} /> |
| 153 | + <Pagination /> |
| 154 | + </Index> |
| 155 | + |
| 156 | + <Index indexName="articles"> |
| 157 | + <Configure hitsPerPage={5} /> |
| 158 | + <Hits hitComponent={ArticleHit} /> |
| 159 | + </Index> |
| 160 | +</InstantSearch> |
| 161 | +``` |
| 162 | + |
| 163 | +Refinement widgets target the **enclosing index**. Putting `<RefinementList attribute="brand">` outside any `<Index>` refines the root index; putting it inside `<Index indexName="products">` refines only that one. |
| 164 | + |
| 165 | +## Features checklist |
| 166 | + |
| 167 | +Consider each. Include when the index and use case support it: |
| 168 | + |
| 169 | +- [ ] `<Configure>` sets `hitsPerPage`, `attributesToHighlight`, `attributesToSnippet` |
| 170 | +- [ ] `<SearchBox>` (or wired to autocomplete that submits here) |
| 171 | +- [ ] `<Stats>` for "X results in Y ms" |
| 172 | +- [ ] `<Hits>` (or `<InfiniteHits>`) with a typed `hitComponent` using `<Highlight>` / `<Snippet>` |
| 173 | +- [ ] `<Pagination>` (or `<InfiniteHits>`, not both) |
| 174 | +- [ ] `<CurrentRefinements>` + `<ClearRefinements>` |
| 175 | +- [ ] One or more refinement widgets matching the index's `attributesForFaceting` |
| 176 | +- [ ] `<SortBy>` if replicas exist |
| 177 | +- [ ] `<HitsPerPage>` if the design needs it |
| 178 | +- [ ] No-results boundary with a helpful message |
| 179 | +- [ ] Mobile refinement drawer / modal |
| 180 | +- [ ] `routing={true}` and `insights={true}` on the provider |
| 181 | +- [ ] SSR if the framework supports it (see [ssr.md](../ssr.md)) |
| 182 | + |
| 183 | +For pattern-specific styling, see [styling.md](styling.md). For pattern-specific anti-patterns, see [anti-patterns.md](anti-patterns.md). |
0 commit comments