Guides / Building Search UI / Going further

Sync your URLs with React InstantSearch

This is the React InstantSearch v7 documentation. React InstantSearch v7 is the latest version of React InstantSearch and the stable version of React InstantSearch Hooks.

If you were using React InstantSearch v6, you can upgrade to v7.

If you were using React InstantSearch Hooks, you can still use the React InstantSearch v7 documentation, but you should check the upgrade guide for necessary changes.

If you want to keep using React InstantSearch v6, you can find the archived documentation.

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.

React InstantSearch 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

If you are doing server-side rendering, you will have to override the default router as getLocation attempts to access to window.location which is not available on the server. Please follow the server-side rendering guide to learn more. If you are using Next.js, a specific package is available to create a compatible router : createInstantSearchRouterNext()

Basic URLs

React InstantSearch lets you enable URL synchronization by setting the routing to true.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import { InstantSearch } from 'react-instantsearch';
import algoliasearch from 'algoliasearch/lite';

const searchClient = algoliasearch('YourApplicationID', 'YourSearchOnlyAPIKey');

function App() {
  return (
    <InstantSearch
      searchClient={searchClient}
      indexName="instant_search"
      routing={true}
    >
      {/* ... */}
    </InstantSearch>
  );
}

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:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import { InstantSearch } from 'react-instantsearch';
import { history } from 'instantsearch.js/es/lib/routers';
import { simple } from 'instantsearch.js/es/lib/stateMappings';
import algoliasearch from 'algoliasearch/lite';

const searchClient = algoliasearch('YourApplicationID', 'YourSearchOnlyAPIKey');

const routing = {
  router: history(),
  stateMapping: simple(),
};

function App() {
  return (
    <InstantSearch
      searchClient={searchClient}
      indexName="instant_search"
      routing={routing}
    >
      {/* ... */}
    </InstantSearch>
  );
}

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
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
import { InstantSearch } from 'react-instantsearch';
import { history } from 'instantsearch.js/es/lib/routers';
import algoliasearch from 'algoliasearch/lite';

const searchClient = algoliasearch('YourApplicationID', 'YourSearchOnlyAPIKey');

const routing = {
  router: history(),
  stateMapping: {
    stateToRoute(uiState) {
      // ...
    },
    routeToState(routeState) {
      // ...
    },
  },
};

function App() {
  return (
    <InstantSearch
      searchClient={searchClient}
      indexName="instant_search"
      routing={routing}
    >
      {/* ... */}
    </InstantSearch>
  );
}

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: converts uiState to routeState.
  • routeToState: converts routeState to uiState.

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
43
44
45
46
47
48
import { InstantSearch } from 'react-instantsearch';
import { history } from 'instantsearch.js/es/lib/routers';
import algoliasearch from 'algoliasearch/lite';

const searchClient = algoliasearch('YourApplicationID', 'YourSearchOnlyAPIKey');

const indexName = 'instant_search';

const routing = {
  router: history(),
  stateMapping: {
    stateToRoute(uiState) {
      const indexUiState = uiState[indexName];
      return {
        q: indexUiState.query,
        categories: indexUiState.menu?.categories,
        brand: indexUiState.refinementList?.brand,
        page: indexUiState.page,
      };
    },
    routeToState(routeState) {
      return {
        [indexName]: {
          query: routeState.q,
          menu: {
            categories: routeState.categories,
          },
          refinementList: {
            brand: routeState.brand,
          },
          page: routeState.page,
        },
      };
    },
  },
};

function App() {
  return (
    <InstantSearch
      searchClient={searchClient}
      indexName={indexName}
      routing={routing}
    >
      {/* ... */}
    </InstantSearch>
  );
}

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
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
import { InstantSearch } from 'react-instantsearch';
import { history } from 'instantsearch.js/es/lib/routers';
import algoliasearch from 'algoliasearch/lite';

const searchClient = algoliasearch('YourApplicationID', 'YourSearchOnlyAPIKey');

// 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: 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?.[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?.brand,
        category: indexUiState.menu?.categories,
      };
    },

    routeToState(routeState) {
      return {
        instant_search: {
          query: routeState.query,
          page: routeState.page,
          menu: {
            categories: routeState.category,
          },
          refinementList: {
            brand: routeState.brands,
          },
        },
      };
    },
  },
};

function App() {
  return (
    <InstantSearch
      searchClient={searchClient}
      indexName={indexName}
      routing={routing}
    >
      {/* ... */}
    </InstantSearch>
  );
}

You’re now using the history router to explicitly set options on the default router mechanism. Notice the usage of both the router and stateMapping options to map uiState to routeState, and vice versa.

Using the routing option as an object, you can configure:

  • windowTitle: a method to map the routeState object returned from stateToRoute 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 the menu widget
    • You call createURL in one of your connectors’ rendering methods.
  • 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 easily 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: *

Next steps

You now have a good starting point to create an even more dynamic experience with React InstantSearch. Next up, you could improve this app by:

Did you find this page helpful?