Concepts / Building Search UI / Routing URLs
Jan. 07, 2019

Routing URLs

Overview

With the routing prop, InstantSearch provides the necessary API entries to allow you to synchronize the state of your search UI (which widget were refined, what is the current search query …) with any kind of storage. And most probably you want that storage to be the browser URL bar.

Synchronizing your UI with the browser URL is a good practice. It allows any of your users to take one of your result pages, copy paste the browser URL and send it to a friend. It also allows your users to use the back and next button of their browser and always end up where they were previously.

By the end of this guide, you will be able to reproduce these examples:

Basic URLs

For a quick start, you can activate the default behavior:

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="movies"
    :routing="routing"
  >
    <!-- add the other components here -->
  </ais-instant-search>
</template>

<script>
import { history as historyRouter } from 'instantsearch.js/es/lib/routers';
import { simple as simpleMapping } from 'instantsearch.js/es/lib/stateMappings';

export default {
  data() {
    return {
      searchClient: algoliasearch(
        'latency',
        '6be0576ff61c053d5f9a3225e2a90f76'
      ),
      routing: {
        router: historyRouter(),
        stateMapping: simpleMapping(),
      }
    };
  },
};
</script>

The resulting URL in your browser URL bar will look like this:

https://website.com/?query=a&refinementList%5Bbrand%5D%5B0%5D=Drama

While not being pretty it is still very accurate: the query is a and the brand attribute, which is a refinementList, was refined (clicked) to Drama. But if you want something custom and clean, let’s move on to more user friendly URLs.

User-friendly URLs

In this part, you will be able to make URLs that map more clearly the refinements. At the end, you will get URLs that look like that:

https://website.com/?query=a&brands=Sony~Samsung&page=2

W e use the character ~ as it is one that is rarely present in data and renders well in URLs. This way your users will be able to read them more easily when shared via emails, documents, social media…

To do so, the routing option accepts a simple boolean but also more complex objects to allow customization. The first customization option you want to use is stateMapping. It allows you to define more precisely how the state of your search will be synchronized to your browser url bar (or any other router storage you might have).

Here’s an example achieving just that (and here’s the live version):

This example assumes that you have added the ais-search-box, ais-refinement-list and ais-pagination widgets to your search UI. Then the ais-refinement-list is activated on the brands attribute and that there are no values in the brands attribute which contain “~”. Please adjust given your own data.

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
import { history as historyRouter } from 'instantsearch.js/es/lib/routers';

const routing = {
  router: historyRouter(),
  stateMapping: {
    stateToRoute(uiState) {
      return {
        query: uiState.query,
        brands:
          uiState.refinementList &&
          uiState.refinementList.brand &&
          uiState.refinementList.brand.join('~'),
        page: uiState.page
      };
    },
    routeToState(routeState) {
      return {
        query: routeState.query,
        refinementList: {
          brand: routeState.brands && routeState.brands.split('~')
        },
        page: routeState.page
      };
    }
  }
};

There’s a lifecycle in which when the stateMapping functions are called:

  • stateToRoute is called whenever widgets are refined (clicked). It is also called every time any widget needs to create a URL.
  • routeToState is called whenever the user loads, reloads the page or click on back/next buttons of the browser.

To build your own mapping easily, just console.log(uiState) and see what you’re getting. Note that the object you return in stateToRoute will be the one you’ll receive as an argument in routeToState.

SEO friendly URLs

URLs are more than just query parameters (with the question mark) and one other important part is the path of the URL. In this part, you will end up with URLs that look like that:

https://website.com/search/q/phone/brands/Sony~Samsung/p/1

Be it for SEO benefits or to align your search UI urls with your current sitemap and existing url scheme.

Example of implementation

Here’s an example achieving just that (and here’s the live version):

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
import { history as historyRouter } from 'instantsearch.js/es/lib/routers';

const routing = {
  router: historyRouter({
    windowTitle(routeState) {
      return `Website / Find ${routeState.q} in ${routeState.brands} brands`;
    },
    createURL({ routeState, location }) {
      let baseUrl = location.href.split('/search/')[0];
      if (!routeState.q && routeState.brands === 'all' && routeState.p === 1) return baseUrl;
      if (baseUrl[baseUrl.length - 1] !== '/') baseUrl += '/';
      let routeStateArray = [
        'q', encodeURIComponent(routeState.q),
        'brands', encodeURIComponent(routeState.brands),
        'p', routeState.p
      ];

      return `${baseUrl}search/${routeStateArray.join('/')}`;
    },
    parseURL({ location }) {
      let routeStateString = location.href.split('/search/')[1];
      if (routeStateString === undefined) return {};
      const routeStateValues = routeStateString.match(/^q\/(.*?)\/brands\/(.*?)\/p\/(.*?)$/);
      return {
        q: decodeURIComponent(routeStateValues[1]),
        brands: decodeURIComponent(routeStateValues[2]),
        p: routeStateValues[3]
      }
    },
  }),
  stateMapping: {
    stateToRoute(uiState) {
      return {
        q: uiState.query || '',
        brands: (uiState.refinementList &&
          uiState.refinementList.brand &&
          uiState.refinementList.brand.join('~')) ||
        'all',
        p: uiState.page || 1
      };
    },
    routeToState(routeState) {
      if (routeState.brands === 'all') routeState.brands = undefined;

      return {
        query: routeState.q,
        refinementList: {brand: routeState.brands && routeState.brands.split('~')},
        page: routeState.p
      };
    }
  }
};

As you can see, we are now using the historyRouter so that we can explicitly set options on the default router mechanism used in the previous example. What we see also is that both the router and stateMapping options can be used together as a way to easily map uiState to routeState and vice versa.

Using that we can configure:

  • windowTitle: This method can be used to map the object (routeState) returned from stateToRoute to your window title
  • createURL: This method is called everytime we need to create a url. When we want to synchronize the routeState to the browser url bar, when we want to render <a href> tags in the menu widget or when you call createURL in one of your connectors’s rendering method
  • parseURL: This method is called everytime the user loads, reloads the page or click on back/next buttons of the browser

About SEO

For your search results to be part of search engines results, you will have to selectively choose them. Trying to have all of your search results inside search engines could be considered as spam by them.

To do that, you can create a robots.txt and host it at https://website.com/robots.txt.

Here’s an example one based on the url scheme we created.

1
2
3
4
5
User-agent: *
Allow: /search/q/phones/brands/Samsung/p1
Allow: /search/q/phones/brands/Apple/p1
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
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) {
      this._onPopState = event => {
        const routeState = event.state;
        // at initial load, the state is read from the URL without
        // update. Therefore the state object is not there. In this
        // case we fallback and read the URL.
        if (!routeState) {
          cb(this.read());
        } else {
          cb(routeState);
        }
      };
      window.addEventListener('popstate', this._onPopState);
    },
    dispose() {
      window.removeEventListener('popstate', this._onPopState);
      this.write();
    },
  },
};

The live version of this example is also available here.

An ideal implementation would also listen to updates from Vue Router and propagate them into InstantSearch, which isn’t done here for brevity.

Did you find this page helpful?