Integrate Algolia InstantSearch into a Vue Project
This TechBlog is based on Vue.js & Nuxt.js, using nuxt/content as a Git-based headless CMS, Tailwind CSS for styling. Today, we want to talk about full-text search integration. nuxt/content actually has a built-in search which indexes your pages/articles and does full-text lookups that are super easy to integrate. But I had quite a bad experience with it, content search delivering wrong search results (that did not even contain the query string or anything similar at all), and making it hard to extract a snippet of surrounding words/sentences of the search results.
Algolia offers a super powerful and flexible search with ready to use InstantSearch UI components for React and Vue. It was quite easy to write my own AlgoliaSearch
component for this blog, but Algolia's InstantSearch defaults were so horribly resource-hungry and it took me a while to figure out how to fine-tune this.
AlgoliaSearch Vue Component
In our AlgoliaSearch
component, we're going to use the following Vue InstantSearch UI components:
The following tutorials are great and give you in-depth instructions of how to set up such a search component:
- Create a keyboard navigable site search with Nuxt/Content and Aloglia vue-instantsearch
- How to add Algolia Search to NuxtJS
... so I try to keep it short here and just repeat the basic setup which is based on those articles. If you already have such a component set up, scroll down to the second part, how to fine-tune Algolia for a better search performance without the clutter and waste of resources.
Here's the full code: demo/nuxt-content-blog
And here's the DemoBlog (a stripped-down version of this blog, so you better understand)
The Nuxt project initially was created as follows:
$ yarn create nuxt-app algolia-vue-instantsearch
? Project name: algolia-vue-instantsearch
? Programming language: JavaScript
? Package manager: Yarn
? UI framework: Tailwind CSS
? Nuxt.js modules: Content - Git-based headless CMS
? Linting tools: ESLint, Prettier
? Testing framework: None
? Rendering mode: Universal (SSR / SSG)
? Deployment target: Static (Static/Jamstack hosting)
? Development tools: jsconfig.json (Recommended for VS Code if you're not using typescript)
? Continuous integration: None
? Version control system: Git
Install the required packages:
- algoliasearch, vue-instantsearch: Algolia Vue InstantSearch
- nuxt-content-algolia: Sync Nuxt content to Algolia index during
nuxt generate
- remove-markdown: Remove Markdown from content body text for Algolia search index.
- v-click-outside: Vue directive to react on clicks outside an element, used to hide search results.
$ yarn add algoliasearch nuxt-content-algolia vue-instantsearch
$ yarn add remove-markdown v-click-outside
Create custom plugin in plugins/vue-instantsearch.js
:
import Vue from 'vue'
import InstantSearch from 'vue-instantsearch'
Vue.use(InstantSearch)
Register custom plugin and set transpile build configuration:
export default {
// ...
plugins: [
'~/plugins/vue-instantsearch'
],
build: {
transpile: [
'vue-instantsearch',
'instantsearch.js/es',
],
},
// ...
}
Configure nuxt-content-algolia
to send index to Algolia during build (nuxt generate
):
export default {
// ...
buildModules: [
'nuxt-content-algolia',
],
nuxtContentAlgolia: {
appId: process.env.ALGOLIA_APP_ID,
// !IMPORTANT secret key should always be an environment variable
// this is not your search only key but the key that grants access to modify the index
apiKey: process.env.ALGOLIA_API_KEY,
// relative to content directory - each path get's its own index
paths: [
{
name: 'articles',
index: process.env.ALGOLIA_INDEX || 'articles',
fields: ['title', 'description', 'bodyPlainText', 'tags'],
},
],
},
// ...
}
Markdown formatting/tags are not needed for search. So, we create a bodyPlainText
using remove-markdown package, nuxt.config.js
:
export default {
// ...
hooks: {
'content:file:beforeInsert': (document) => {
if (document.extension === '.md') {
const removeMd = require('remove-markdown')
document.bodyPlainText = removeMd(document.text)
}
},
},
// ...
}
Create .env
file and set required variables - put your Algolia app ID and search-only key there:
ALGOLIA_INDEX=dev_articles
ALGOLIA_APP_ID=ABCDE12345
#ALGOLIA_API_KEY=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
ALGOLIA_SEARCH_ONLY_KEY=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
ALGOLIA_HITS_PER_PAGE=5
ALGOLIA_QUERY_BUFFER_TIME=300
NOTE: Copy
.env.example
from my demo/nuxt-content-blog to.env
to start with.WARNING: Make sure, you never store
ALGOLIA_API_KEY
(the private API key) in your git repo. Only use it in your dev environment's.env
and NEVER put it in your production.env
. If you deploy to your production host with GitLab CI/CD, you can easily setALGOLIA_API_KEY
as an environment variable only, as this is only needed by nuxt-content-algolia in build step onnuxt generate
.I also recommend setting up 2 separate indices at Algolia (same account),
dev_articles
for development andprod_articles
for production. So you would putALGOLIA_INDEX=prod_articles
into your production.env
.
The skeleton of AlgoliaSearch
Vue component should then look like this (find full component here: AlgoliaSearch.vue
):
<template>
<ais-instant-search>
<ais-configure />
<ais-autocomplete>
<div slot-scope="{ currentRefinement, indices, refine }">
<!-- search input field -->
<input @input="refine($event.currentTarget.value)" />
<!-- search results -->
<div v-if="currentRefinement.length">
<div v-for="section in indices" :key="section.objectID">
<NuxtLink v-for="(hit, index) in section.hits" :key="hit.objectID">
<ais-highlight attribute="title" :hit="hit" />
<ais-snippet attribute="bodyPlainText" :hit="hit" />
</NuxtLink>
</div>
<ais-powered-by />
</div>
</div>
</ais-autocomplete>
</ais-instant-search>
</template>
Finally, insert AlgoliaSearch
Vue component in your layout:
<template>
<header>
<AlgoliaSearch />
</header>
</template>
full code here: demo/nuxt-content-blog
InstantSearch fine-tuning
If you copied the Search component from the original article, and have signed up for an Algolia Search account, the default search experience is quite bad. Not performance-wise – Algolia keeps its promise and delivers search responses in < 30ms. But look at this waste of resources! ...
- By default, the autocomplete search field fires a
POST
request to Algolia's servers on every single keystroke! - By default, InstantSearch sends an initial request to Algolia’s servers with an empty query (see Conditional Requests). Algolia explains it with: "This connection helps speed up later requests." – But for a regular use-case, this will just increase transferred data over the network and does not improve search speed at all (the initial response just contains the last x articles, anyway).
- By default, InstantSearch responds with all attributes, also returning full article content which might be several KB per article. As Algolia even duplicates the whole content in its JSON response for highlighting, it will even double the transferred data.
- If you accidentally wrap the
ais-configure
tag around your ais-autocomplete component (which works perfectly fine and was even recommended by the original article), this would blow up your initialPOST
request again, triggering 2 requests instead of just 1. - If you add your
AlgoliaSearch
component at multiple place, e.g. in your regular screen header and in your mobile menu, that would double the initial requests to Algolia again.
I managed to fire 4 POST
requests to Algolia's servers on initial page load without even touching the search input field. Any consecutive search query fired several requests, one for each keystroke. Like this, I managed to already use up half of my free 10'000 requests/mo in the first two days of integrating the search into this blog, without even publishing it yet. And when I checked Algolia's responses in the developer console, I noticed each response is delivering over 10KB of JSON data. This is insane!
Please, Algolia, stop beeing so greedy and provide us with some sane defaults that are not so resource-hungry! Think about the climate and not just about monetizing your services!
I have managed to fine-tune it to the following:
- No more initial request getting fired to Algolia. If the visitor does not submit a search query, no request!
- Autocomplete search field is now throttling your keystrokes. It will just fire a search query after 300ms, so if you type fast enough, there will be only a single search request fired to Algolia's servers!
- Full-text content
bodyPlainText
is no longer delivered in Algolia's search response. - Full-text content
bodyPlainText
is no longer duplicated for highlighting (which anyway we don't need) in Algolia's search response.
That's how I did:
Avoid Initial Request
Algolia obtrusively makes you send an initial POST
request with response data you will probably (in most use cases) never need. There is no way to turn this off by configuration, there is only this hint here: Conditional Requests > Detecting empty search results and for a real workaround you would need to deeply dig into this sample code.
So, this is my solution:
export default {
computed: {
searchClient() {
const algoliaClient = algoliasearch(
this.$config.algoliaAppId,
this.$config.algoliaSearchOnlyKey
)
return {
...algoliaClient,
search(requests) {
if (requests.every(({ params }) => !params.query)) {
return Promise.resolve({
results: requests.map(() => ({
hits: [],
nbHits: 0,
processingTimeMS: 0,
})),
})
}
return algoliaClient.search(requests)
},
}
},
},
}
I put this into a computed function, as we want to have process.env
or this.$config
available for ALGOLIA_APP_ID
/ ALGOLIA_SEARCH_ONLY_KEY
env var lookups.
You may then pass searchClient
to the ais-instant-search component:
<template>
<ais-instant-search
:search-client="searchClient"
:index-name="$config.algoliaIndex"
:search-function="searchFunction"
>
...
Throttle Search Submission
In above snippet, you might have noticed I have provided a searchFunction
to the ais-instant-search component. This is needed to debounce the search submission so that we're not going to fire a search request on every keystroke.
Please read ais-instant-search prop search-function
API documentation. This is my solution:
export default {
methods: {
searchFunction(helper) {
if (helper.state.query) {
// debounce search queries, so that an Algolia search request is not triggered
// if another search query has overwritten the query during the same 300ms
this.$debounce(300, 'search').then(() => {
helper.search()
// ensure that search results are always shown, even if they were hidden by previous navigation to
// same route that was already active
this.showResults = true
})
} else {
// on empty search query, fire search immediately (will send local response, see computed searchClient())
// console.log('Algolia search called with query: ' + helper.state.query)
helper.search()
}
},
},
}
UPDATE 2021-09-17: I have updated above
searchFunction
to use my global debounce function which is injected in Nuxt pluginplugins/helpers.js
:plugins/helpers.jsconst debounceStack = [] const debounce = (delay, key = 'global') => { return new Promise((resolve) => { clearTimeout(debounceStack[key]) debounceStack[key] = setTimeout(resolve, delay) }) } export default (_context, inject) => { inject('debounce', debounce) }
This is a tech blog, so I guess a regular visitor types fast enough (you're a dev that types fast as hell, right?) to barely ever wait more than 300ms between two keystrokes. The search will be delayed by 300ms, but we can live with that. If you're on a paid Algolia plan and have the money for a lot of extra requests, go ahead and reduce that delay. In my demo/nuxt-content-blog codebase, you can tune this by environment variable in .env
:
ALGOLIA_QUERY_BUFFER_TIME=300
You can play around with the search field in my DemoBlog – but make sure you don't type too slow and use up all my free credits at Algolia!
Reduce Response Data
Again, Algolia by default delivers way too much data in every search response JSON. If you did not fine-tune this in Algolia's dashboard, you would get the full article bodyPlainText
content duplicated for every article in your response. We don't need those fields:
{
"results": [
{
"hits": [
{
"title": "...",
"description": "...",
"bodyPlainText": "**REMOVE_THIS**",
"tags": ["...", "..."],
"objectID": "...",
"_highlightResult": {
"bodyPlainText": {
"value": "**REMOVE_THIS**"
}
}
}
]
}
]
}
AFAIK, there is no way to configure this client-side as request param. You need to configure this in Algolia dashboard for each (dev_articles
, prod_articles
) index:
- Relevance Essentials > Searchable attributes:
title, description, bodyPlainText, tags
(here, you must includebodyPlainText
) - Search Behaviour > Retrieved attributes:
description, tags, title
(remove default*
) - Pagination and Display > Highlighting > Attributes to highlight:
description, title
(don't includebodyPlainText
)
Your JSON responses will then be below 1KB, while previously they could easily grow over 10KB for just 5 search results, depending on your article sizes.
Other Algolia Settings
I also recommend to enable the following in Algolia Dashboard:
- Search Behaviour > Advanced Syntax
That provides you: exact matching of quoted expressions and exclusion of words preceded by a “-“ sign, see advancedSyntax
API parameter
That's it. Please let me know if you have further ideas for improvement.