Sync Your URLs with Vue InstantSearch
On this page
You’re reading the documentation for Vue InstantSearch v4. Read the migration guide to learn how to upgrade from v3 to v4. You can still find the v3 documentation for this page.
Overview
Synchronizing your UI with the browser URL is considered good practice. It allows your users to share one of your results page by copying its URL. It also improves the user experience by enabling the use of the back and next browser buttons to keep track of previous searches.
Vue InstantSearch provides the necessary API entries to let you synchronize the state of your search UI (e.g., refined widgets, current search query) with any kind of storage. This is possible via the routing
option. This guide focuses on storing the UI state in the browser URL.
Basic URLs
This guide uses the router from InstantSearch.js. Make sure you add instantsearch.js
to your project’s dependencies in addition to vue-instantsearch
.
Vue InstantSearch provides a basic way to activate the browser URL synchronization with the routing
option. You can find a live example on this sandbox.
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
<template>
<ais-instant-search
:search-client="searchClient"
index-name="instant_search"
:routing="routing"
>
<!-- add the other components here -->
</ais-instant-search>
</template>
<script>
import { history as historyRouter } from 'instantsearch.js/es/lib/routers';
import { singleIndex as singleIndexMapping } from 'instantsearch.js/es/lib/stateMappings';
export default {
data() {
return {
searchClient: algoliasearch(
'latency',
'6be0576ff61c053d5f9a3225e2a90f76'
),
routing: {
router: historyRouter(),
stateMapping: singleIndexMapping('instant_search'),
},
};
},
};
</script>
Assume the following search UI state:
- Query: “galaxy”
- Menu:
categories
: “Cell Phones”
- Refinement List:
brand
: “Apple”, “Samsung”
- Page: 2
The resulting URL in your browser URL bar will look like this:
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. However, this isn’t the most human-readable, or optimized for search engines. We’ll see in the next section how to make it more SEO-friendly.
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.
The stateMapping
defines how to go from InstantSearch’s internal state to a URL, and vice versa. You can override 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
15
16
17
18
19
20
export default {
data() {
return {
searchClient: algoliasearch(
'latency',
'6be0576ff61c053d5f9a3225e2a90f76'
),
routing: {
stateMapping: {
stateToRoute(uiState) {
// ...
},
routeToState(routeState) {
// ...
},
},
},
};
},
};
InstantSearch manages a state called uiState
. It contains information like query, facets, or the current page, including the hierarchy of the added widgets.
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
35
36
37
38
39
40
41
42
const indexName = 'instant_search';
export default {
data() {
return {
indexName,
searchClient: algoliasearch(
'latency',
'6be0576ff61c053d5f9a3225e2a90f76'
),
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
This guide uses the router from InstantSearch.js. Make sure you add instantsearch.js
to your project’s dependencies in addition to vue-instantsearch
.
URLs are more than query parameters. Another important part is the path. Manipulating the URL path is a common ecommerce pattern that allows you to better reference your page results. In this section, you’ll learn how to create this kind of URLs:
1
https://example.org/search/Cell+Phones/?query=galaxy&page=2&brands=Apple&brands=Samsung
This URL is composed of the path which now includes /search
, then /Cell+Phones
for the category. The query parameters are also simplified by only using the attribute name as key. This requires you to have only one widget per attribute.
Implementation example
Here’s an example where we’re storing the brand in the path name, and the query and page as query parameters. You can find a live example on this sandbox.
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
import { history as historyRouter } from 'instantsearch.js/es/lib/routers';
// 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 routing = {
router: historyRouter({
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
},
},
};
},
},
},
};
We are now using the history router to explicitly set options on the default router mechanism used in the first example. You can notice that we use both the router
and stateMapping
options to map uiState
to routeState
, and vice versa.
Using the routing
option as an object, we can configure:
windowTitle
: a method to map therouteState
object returned fromstateToRoute
to the window title.createURL
: a method called every time we need to create a URL. This should be done 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 the user loads or reloads the page, or clicks on the back or next buttons of the browser.
Making URLs more discoverable
In real-life applications, you might want to make specific categories easier to access by associating them with readable and memorable URLs.
Given our dataset, we can make some categories more discoverable:
- “Cameras & Camcorders” →
/Cameras
- “Car Electronics & GPS” →
/Cars
In this example, anytime users visit https://example.org/search/Cameras
, it pre-selects the “Cameras & 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
// Step 1. Add the dictionaries to convert the names and the slugs
const encodedCategories = {
Cameras: 'Cameras & Camcorders',
Cars: 'Car Electronics & GPS',
Phones: 'Cell Phones',
TV: 'TV & Home Theater',
};
const decodedCategories = Object.keys(encodedCategories).reduce((acc, key) => {
const newKey = encodedCategories[key];
const newValue = key;
return {
...acc,
[newKey]: newValue,
};
}, {});
// Step 2. 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 you can build these dictionaries from your Algolia records.
With such a solution, you have full control over what categories are discoverable via the URL.
About SEO
For your search results to be part of search engines results, you have to be selective. Trying to index too many search results pages could be considered as spam.
To do that, you can create a robots.txt
and host it at https://example.org/robots.txt
.
Here’s an example based on the URL scheme we created.
1
2
3
4
5
User-agent: *
Allow: /search/Audio/
Allow: /search/Phones/
Disallow: /search/
Allow: *
Combining with Vue Router
The previous examples were using the InstantSearch router. This is fine in almost all of the use cases, but if you plan on using Vue Router too, and also plan on reading the URL with Vue Router in the search page to show something outside of the InstantSearch life cycle, you can choose to synchronize with Vue Router. Note that this is not necessary if you are using Vue Router and are not planning to read from the URL.
The API we use of InstantSearch is the router
, but instead of using historyRouter
, a new one is written from scratch. The router
key expects an object with the following keys as value:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
const routing = {
router: {
read() {
/* read from the URL and return a routeState */
},
write(routeState) {
/* write to the URL */
},
createURL(routeState) {
/* return a URL as a string */
},
onUpdate(callback) {
/* call this callback whenever the URL changed externally */
},
dispose() {
/* remove any listeners */
},
},
};
We will fill in all these functions Vue Router. For simplicity, in this example we will not fill in a stateMapping
, and we will also synchronize towards the query string completely. The default configuration of Vue Router does not allow for deeply nested URLs, so we have to implement that first in main.js
:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import qs from 'qs';
const router = new Router({
routes: [
// ...
],
// set custom query resolver
parseQuery(query) {
return qs.parse(query);
},
stringifyQuery(query) {
const result = qs.stringify(query);
return result ? `?${result}` : '';
},
});
Then we can fill in the router
key on the routing
object in the data
function:
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
const vueRouter = this.$router; /* get this from Vue Router */
const routing = {
router: {
read() {
return vueRouter.currentRoute.query;
},
write(routeState) {
vueRouter.push({
query: routeState,
});
},
createURL(routeState) {
return vueRouter.resolve({
query: routeState,
}).href;
},
onUpdate(cb) {
if (typeof window === 'undefined') return;
this._removeAfterEach = vueRouter.afterEach(() => {
cb(this.read());
});
this._onPopState = event => {
const routeState = event.state;
// On initial load, the state is read from the URL without
// update. Therefore, the state object isn't present. In this
// case, we fallback and read the URL.
if (!routeState) {
cb(this.read());
} else {
cb(routeState);
}
};
window.addEventListener('popstate', this._onPopState);
},
dispose() {
if (typeof window === 'undefined') {
return;
}
if (this._onPopState) {
window.removeEventListener('popstate', this._onPopState);
}
if (this._removeAfterEach) {
this._removeAfterEach();
}
},
},
};
The live version of this example is also available here.
Combining with Nuxt.js
To enable routing in a Nuxt application, you can’t use the createServerRootMixin
factory as a mixin as usual, because you need to access Vue Router which is only available on the component instance.
Here’s the workaround:
- Use
createServerRootMixin
indata
, sothis.$router
is available. - Create an InstantSearch router that wraps Vue Router.
- Set up
provide
like the root mixin would otherwise do. - Set up
findResultsState
inserverPrefetch
. - Call
hydrate
inbeforeMount
.
First, set up a custom renderToString
function.
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
<template>
<ais-instant-search-ssr>
<!-- add the other components here -->
</ais-instant-search-ssr>
</template>
<script>
import { AisInstantSearchSsr, createServerRootMixin } from 'vue-instantsearch';
import algoliasearch from 'algoliasearch/lite';
import _renderToString from 'vue-server-renderer/basic';
function renderToString(app) {
return new Promise((resolve, reject) => {
_renderToString(app, (err, res) => {
if (err) reject(err);
resolve(res);
});
});
}
const searchClient = algoliasearch(
'latency',
'6be0576ff61c053d5f9a3225e2a90f76'
);
</script>
Then, you can wrap the Vue router for usage with Vue 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
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
<script>
// ...
function nuxtRouter(vueRouter) {
return {
read() {
return vueRouter.currentRoute.query;
},
write(routeState) {
// Only push a new entry if the URL changed (avoid duplicated entries in the history)
if (this.createURL(routeState) === this.createURL(this.read())) {
return;
}
vueRouter.push({
query: routeState,
});
},
createURL(routeState) {
return vueRouter.resolve({
query: routeState,
}).href;
},
onUpdate(cb) {
if (typeof window === 'undefined') return;
this._removeAfterEach = vueRouter.afterEach(() => {
cb(this.read());
});
this._onPopState = (event) => {
const routeState = event.state;
// On initial load, the state is read from the URL without
// update. Therefore, the state object isn't present. In this
// case, we fallback and read the URL.
if (!routeState) {
cb(this.read());
} else {
cb(routeState);
}
};
window.addEventListener('popstate', this._onPopState);
},
dispose() {
if (typeof window === 'undefined') {
return;
}
if (this._onPopState) {
window.removeEventListener('popstate', this._onPopState);
}
if (this._removeAfterEach) {
this._removeAfterEach();
}
},
};
}
export default {
data() {
// Create it in `data` to access the Vue Router
const mixin = createServerRootMixin({
searchClient,
indexName: 'instant_search',
routing: {
router: nuxtRouter(this.$router),
},
});
return {
...mixin.data(),
};
},
provide() {
return {
// Provide the InstantSearch instance for SSR
$_ais_ssrInstantSearchInstance: this.instantsearch,
};
},
serverPrefetch() {
return this.instantsearch
.findResultsState({ component: this, renderToString })
.then((algoliaState) => {
this.$ssrContext.nuxt.algoliaState = algoliaState;
});
},
beforeMount() {
const results =
(this.$nuxt.context && this.$nuxt.context.nuxtState.algoliaState) ||
window.__NUXT__.algoliaState;
this.instantsearch.hydrate(results);
// Remove the SSR state so it can't be applied again by mistake
delete this.$nuxt.context.nuxtState.algoliaState;
delete window.__NUXT__.algoliaState;
},
components: {
AisInstantSearchSsr,
// Add your other components here
},
};
</script>
Note that just like in Vue Router, you need to set up Nuxt’s Vue Router to write deep query strings. In Nuxt, you do this in nuxt.config.js
like this:
1
2
3
4
5
6
7
8
9
10
11
12
// nuxt.config.js
module.exports = {
router: {
parseQuery(queryString) {
return require('qs').parse(queryString);
},
stringifyQuery(object) {
var queryString = require('qs').stringify(object);
return queryString ? '?' + queryString : '';
},
},
};