Sync your URLs with InstantSearch.js
You are currently reading the documentation for InstantSearch.js v4. Read our migration guide to learn how to upgrade from v3 to v4. You can still access the V3 documentation for this page.
Synchronizing your UI with the browser URL is a good practice. It allows your users to take one of your results pages, copy the URL, and share it. It also improves the user experience by enabling the use of the back and next browser buttons to keep track of previous searches.
InstantSearch.js provides the necessary API entries to let you synchronize the state of your search UI (your refined widgets, current search query, the uiState
) with any kind of storage. This is possible with the routing
option. This guide focuses on storing the UI state in the browser URL.
This guide goes through different ways to handle routing with your search UI:
- Enabling routing with no extra configuration
- Manually rewriting URLs to tailor it to your needs
- Crafting SEO-friendly URLs
Note that when you are using routing, you can not use initialUiState
as the two options override each other. Simple and static use cases can be more straightforward using initialUiState
, but anything dynamic or complex should use routing
.
Basic URLs
InstantSearch.js lets you enable URL synchronization by setting the routing
to true
.
1
2
3
4
5
const search = instantsearch({
searchClient,
indexName: 'instant_search',
routing: true
});
Assume the following search UI state:
- Query: “galaxy”
- Menu:
categories
: “Cell Phones”
- Refinement List:
brand
: “Apple”, “Samsung”
- Page: 2
This results in the following URL:
1
https://example.org/?instant_search[query]=galaxy&instant_search[menu][categories]=All Unlocked Cell Phones&instant_search[refinementList][brand][0]=Apple&instant_search[refinementList][brand][0]=Samsung&instant_search[page]=2
This URL is accurate, and can be translated back to a search UI state
Rewriting URLs manually
The default URLs that InstantSearch generates are comprehensive, but if you have many widgets, this can also generate noise. You may want to decide what goes in the URL and what doesn’t, or even rename the query parameters to something that makes more sense to you.
Setting routing
to true
is syntactic sugar for the following code:
All examples in this guide assume you’ve included InstantSearch.js in your web page from a CDN. If, instead, you’re using it with a package manager, adjust how you import InstantSearch.js and its widgets for more information.
1
2
3
4
5
6
7
8
const search = instantsearch({
searchClient,
indexName: 'instant_search',
routing: {
router: instantsearch.routers.history(),
stateMapping: instantsearch.stateMappings.simple(),
},
});
The stateMapping
option defines how to go from InstantSearch’s internal state to a URL, and vice versa. You can use it to rename query parameters and choose what to include in the URL.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
const search = instantsearch({
searchClient,
indexName: 'instant_search',
routing: {
stateMapping: {
stateToRoute(uiState) {
// ...
},
routeToState(routeState) {
// ...
},
},
},
});
InstantSearch manages uiState
.
It contains information about the user’s search, including the query, applied filters, the current page being viewed, and the widget hierarchy.
uiState
only stores modified widget values, not defaults.
To persist this state in the URL, InstantSearch first converts the uiState
into an object called routeState
. This routeState
then becomes a URL. Conversely, when InstantSearch reads the URL and applies it to the search, it converts routeState
into uiState
. This logic lives in two functions:
stateToRoute
: convertsuiState
torouteState
.routeToState
: convertsrouteState
touiState
.
Assume the following search UI state:
- Query: “galaxy”
- Menu:
categories
: “Cell Phones”
- Refinement List:
brand
: “Apple” and “Samsung”
- Page: 2
This translates into the following uiState
:
1
2
3
4
5
6
7
8
9
10
11
12
{
"indexName": {
"query": "galaxy",
"menu": {
"categories": "Cell Phones",
},
"refinementList": {
"brand": ["Apple", "Samsung"],
},
"page": 2,
},
}
You can implement stateToRoute
to flatten this object into a URL, and routeToState
to restore the URL into a UI state.
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
const indexName = 'instant_search';
const search = instantsearch({
searchClient,
indexName,
routing: {
stateMapping: {
stateToRoute(uiState) {
const indexUiState = uiState[indexName];
return {
q: indexUiState.query,
categories: indexUiState.menu && indexUiState.menu.categories,
brand:
indexUiState.refinementList && indexUiState.refinementList.brand,
page: indexUiState.page,
}
},
routeToState(routeState) {
return {
[indexName]: {
query: routeState.q,
menu: {
categories: routeState.categories,
},
refinementList: {
brand: routeState.brand,
},
page: routeState.page,
},
};
},
},
},
});
SEO-friendly URLs
URLs are more than query parameters. Another important part is the path. Manipulating the URL path is a common ecommerce pattern that lets you better reference your result pages.
1
https://example.org/search/Cell+Phones/?query=galaxy&page=2&brands=Apple&brands=Samsung
Example of implementation
Here’s an example that stores the brand in the path, and the query and page as query parameters.
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
// Returns a slug from the category name.
// Spaces are replaced by "+" to make
// the URL easier to read and other
// characters are encoded.
function getCategorySlug(name) {
return name
.split(' ')
.map(encodeURIComponent)
.join('+');
}
// Returns a name from the category slug.
// The "+" are replaced by spaces and other
// characters are decoded.
function getCategoryName(slug) {
return slug
.split('+')
.map(decodeURIComponent)
.join(' ');
}
const search = instantsearch({
searchClient,
indexName: 'instant_search',
routing: {
router: instantsearch.routers.history({
windowTitle({ category, query }) {
const queryTitle = query ? `Results for "${query}"` : 'Search';
if (category) {
return `${category} – ${queryTitle}`;
}
return queryTitle;
},
createURL({ qsModule, routeState, location }) {
const urlParts = location.href.match(/^(.*?)\/search/);
const baseUrl = `${urlParts ? urlParts[1] : ''}/`;
const categoryPath = routeState.category
? `${getCategorySlug(routeState.category)}/`
: '';
const queryParameters = {};
if (routeState.query) {
queryParameters.query = encodeURIComponent(routeState.query);
}
if (routeState.page !== 1) {
queryParameters.page = routeState.page;
}
if (routeState.brands) {
queryParameters.brands = routeState.brands.map(encodeURIComponent);
}
const queryString = qsModule.stringify(queryParameters, {
addQueryPrefix: true,
arrayFormat: 'repeat'
});
return `${baseUrl}search/${categoryPath}${queryString}`;
},
parseURL({ qsModule, location }) {
const pathnameMatches = location.pathname.match(/search\/(.*?)\/?$/);
const category = getCategoryName(
(pathnameMatches && pathnameMatches[1]) || ''
);
const { query = '', page, brands = [] } = qsModule.parse(
location.search.slice(1)
);
// `qs` does not return an array when there's a single value.
const allBrands = Array.isArray(brands)
? brands
: [brands].filter(Boolean);
return {
query: decodeURIComponent(query),
page,
brands: allBrands.map(decodeURIComponent),
category
};
}
}),
stateMapping: {
stateToRoute(uiState) {
const indexUiState = uiState['instant_search'] || {};
return {
query: indexUiState.query,
page: indexUiState.page,
brands: indexUiState.refinementList && indexUiState.refinementList.brand,
category: indexUiState.menu && indexUiState.menu.categories
};
},
routeToState(routeState) {
return {
instant_search: {
query: routeState.query,
page: routeState.page,
menu: {
categories: routeState.category
},
refinementList: {
brand: routeState.brands
}
}
};
}
}
}
});
You’re now using the instantsearch.routers.history
to explicitly set options on the default router mechanism. The router
and stateMapping
options are used to map uiState
to routeState
, and vice versa.
Using the routing
option as an object, you can configure:
windowTitle
: a method to map therouteState
object returned fromstateToRoute
to the window title.createURL
: a method called every time you need to create a URL. When:- You want to synchronize the
routeState
to the browser URL - You want to render
a
tags in themenu
widget - You call
createURL
in one of your connectors’ rendering methods.
- You want to synchronize the
parseURL
: a method called every time users load or reload the page, or click the browser’s back or next buttons.
Making URLs more discoverable
In real-life applications, you might want to make some categories more accessible, with a URL that’s easier to read and to remember.
Given your dataset, you can make some categories more discoverable:
- “Cameras and camcorders” →
/Cameras
- “Car electronics and GPS” →
/Cars
In this example, on arrival at https://example.org/search/Cameras
, it pre-selects the “Cameras and camcorders” filter.
You can achieve this with a dictionary.
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
// Add the dictionaries to convert the names and the slugs
const encodedCategories = {
Cameras: 'Cameras and camcorders',
Cars: 'Car electronics and GPS',
Phones: 'Phones',
TV: 'TV and home theater'
};
const decodedCategories = Object.keys(encodedCategories).reduce((acc, key) => {
const newKey = encodedCategories[key];
const newValue = key;
return {
...acc,
[newKey]: newValue
};
}, {});
// Update the getters to use the encoded/decoded values
function getCategorySlug(name) {
const encodedName = decodedCategories[name] || name;
return encodedName
.split(' ')
.map(encodeURIComponent)
.join('+');
}
function getCategoryName(slug) {
const decodedSlug = encodedCategories[slug] || slug;
return decodedSlug
.split('+')
.map(decodeURIComponent)
.join(' ');
}
Note that these dictionaries can come from your Algolia records.
With such a solution, you have full control over what categories are discoverable from the URL.
About SEO
For your search results to be part of search engines results, you have to be selective. Adding too many search results inside search engines could be considered as spam.
To do that, you can create a robots.txt
to allow or disallow URLs from being crawled by search engines.
Here’s an example based on the previously created URL scheme.
1
2
3
4
5
User-agent: *
Allow: /search/Audio/
Allow: /search/Phones/
Disallow: /search/
Allow: *
Unrelated URL parameters are removed from the URL
Applies to InstantSearch.js v3 and later.
If you enable InstantSearch routing, only the parameters coming from widgets are included in the URL. To keep other parameters unrelated to InstantSearch, add them when implementing createURL
.
For example, to keep all URL parameters that start with “utm_”, use 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
27
28
29
30
31
32
33
34
35
history({
// ... other options
parseURL({ qsModule, location }) {
return qsModule.parse(location.search.slice(1));
},
createURL({ qsModule, location, routeState }) {
const { origin, pathname, hash } = location;
const queriesFromUrl = qsModule.parse(location.search.slice(1));
// Get all parameters from the URL that are not in the InstantSearch state and that start with "utm_"
const utmQueries = Object.fromEntries(
Object.entries(queriesFromUrl).filter(
([key]) =>
!Object.keys(routeState).includes(key) &&
// Add here a condition to keep the parameters you want, for example starting with "utm_"
key.startsWith('utm_')
)
);
// Create query string with InstantSearch state and other parameters
const queryString = qsModule.stringify(
{
...routeState,
...utmQueries,
},
{
addQueryPrefix: true,
arrayFormat: 'repeat',
}
);
return `${origin}${pathname}${queryString}${hash}`;
},
});
Group facet values
Applies to InstantSearch.js v1 and later.
If you want to group, for example, “turquoise”, “ocean” and “sky” under “blue”, the recommended solution is to group them at indexing time. You can either add the group name as a separate attribute to globally filter on, or add both values in an array to make both the group and the individual value show up in the list.
For example, with the following dataset:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
[
{
"objectID": "1",
"color": "turquoise"
},
{
"objectID": "2",
"color": "ocean"
},
{
"objectID": "3",
"color": "sky"
}
]
You could create an additional attribute and use it for faceting:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
[
{
"objectID": "1",
"color": "turquoise",
"colorGroup": "blue"
},
{
"objectID": "2",
"color": "ocean",
"colorGroup": "blue"
},
{
"objectID": "3",
"color": "sky",
"colorGroup": "blue"
}
]
Or you could list the individual colors and their groups so you can use them both for faceting:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
[
{
"objectID": "1",
"color": [
"turquoise",
"blue"
]
},
{
"objectID": "2",
"color": [
"ocean",
"blue"
]
},
{
"objectID": "3",
"color": [
"sky",
"blue"
]
}
]