Integrate Algolia InstantSearch into a Vue Project

August 30th, 2021 by Philip Iezzi 11 min read
cover image

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:

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

$ yarn add algoliasearch nuxt-content-algolia vue-instantsearch
$ yarn add remove-markdown v-click-outside

Create custom plugin in plugins/vue-instantsearch.js:

plugins/vue-instantsearch.js
import Vue from 'vue'
import InstantSearch from 'vue-instantsearch'

Vue.use(InstantSearch)

Register custom plugin and set transpile build configuration:

nuxt.config.js
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):

nuxt.config.js
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:

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 set ALGOLIA_API_KEY as an environment variable only, as this is only needed by nuxt-content-algolia in build step on nuxt generate.

I also recommend setting up 2 separate indices at Algolia (same account), dev_articles for development and prod_articles for production. So you would put ALGOLIA_INDEX=prod_articles into your production .env.

The skeleton of AlgoliaSearch Vue component should then look like this (find full component here: AlgoliaSearch.vue):

components/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:

layouts/default.vue
<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 initial POST 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:

AlgoliaSearch.vue
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:

AlgoliaSearch.vue
<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:

AlgoliaSearch.vue
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 plugin plugins/helpers.js:

plugins/helpers.js
const 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:

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

JSON response
{
    "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 include bodyPlainText)
  • Search Behaviour > Retrieved attributes: description, tags, title (remove default *)
  • Pagination and Display > Highlighting > Attributes to highlight: description, title (don't include bodyPlainText)

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.