Guides / Building Search UI / Going further

You are reading the documentation for Angular InstantSearch v3, which is in beta. You can find the v2 documentation here.

Overview

Angular InstantSearch provides the API entries you need to synchronize the state of your search UI (like widget refinements or current search query) with your preferred backend. This is possible via the routing option of the InstantSearch widget. This guide shows you how to store your site’s UI state in the browser URL.

Synchronizing your UI with the browser URL is considered good practice. It allows your users to share one of your results pages by copying its URL. It also improves user experience by making previous searches available through the “back” and “forward” browser buttons.

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

Basic URLs

You can activate browser URL synchronization by setting the routing option of your Angular InstantSearch widget to true. 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
import algoliasearch from 'algoliasearch/lite';

@Component({
  template: `
    <ais-instantsearch [config]="config">
      <!-- more widgets -->
    </ais-instantsearch>
  `,
})
export class AppComponent {
  config = {
    indexName: 'demo_ecommerce',
    searchClient: algoliasearch('YourApplicationID', 'YourSearchOnlyAPIKey'),
    routing: true,
  }
}

Let’s consider 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://website.com/?menu[categories]=Cell Phones&refinementList[brand][0]=Apple&refinementList[brand][1]=Samsung&page=2&query=galaxy

This URL can be translated back to a search UI state. However, it can’t be easily read by a user and it isn’t optimized for search engines. We’ll see in the next section how to make URLs 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.

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

@Component({
  template: `
    <ais-instantsearch [config]="config">
      <!-- more widgets -->
    </ais-instantsearch>
  `,
})
export class AppComponent {
  config = {
    indexName: 'demo_ecommerce',
    searchClient: algoliasearch('YourApplicationID', 'YourSearchOnlyAPIKey'),
    routing: {
      router: historyRouter(),
      stateMapping: simpleMapping(),
    },
  };
}

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
21
22
23
@Component({
  template: `
    <ais-instantsearch [config]="config">
      <!-- more widgets -->
    </ais-instantsearch>
  `,
})
export class AppComponent {
  config = {
    indexName: 'demo_ecommerce',
    searchClient: algoliasearch('YourApplicationID', 'YourSearchOnlyAPIKey'),
    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: 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
{
  "your-index-name": {
    "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
@Component({
  template: `
    <ais-instantsearch [config]="config">
      <!-- more widgets -->
    </ais-instantsearch>
  `,
})
export class AppComponent {
  config = {
    indexName: 'demo_ecommerce',
    searchClient: algoliasearch('YourApplicationID', 'YourSearchOnlyAPIKey'),
    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. They are also composed of a path. Manipulating the URL path is a common e-commerce pattern that lets you better reference your page results. In this section, you’ll learn how to create cleaner URLs with cleaner paths and parameters. Example:

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

Setting up your Angular Router

In Angular, URL path manipulation is done by the Angular Router service.

For example, if we want our search to live on the path https://website.com/search, then we have to generate a component for our search page and move our InstantSearch code into it.

$
ng generate component Search
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// search/search.component.ts
import algoliasearch from 'algoliasearch/lite';

@Component({
  template: `
    <ais-instantsearch [config]="config">
      <!-- more widgets -->
    </ais-instantsearch>
  `
})
export class SearchComponent {
  config = {
    indexName: 'demo_ecommerce',
    searchClient: algoliasearch('YourApplicationID', 'YourSearchOnlyAPIKey'),
    routing: true
  };
}

Now in app.module.ts, import and configure the RouterModule.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// app.module.ts
import { NgModule } from '@angular/core';
import { BrowserModule } from '@angular/platform-browser';
import { NgAisModule } from 'angular-instantsearch';

import { RouterModule, Routes } from '@angular/router';

import { AppComponent } from './app.component';
import { SearchComponent } from "./search/search.component";

const appRoutes: Routes = [
  { path: 'search/:category', component: SearchComponent }
  { path: 'search', component: SearchComponent }
];

@NgModule({
  declarations: [AppComponent, SearchComponent],
  exports: [
    NgAisModule.forRoot(),
    BrowserModule,
    RouterModule.forRoot(appRoutes, { enableTracing: true })
  ]
})
export class AppModule {}
1
2
// app.component.html
<router-outlet></router-outlet>

Your search should now render on https://website.com/search/. We’re ready to move on the next step: customizing the URL.

Customizing your URLs

We want the final URLs to look something like this:

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

Here’s an example where the brand is stored in the path name, and the query and page number are stored as query parameters. You can find the code for this example on CodeSandbox.

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
// search/search.component.ts
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(' ');
}

/* ... */
export class SearchComponent {
  config = {
    indexName: 'demo_ecommerce',
    searchClient: algoliasearch('YourApplicationID', 'YourSearchOnlyAPIKey'),
    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(
            decodeURIComponent((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) {
          return {
            query: uiState.query,
            page: uiState.page,
            brands: uiState.refinementList && uiState.refinementList.brand,
            category: uiState.menu && uiState.menu.categories
          };
        },

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

The example above leverages instantsearch.routers.history to explicitly set the options of the default router. It uses the router and stateMapping options to map the uiState to the routeState and vice versa.

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

  • windowTitle: a method that maps the routeState object returned from stateToRoute to the window title.
  • createURL: a method called every time we need to create a URL. When:
    • we synchronize the routeState to the browser URL.
    • we render <a> tags in the menu widget.
    • you call createURL in one of your connectors’ rendering methods.
  • parseURL: a method called every time a user loads or reloads the page, or clicks on the back or next browser button.

Making URLs more discoverable

You might want to make specific categories more easily accessible by assigning them a URL that’s easier to read and to remember.

Given our dataset, we may be interested in making the cameras and cars categories more discoverable:

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

In this example, anytime your user visits https://website.com/search/cameras, the “Cameras & Camcorders” filter will be automatically selected.

This can be achieved 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 this solution in place, you have complete control over the categories that are discoverable through URLs.

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 spamming.

To prevent too many different variants of your search pages from showing up in search engines, you can create a robots.txt file and host it at https://website.com/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: *

Did you find this page helpful?