Integrate Autocomplete with InstantSearch.js
When you think of search experiences on sites like Amazon (ecommerce) or YouTube (media), you may notice that both sites use an autocomplete experience. It’s the autocomplete, and not just a search input, that powers the search page.
If you have an existing InstantSearch implementation, you can create a similar experience by adding Autocomplete to your InstantSearch application. Adding Autocomplete to an existing InstantSearch implementation lets you enhance the search experience and create a richer, more contextual search. You can use context from the current user and how they interacted with your site, save their recent searches, provide suggested queries, and more. This autocomplete can work as a rich search box in a search page, and a portable all-in-one search experience anywhere else on your site.
This guide shows you how to integrate Autocomplete with InstantSearch on your site.
This guide starts from a brand new InstantSearch application, but you can adapt it to integrate Autocomplete in your existing implementation.
Creating a search page with InstantSearch
First, begin with some boilerplate for the InstantSearch implementation. In a new project, create an index.html
file and add the following code:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link
rel="stylesheet"
href="https://unpkg.com/instantsearch.css@7/themes/satellite-min.css"
/>
<title>InstantSearch | Autocomplete</title>
</head>
<body>
<div class="container">
<div>
<div id="categories"></div>
</div>
<div>
<div id="searchbox"></div>
<div id="hits"></div>
<div id="pagination"></div>
</div>
</div>
<script type="module" src="app.js"></script>
</body>
</html>
The search page uses a two-column layout with categories on the left, and a search box, hits, and a pagination widget on the right.
Next, create the app.js
file to instantiate InstantSearch.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
import algoliasearch from 'algoliasearch/lite'
import instantsearch from 'instantsearch.js'
import historyRouter from 'instantsearch.js/es/lib/routers/history'
import {
searchBox,
hierarchicalMenu,
hits,
pagination,
} from 'instantsearch.js/es/widgets'
import '@algolia/autocomplete-theme-classic'
const searchClient = algoliasearch(
'latency',
'6be0576ff61c053d5f9a3225e2a90f76'
)
const INSTANT_SEARCH_INDEX_NAME = 'instant_search'
const instantSearchRouter = historyRouter()
const search = instantsearch({
searchClient,
indexName: INSTANT_SEARCH_INDEX_NAME,
routing: instantSearchRouter,
})
search.addWidgets([
searchBox({
container: '#searchbox',
placeholder: 'Search for products',
}),
hierarchicalMenu({
container: '#categories',
attributes: ['hierarchicalCategories.lvl0', 'hierarchicalCategories.lvl1'],
}),
hits({
container: '#hits',
}),
pagination({
container: '#pagination',
}),
])
search.start()
This InstantSearch implementation imports the default historyRouter
router which you want to reuse in your Autocomplete integration. This lets InstantSearch understand query parameters from the URL to derive its state.
If your Algolia index doesn’t have hierarchical attributes, you can use a regular attribute with a menu
widget.
You should now have a working InstantSearch application.
Using Autocomplete as a search box
InstantSearch ships with a searchBox
component, but it doesn’t provide autocomplete features like you see on YouTube and Amazon. Instead, you can replace the searchBox
with an autocomplete experience, using Autocomplete.
Start by removing the InstantSearch #searchbox
container and adding the Autocomplete container, named #autocomplete
here.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link
rel="stylesheet"
href="https://unpkg.com/instantsearch.css@7/themes/satellite-min.css"
/>
<title>InstantSearch | Autocomplete</title>
</head>
<body>
<header class="header">
<div id="autocomplete"></div>
</header>
<div class="container">
<div>
<div id="categories"></div>
</div>
<div>
<div id="hits"></div>
<div id="pagination"></div>
</div>
</div>
<script src="app.js"></script>
</body>
</html>
You want the Autocomplete experience to be available on all the pages of the site, not only the search page, so you can move its container to a header or navigation bar instead of the page content.
Next, you can virtualize the searchBox
from your InstantSearch implementation and instantiate Autocomplete in your app.js
file.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
import { autocomplete } from '@algolia/autocomplete-js'
import algoliasearch from 'algoliasearch/lite'
import instantsearch from 'instantsearch.js'
import historyRouter from 'instantsearch.js/es/lib/routers/history'
import { connectSearchBox } from 'instantsearch.js/es/connectors'
import { hierarchicalMenu, hits, pagination } from 'instantsearch.js/es/widgets'
import '@algolia/autocomplete-theme-classic'
const searchClient = algoliasearch(
'latency',
'6be0576ff61c053d5f9a3225e2a90f76'
)
const INSTANT_SEARCH_INDEX_NAME = 'instant_search'
const instantSearchRouter = historyRouter()
const search = instantsearch({
searchClient,
indexName: INSTANT_SEARCH_INDEX_NAME,
routing: instantSearchRouter,
})
// Mount a virtual search box to manipulate InstantSearch's `query` UI
// state parameter.
const virtualSearchBox = connectSearchBox(() => {})
search.addWidgets([
virtualSearchBox({}),
hierarchicalMenu({
container: '#categories',
attributes: ['hierarchicalCategories.lvl0', 'hierarchicalCategories.lvl1'],
}),
hits({
container: '#hits',
templates: {
item:
'<div>{{#helpers.highlight}}{ "attribute": "name" }{{/helpers.highlight}}</div>',
},
}),
pagination({
container: '#pagination',
}),
])
search.start()
// Set the InstantSearch index UI state from external events.
function setInstantSearchUiState(indexUiState) {
search.setUiState(uiState => ({
...uiState,
[INSTANT_SEARCH_INDEX_NAME]: {
...uiState[INSTANT_SEARCH_INDEX_NAME],
// We reset the page when the search state changes.
page: 1,
...indexUiState,
},
}))
}
// Return the InstantSearch index UI state.
function getInstantSearchUiState() {
const uiState = instantSearchRouter.read()
return (uiState && uiState[INSTANT_SEARCH_INDEX_NAME]) || {}
}
const searchPageState = getInstantSearchUiState()
autocomplete({
container: '#autocomplete',
placeholder: 'Search for products',
detachedMediaQuery: 'none',
initialState: {
query: searchPageState.query || '',
},
onSubmit({ state }) {
setInstantSearchUiState({ query: state.query })
},
onReset() {
setInstantSearchUiState({ query: '' })
},
onStateChange({ prevState, state }) {
if (prevState.query !== state.query) {
setInstantSearchUiState({ query: state.query })
}
},
})
This replaces the InstantSearch search box with Autocomplete, and acts exactly like before. But you can now add many more interesting features.
Adding recent searches
When you make a search on YouTube or Google and come back to the search box later on, the autocomplete displays your recent searches. This pattern lets users quickly access content by using the same path they took to find it in the first place.
Autocomplete lets you add recent searches via the @algolia/autocomplete-plugin-recent-searches
package. It exposes a createLocalStorageRecentSearchesPlugin
function to let you create a recent searches plugin.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
import { autocomplete } from '@algolia/autocomplete-js';
import { createLocalStorageRecentSearchesPlugin } from '@algolia/autocomplete-plugin-recent-searches';
// ...
// Build URLs that InstantSearch understands.
function getInstantSearchUrl(indexUiState) {
return search.createURL({ [INSTANT_SEARCH_INDEX_NAME]: indexUiState });
}
// Detect when an event is modified with a special key to let the browser
// trigger its default behavior.
function isModifierEvent(event) {
const isMiddleClick = event.button === 1;
return (
isMiddleClick ||
event.altKey ||
event.ctrlKey ||
event.metaKey ||
event.shiftKey
);
}
function onSelect({ setIsOpen, setQuery, event, query }) {
// You want to trigger the default browser behavior if the event is modified.
if (isModifierEvent(event)) {
return;
}
setQuery(query);
setIsOpen(false);
setInstantSearchUiState({ query });
}
function getItemUrl({ query }) {
return getInstantSearchUrl({ query });
}
function createItemWrapperTemplate({ children, query, html }) {
const uiState = { query };
return html`<a
class="aa-ItemLink"
href="${getInstantSearchUrl(uiState)}"
onClick="${(event) => {
if (!isModifierEvent(event)) {
// Bypass the original link behavior if there's no event modifier
// to set the InstantSearch UI state without reloading the page.
event.preventDefault();
}
}}"
>
${children}
</a>`;
}
const recentSearchesPlugin = createLocalStorageRecentSearchesPlugin({
key: 'instantsearch',
limit: 3,
transformSource({ source }) {
return {
...source,
getItemUrl({ item }) {
return getItemUrl({
query: item.label,
});
},
onSelect({ setIsOpen, setQuery, item, event }) {
onSelect({
setQuery,
setIsOpen,
event,
query: item.label,
});
},
// Update the default `item` template to wrap it with a link
// and plug it to the InstantSearch router.
templates: {
...source.templates,
item(params) {
const { children } = source.templates.item(params).props;
return createItemWrapperTemplate({
query: params.item.label,
children,
html: params.html,
});
},
},
};
},
});
autocomplete({
// You want recent searches to appear with an empty query.
openOnFocus: true,
// Add the recent searches plugin.
plugins: [recentSearchesPlugin],
// ...
});
Note that some of this code is abstracted away in the following sections to simplify the examples.
Since the recentSearchesPlugin
reads from localStorage
, you can’t see any recent searches until you perform at least one query. To submit a search, make sure to press Enter on the query. Once you do, you’ll see it appear as a recent search.
Adding Query Suggestions
The most typical pattern you can see on every autocomplete is suggestions. They’re predictions of queries that match what the user is currently typing, and that guarantee to return results. For example, when typing “how to” in Google, the search engine suggests matching suggestions for the user to complete their query. It’s especially useful on mobile, where typing is harder than on a physical keyboard.
Autocomplete lets you add Query Suggestions with the @algolia/autocomplete-plugin-query-suggestions
package. It exposes a createQuerySuggestionsPlugin
function to let you create a Query Suggestions plugin.
This plugin requires a Query Suggestions index.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
// ...
import { createQuerySuggestionsPlugin } from '@algolia/autocomplete-plugin-query-suggestions';
// ...
const querySuggestionsPlugin = createQuerySuggestionsPlugin({
searchClient,
indexName: 'instant_search_demo_query_suggestions',
getSearchParams() {
// This creates a shared `hitsPerPage` value once the duplicates
// between recent searches and Query Suggestions are removed.
return recentSearchesPlugin.data.getAlgoliaSearchParams({
hitsPerPage: 6,
});
},
transformSource({ source }) {
return {
...source,
sourceId: 'querySuggestionsPlugin',
getItemUrl({ item }) {
return getItemUrl({
query: item.query,
});
},
onSelect({ setIsOpen, setQuery, event, item }) {
onSelect({
setQuery,
setIsOpen,
event,
query: item.query,
});
},
getItems(params) {
// We don't display Query Suggestions when there's no query.
if (!params.state.query) {
return [];
}
return source.getItems(params);
},
templates: {
...source.templates,
item(params) {
const { children } = source.templates.item(params).props;
return createItemWrapperTemplate({
query: params.item.label,
children,
html: params.html,
});
},
},
};
},
});
autocomplete({
// ...
// Add the recent searches and Query Suggestions plugins.
plugins: [recentSearchesPlugin, querySuggestionsPlugin],
});
Debouncing search results
Having two sets of results update as you type generates many UI flashes. This is distracting to the user, because two distinct sections of the page are competing for their attention.
You can mitigate this problem by debouncing search results.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
function debounce(fn, time) {
let timerId = undefined
return function(...args) {
if (timerId) {
clearTimeout(timerId)
}
timerId = setTimeout(() => fn(...args), time)
}
}
const debouncedSetInstantSearchUiState = debounce(setInstantSearchUiState, 500)
autocomplete({
// ...
onStateChange({ prevState, state }) {
if (prevState.query !== state.query) {
debouncedSetInstantSearchUiState({ query: state.query })
}
},
})
Supporting categories in Query Suggestions
A key feature to Autocomplete is to pre-configure your InstantSearch page. The Query Suggestions plugin supports categories that you can leverage to refine both the query and the category in a single interaction. This pattern brings users to the right category without interacting with the hierarchicalMenu
widget, only with the autocomplete.
First, you need to support categories in the helpers you created at the beginning.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
function onSelect({ setIsOpen, setQuery, event, query, category }) {
// You want to trigger the default browser behavior if the event is modified.
if (isModifierEvent(event)) {
return;
}
setQuery(query);
setIsOpen(false);
setInstantSearchUiState({
query,
hierarchicalMenu: {
[INSTANT_SEARCH_HIERARCHICAL_ATTRIBUTE]: [category],
},
});
}
function getItemUrl({ query, category }) {
return getInstantSearchUrl({
query,
hierarchicalMenu: {
[INSTANT_SEARCH_HIERARCHICAL_ATTRIBUTE]: [category],
},
});
}
function createItemWrapperTemplate({ children, query, category, html }) {
const uiState = {
query,
hierarchicalMenu: {
[INSTANT_SEARCH_HIERARCHICAL_ATTRIBUTE]: [category],
},
};
return html`<a
class="aa-ItemLink"
href="${getInstantSearchUrl(uiState)}"
onClick="${(event) => {
if (!isModifierEvent(event)) {
// Bypass the original link behavior if there's no event modifier
// to set the InstantSearch UI state without reloading the page.
event.preventDefault();
}
}}"
>
${children}
</a>`;
}
Then, you can update the plugins to forward the category to these helpers.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
const recentSearchesPlugin = createLocalStorageRecentSearchesPlugin({
key: 'instantsearch',
limit: 3,
transformSource({ source }) {
return {
...source,
getItemUrl({ item }) {
return getItemUrl({
query: item.label,
category: item.category,
});
},
onSelect({ setIsOpen, setQuery, item, event }) {
onSelect({
setQuery,
setIsOpen,
event,
query: item.label,
category: item.category,
});
},
templates: {
...source.templates,
// Update the default `item` template to wrap it with a link
// and plug it to the InstantSearch router.
item(params) {
const { children } = source.templates.item(params).props;
return createItemWrapperTemplate({
query: params.item.label,
category: params.item.category,
children,
html: params.html,
});
},
},
};
},
});
const querySuggestionsPlugin = createQuerySuggestionsPlugin({
searchClient,
indexName: 'instant_search_demo_query_suggestions',
getSearchParams() {
// This creates a shared `hitsPerPage` value once the duplicates
// between recent searches and Query Suggestions are removed.
return recentSearchesPlugin.data.getAlgoliaSearchParams({
hitsPerPage: 6,
});
},
// Add categories to the suggestions.
categoryAttribute: [
INSTANT_SEARCH_INDEX_NAME,
'facets',
'exact_matches',
INSTANT_SEARCH_HIERARCHICAL_ATTRIBUTE,
],
transformSource({ source }) {
return {
...source,
sourceId: 'querySuggestionsPlugin',
getItemUrl({ item }) {
return getItemUrl({
query: item.query,
category: item.__autocomplete_qsCategory,
});
},
onSelect({ setIsOpen, setQuery, event, item }) {
onSelect({
setQuery,
setIsOpen,
event,
query: item.query,
category: item.__autocomplete_qsCategory,
});
},
getItems(params) {
if (!params.state.query) {
return [];
}
return source.getItems(params);
},
templates: {
...source.templates,
item(params) {
const { children } = source.templates.item(params).props;
return createItemWrapperTemplate({
query: params.item.label,
category: params.item.__autocomplete_qsCategory,
children,
html: params.html,
});
},
},
};
},
});
Finally, you can implement the onReset
function on your Autocomplete instance to also reset the InstantSearch category.
1
2
3
4
5
6
7
8
9
10
11
12
autocomplete({
// ...
onReset() {
setInstantSearchUiState({
query: '',
hierarchicalMenu: {
[INSTANT_SEARCH_HIERARCHICAL_ATTRIBUTE]: [],
},
})
},
// ...
})
Adding contextual Query Suggestions
For an even richer Autocomplete experience, you can pick up the currently active InstantSearch category and provide suggestions for both this specific category and others. This pattern lets you reduce the scope of the search to the current category, like an actual department store, or broaden the suggestions to get out of the current category.
First, make sure to set your category attribute as a facet in your Query Suggestions index. In this demo, the attribute to facet is instant_search.facets.exact_matches.hierarchicalCategories.lvl0.value
.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
// ...
// Get the current category from InstantSearch.
function getInstantSearchCurrentCategory() {
const indexRenderState = search.renderState[INSTANT_SEARCH_INDEX_NAME];
const hierarchicalMenuUiState =
indexRenderState && indexRenderState.hierarchicalMenu;
const categories =
(hierarchicalMenuUiState &&
hierarchicalMenuUiState[INSTANT_SEARCH_HIERARCHICAL_ATTRIBUTE] &&
hierarchicalMenuUiState[INSTANT_SEARCH_HIERARCHICAL_ATTRIBUTE].items) ||
[];
const refinedCategory = categories.find((category) => category.isRefined);
return refinedCategory && refinedCategory.value;
}
// Query Suggestions plugin for the current category.
const querySuggestionsPluginInCategory = createQuerySuggestionsPlugin({
searchClient,
indexName: 'instant_search_demo_query_suggestions',
getSearchParams() {
const currentCategory = getInstantSearchCurrentCategory();
return recentSearchesPlugin.data.getAlgoliaSearchParams({
hitsPerPage: 3,
facetFilters: [
`${INSTANT_SEARCH_INDEX_NAME}.facets.exact_matches.${INSTANT_SEARCH_HIERARCHICAL_ATTRIBUTE}.value:${currentCategory}`,
],
});
},
transformSource({ source }) {
const currentCategory = getInstantSearchCurrentCategory();
return {
...source,
sourceId: 'querySuggestionsPluginInCategory',
getItemUrl({ item }) {
return getItemUrl({
query: item.query,
category: currentCategory,
});
},
onSelect({ setIsOpen, setQuery, event, item }) {
onSelect({
setQuery,
setIsOpen,
event,
query: item.query,
category: currentCategory,
});
},
getItems(params) {
if (!currentCategory) {
return [];
}
return source.getItems(params);
},
templates: {
...source.templates,
header({ items, html }) {
if (items.length === 0) {
return null;
}
return html`<span class="aa-SourceHeaderTitle"
>In ${currentCategory}</span
>
<div class="aa-SourceHeaderLine" />`;
},
item(params) {
const { children, html } = source.templates.item(params).props;
return createItemWrapperTemplate({
query: params.item.query,
category: currentCategory,
children,
html,
});
},
},
};
},
});
// Query Suggestions plugin for the other categories.
const querySuggestionsPlugin = createQuerySuggestionsPlugin({
// ...
getSearchParams() {
const currentCategory = getInstantSearchCurrentCategory();
if (!currentCategory) {
return recentSearchesPlugin.data.getAlgoliaSearchParams({
hitsPerPage: 6,
});
}
return recentSearchesPlugin.data.getAlgoliaSearchParams({
hitsPerPage: 3,
facetFilters: [
`${INSTANT_SEARCH_INDEX_NAME}.facets.exact_matches.${INSTANT_SEARCH_HIERARCHICAL_ATTRIBUTE}.value:-${currentCategory}`,
],
});
},
transformSource({ source }) {
const currentCategory = getInstantSearchCurrentCategory();
return {
...source,
// ...
templates: {
...source.templates,
header({ items, html }) {
if (!currentCategory || items.length === 0) {
return null;
}
return html`<span class="aa-SourceHeaderTitle"
>In other categories</span
>
<div class="aa-SourceHeaderLine" />`;
},
// ...
},
};
},
});
Next steps
Autocomplete is now the main user interaction point driving InstantSearch to refine search results. From now on, you’re leveraging the complete Autocomplete ecosystem to bring a state-of-the-art search experience for desktop and mobile.
You can now add Autocomplete everywhere on your site and redirect users to the search page whenever they submit a search, or after they select a suggestion. You can also use context from the current page to personalize the autocomplete experience. For example, you could display a preview of matching results in a panel for each suggestion, and let InstantSearch provide these results once on the search page.