Concepts / Building Search UI / Routing URLs
Nov. 28, 2019

Routing URLs

Overview

React InstantSearch provides the necessary API to synchronize the state of your search UI (e.g., refined widgets, current search query) with any kind of storage. This is possible with the props available on the InstantSearch component. This guide focuses on storing the UI state in the browser URL.

Synchronizing your search UI with the browser URL is a good practice. It allows your users to share the current state of their search. It also improves the user experience by enabling the use of the back and forward buttons in the browser to navigate previous searches.

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

Before we start

API Overview

searchState

This object describes the current state of the search. It contains the states of all the widgets and turns the InstantSearch component into a controlled component.

You can learn more on the search state guide.

onSearchStateChange(nextSearchState)

This function is called every time the search state is updated.

createURL(searchState)

This function returns a string defining the URL based on all widgets generating parameters and passed down to every connector.

Routing libraries

React InstantSearch can be plugged into any history or routing library. For any solution, you need to listen for searchState changes and inject it appropriately into the InstantSearch component.

All the examples in this tutorial use react-router.

Debouncing the URL update

A good practice is to not synchronize the search state to the browser URL too often. It could degrade the browsing experience by creating too many history entries, making the user frustrated when navigating to previous and next pages. Moreover, browsers often react badly when manipulating the URL too often.

This is why we won’t update the URL at each key stroke but rather debounce the URL update at 400 milliseconds.

Basic URLs

First, we create a basic URL synchronization without URL transformations. You can find a live example on this sandbox.

The createURL method is based on the qs module. The method turns our search state into a string, which the searchStateToUrl method can then convert into a URL. The urlToSearchState does the opposite: it creates a search state from a URL.

1
2
3
4
5
6
const createURL = state => `?${qs.stringify(state)}`;

const searchStateToUrl = ({ location }, searchState) =>
  searchState ? `${location.pathname}${createURL(searchState)}` : '';

const urlToSearchState = ({ search }) => qs.parse(search.slice(1));

Now, we can use these functions in our React app.

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
import React from 'react'
import ReactDOM from 'react-dom'
import qs from 'qs'
import algoliasearch from 'algoliasearch/lite'
import {
  InstantSearch,
  Hits,
  SearchBox,
  RefinementList,
  ClearRefinements,
  Panel,
  Menu,
  Pagination
} from 'react-instantsearch-dom'

const DEBOUNCE_TIME = 400;
const searchClient = algoliasearch(
  'YourApplicationID',
  'YourAdminAPIKey'
);

/* `createUrl()`, `searchStateToUrl()` and `urlToSearchState()` go here */

const App = ({ location, history }) => {
  const [searchState, setSearchState] = useState(urlToSearchState(location));
  const [debouncedSetState, setDebouncedSetState] = useState(null);

  const onSearchStateChange = updatedSearchState => {
    clearTimeout(debouncedSetState);

    setDebouncedSetState(
      setTimeout(() => {
        history.pushState(searchStateToUrl(updatedSearchState), updatedSearchState);
      }, DEBOUNCE_TIME)
    );

    setSearchState(updatedSearchState);
  };

  return (
    <div className="container">
      <InstantSearch
        searchClient={searchClient}
        indexName="instant_search"
        searchState={searchState}
        onSearchStateChange={onSearchStateChange}
        createURL={createURL}
      >
        <div className="search-panel">
          <div className="search-panel__filters">
            <ClearRefinements />

            <Panel header="Category">
              <Menu attribute="categories" />
            </Panel>

            <Panel header="Brand">
              <RefinementList attribute="brand" />
            </Panel>
          </div>

          <div className="search-panel__results">
            <SearchBox className="searchbox" placeholder="Search" />

            <Hits />

            <div className="pagination">
              <Pagination />
            </div>
          </div>
        </div>
      </InstantSearch>
    </div>
  );
};

Assume the following search UI state:

  • Query: “galaxy”
  • Categories: “Cell Phones”
  • Refinement List:
    • brand: “Apple”, “Samsung”
  • Page: 2

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

1
https://website.com/?menu[categories]=Cell Phones&refinementList[brand][0]=Apple&refinementList[brand][1]=Samsung&page=2&query=galaxy

This URL is accurate, and can be translated back to a search UI state. However, this URL can be more SEO-friendly.

SEO-friendly URLs

URLs are not composed of only query parameters. Another important part of a URL is its path. Manipulating the URL path is a common e-commerce pattern that allows you to reference your page results better. In this section, you’ll learn how to create URLs resembling the following:

1
https://website.com/search/Cell+Phones/?query=galaxy&page=2&brands=Apple&brands=Samsung

Example of implementation

The following example stores the brand name in the path, and the query and page as query parameters. You can find a live example on this sandbox.

You have to update the createURL, searchStateToUrl and urlToSearchState methods.

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
// 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 createURL = state => {
  const isDefaultRoute =
    !state.query &&
    state.page === 1 &&
    (state.refinementList && state.refinementList.brand.length === 0) &&
    (state.menu && !state.menu.categories);

  if (isDefaultRoute) {
    return '';
  }

  const categoryPath = state.menu.categories
    ? `${getCategorySlug(state.menu.categories)}/`
    : '';
  const queryParameters = {};

  if (state.query) {
    queryParameters.query = encodeURIComponent(state.query);
  }
  if (state.page !== 1) {
    queryParameters.page = state.page;
  }
  if (state.refinementList.brand) {
    queryParameters.brands = state.refinementList.brand.map(encodeURIComponent);
  }

  const queryString = qs.stringify(queryParameters, {
    addQueryPrefix: true,
    arrayFormat: 'repeat',
  });

  return `/search/${categoryPath}${queryString}`;
};

const searchStateToUrl = searchState =>
  searchState ? createURL(searchState) : '';

const urlToSearchState = location => {
  const pathnameMatches = location.pathname.match(/search\/(.*?)\/?$/);
  const category = getCategoryName(
    (pathnameMatches && pathnameMatches[1]) || ''
  );
  const { query = '', page = 1, brands = [] } = qs.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,
    menu: {
      categories: decodeURIComponent(category),
    },
    refinementList: {
      brand: allBrands.map(decodeURIComponent),
    },
  };
};

Making URLs more discoverable

You might want to make some categories more easily accessible with a URL that’s easier to read and to remember.

Given our dataset, we can make some categories more discoverable:

  • “Cameras & Camcorders” → /Cameras
  • “Car Electronics & GPS” → /Cars
  • etc.

For example, anytime the users visits https://website.com/search/Cameras, you want to pre-select 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(' ');
}

These dictionaries can come from your Algolia records if needed.

This solution gives you full control over what categories are discoverable via the URL.

About SEO

You need to be selective about which of your search results you want to add to search engine results. Adding too many search results could be considered spamming.

To tell search engines which of your pages to index, create a robots.txt file and host it at /robots.txt on your domain.

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: *

Did you find this page helpful?