Sync Your URLs with Angular InstantSearch
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.
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.
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
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 { liteClient as 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,
}
}
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://example.org/?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. You’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 { liteClient as 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 ui-state. 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
@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’re also composed of a path. Manipulating the URL path is a common ecommerce 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://example.org/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 you want your search to live on the path https://example.org/search
, then you have to generate a component for your search page and move your 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 { liteClient as 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://example.org/search/
. You’re ready to move on the next step: customizing the URL.
Customizing your URLs
The final URLs should look something like this:
1
https://example.org/search/Cell+Phones/?query=galaxy&page=2&brands=Apple&brands=Samsung
Here’s an example where the brand is stored in the path, 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
// 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 preceding example uses 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, you can configure:
windowTitle
: a method that maps therouteState
object returned fromstateToRoute
to the window title.-
createURL
: a method called every time you need to create a URL. When:- Synchronizing the
routeState
to the browser URL. - Rendering
<a>
tags in themenu
widget. - You call
createURL
in one of your connectors’ rendering methods.
- Synchronizing the
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 your dataset, you may be interested in making the cameras
and cars
categories more discoverable. For example:
- “Cameras and camcorders”:
/cameras
- “Car electronics and GPS”:
/cars
In this example, anytime your user visits https://example.org/search/cameras
, the “Cameras and 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://example.org/robots.txt
.
Here’s an example based on the URL scheme you created.
1
2
3
4
5
User-agent: *
Allow: /search/Audio/
Allow: /search/Phones/
Disallow: /search/
Allow: *