Skip to content

Commit f38efca

Browse files
feat(instantsearch): add React search results page pattern
Adds the canonical widget tree (SearchBox, Configure, Hits, Pagination, Stats, NoResultsBoundary), the refinements catalog (RefinementList, HierarchicalMenu, RangeInput, ToggleRefinement, CurrentRefinements, ClearRefinements), sort via SortBy with replicas, and multi-index guidance. The pattern reference points reviewers at types and live docs for prop-level details rather than baking them in. Adds evals 6 and 7 covering the full results page and a searchable refinement list with active-filter chips. Stacks on feat/instantsearch-source-of-truth.
1 parent 8eb583d commit f38efca

5 files changed

Lines changed: 311 additions & 3 deletions

File tree

skills/instantsearch/SKILL.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -86,9 +86,10 @@ Pick the matching pattern reference for the library and the user's request. If n
8686

8787
Patterns available for each library:
8888

89-
| Pattern | React | Vue | JS |
90-
| ------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --- | --- |
91-
| Autocomplete | [features](references/react/autocomplete/features.md), [styling](references/react/autocomplete/styling.md), [anti-patterns](references/react/autocomplete/anti-patterns.md) |||
89+
| Pattern | React | Vue | JS |
90+
| ------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | --- | --- |
91+
| Autocomplete | [features](references/react/autocomplete/features.md), [styling](references/react/autocomplete/styling.md), [anti-patterns](references/react/autocomplete/anti-patterns.md) |||
92+
| Search results page (incl. faceted search, sort, pagination) | [features](references/react/search-results-page/features.md), [styling](references/react/search-results-page/styling.md), [anti-patterns](references/react/search-results-page/anti-patterns.md) |||
9293

9394
Also read and apply the library-level references (apply regardless of pattern):
9495

skills/instantsearch/evals/evals.json

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,39 @@
7676
"Does not guess ais-* class names"
7777
]
7878
},
79+
{
80+
"id": 6,
81+
"prompt": "Add a search results page at /search for my Next.js App Router site. Users should be able to filter by brand and category, sort by price ascending or descending, and paginate. The index has replicas products_price_asc and products_price_desc.",
82+
"expected_output": "A results page using InstantSearchNext with the canonical widget tree, refinements, sort, pagination, routing, insights, and a no-results boundary, with all non-trivial props sourced from installed types",
83+
"files": [],
84+
"expectations": [
85+
"Uses InstantSearchNext from react-instantsearch-nextjs at the layout level, not wrapping a single page",
86+
"Sets routing={true} and insights={true} on the provider",
87+
"Uses Configure to set hitsPerPage, attributesToHighlight, and attributesToSnippet",
88+
"Uses RefinementList for brand and HierarchicalMenu (or RefinementList/Menu, justified) for category",
89+
"Uses SortBy with the provided replica index names (products, products_price_asc, products_price_desc)",
90+
"Uses Pagination, not InfiniteHits, since the user did not ask for infinite scroll",
91+
"Renders a NoResultsBoundary using useInstantSearch().results.nbHits",
92+
"Uses Highlight on hit text attributes; does not render raw strings",
93+
"Reads RefinementList and SortBy props from node_modules/react-instantsearch/dist/es/widgets before writing prop values it has not used before",
94+
"Does NOT add getServerState (App Router handles SSR via InstantSearchNext)",
95+
"Does NOT combine Pagination with InfiniteHits"
96+
]
97+
},
98+
{
99+
"id": 7,
100+
"prompt": "I want a searchable refinement list for brand (so the user can type to filter brands) and active filter chips that show as removable pills at the top of the results.",
101+
"expected_output": "RefinementList with searchable + searchablePlaceholder configured from the type, plus CurrentRefinements wired with widget-aware classNames for the chip layout",
102+
"files": [],
103+
"expectations": [
104+
"Reads RefinementList .d.ts to confirm the searchable, searchablePlaceholder, limit, and showMore props before using them",
105+
"Adds searchable={true} and a searchablePlaceholder on RefinementList",
106+
"Uses CurrentRefinements (not a custom hook) for the active-filter chips",
107+
"Pairs CurrentRefinements with ClearRefinements where appropriate, with excludedAttributes considered when needed",
108+
"Greps installed source for ais-CurrentRefinements-* class names before writing chip CSS",
109+
"Does not rebuild a refinement list with useRefinementList when RefinementList suffices"
110+
]
111+
},
79112
{
80113
"id": 10,
81114
"prompt": "Set up my React InstantSearch project with the correct future flags so it's ready for the next major release.",
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
# Search Results Page Anti-patterns
2+
3+
These are in addition to the [library-level anti-patterns](../anti-patterns.md).
4+
5+
| Anti-pattern | Why it's wrong | What to do instead |
6+
| ------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------- |
7+
| Adding a refinement widget for an attribute not in `attributesForFaceting` | Runtime error: "facet not configured" | Confirm the index's faceting config first. Ask the user to add the attribute via `algolia-cli` if missing |
8+
| Combining `<Pagination>` and `<InfiniteHits>` on the same index | Both widgets manage page state; they fight | Pick one. `<Pagination>` for shareable URLs, `<InfiniteHits>` for catalog scroll |
9+
| Adding `<SortBy>` without verified replicas | Switching to a missing replica throws on first selection | Confirm replica index names with the user or via `algolia-cli` before adding the widget |
10+
| Skipping a no-results component | Page renders nothing when the user mistypes; looks broken | Render a no-results boundary using `useInstantSearch().results.nbHits` (see features.md) |
11+
| Putting refinement widgets outside their target `<Index>` in a multi-index page | Refinements apply to the wrong index (the parent) | Place each refinement widget inside the `<Index>` it should refine |
12+
| Using `<Configure>` to set refinement defaults the user can change | `<Configure>` is for fixed index params; user-changeable defaults belong on the refinement widget | Use the widget's default-selection prop (read its type), or set `uiState` via `routing.stateMapping` |
13+
| Hardcoding `hitsPerPage` in the URL via middleware | Conflicts with `routing` and the `<HitsPerPage>` widget | Use `<Configure hitsPerPage={N}>` for a fixed value, `<HitsPerPage>` for a user-chosen value |
14+
| Re-implementing `<Pagination>` from `useHits` + a manual page counter | Loses URL sync, accessibility, and edge-case handling (last page, single page, no results) | Use `<Pagination>`. If the rendering is custom, use `usePagination` and read its return type |
15+
| Wrapping each result column section in its own `<InstantSearch>` provider | Each provider creates a separate search instance; widgets inside don't see siblings | One provider at the page (or layout) level, multiple widgets and `<Index>` blocks inside |
16+
| Mounting the same refinement widget twice with the same `attribute` and different `limit`s | Two virtual widgets fight; only one set of params wins | Mount once. If desktop/mobile differ, render the same widget instance via CSS, not two instances |
17+
| Calling `setUiState` from `useEffect` to "preselect" refinements | Causes an extra search per mount and fights `routing` | Use the widget's default-selection prop, or `routing.stateMapping` to derive defaults from the URL |
18+
| Showing raw text for `name` / `title` on hits | Loses query highlighting; users can't see why a hit matched | Use `<Highlight attribute="name" hit={hit} />` per technology rules |
19+
| Long descriptions rendered with `<Highlight>` instead of `<Snippet>` | Renders the full text with marks; visually noisy and slow | Use `<Snippet>` and set `attributesToSnippet` in `<Configure>` (e.g., `description:30` for 30 words) |
20+
| Forgetting `routing={true}` on the provider | Search state is lost on reload; URLs are not shareable | Set `routing={true}` (or `routing={{...}}`) on `<InstantSearch>` / `<InstantSearchNext>` |
21+
| Forgetting `insights={true}` on the provider | No click/conversion analytics for results-page interactions | Set `insights={true}` and ensure hit components emit click/conversion events where relevant |
Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
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 &quot;{indexUiState.query}&quot;.</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

Comments
 (0)