Adding Search Functionality To A Gatsby Site

React

17/06/2021


In this article I'll show you how I added search functionality to my Gatsby site. I decided to implement this using an API-based search engine, namely Algolia, for several reasons.

  • Opting for a client-side solution will eventually bloat the bundle size of your site as content increases,
  • Algolia offers a generous free tier and has great documentation for ReactJS/Gatsby, and
  • If I ever use up the free tier (which is a great problem to have 🤩), I can easily opt for a more affordable solution, such as a self-hosted Elasticsearch instance.

Adding Algolia

There's a handy-dandy Gatsby plugin for Algolia that will take care of the indexing of your pages. Indexing simple means creating a search-friendly list.

By default, the plugin uses the frontmatter field title from your Markdown files. If it's named differently in your site, you will need to configure the Algolia query, accordingly.

Account

First of all, make sure you have created an Algolia account.

Installation

Then, install the plugin in your project.

BASH
npm install gatsby-plugin-algolia

Connect the API

Next on the list, connect 🔌 the plugin to your Algolia account using your API keys.

API Keys dashboard in Algolia

API Keys dashboard in Algolia

Copy the values of Application ID, Search-Only API Key and Admin API Key, and paste them into a .env file at the root of your project. Of course, ⚠️ don't commit this file to your repository.

.ENV
GATSBY_ALGOLIA_APP_ID=<App ID>
GATSBY_ALGOLIA_SEARCH_KEY=<Search-Only API Key>
ALGOLIA_ADMIN_KEY=<Admin API Key>

Configure the plugin

While we have installed the plugin, Gatsby isn't aware of it yet. Therefore, in gatsby-config.js, make the following changes.

JAVASCRIPT
// Add this to the top
require("dotenv").config()
// ...
plugins: [
// ... existing plugins
{
resolve: `gatsby-plugin-algolia`,
options: {
appId: process.env.GATSBY_ALGOLIA_APP_ID,
apiKey: process.env.ALGOLIA_ADMIN_KEY,
queries: require("./src/utils/algolia-queries")
},
}
],
}
// ...

The dotenv library, which is already installed in Gatsby, will make the variables from our .env file accessible in our configuration file.

Configure the query

The plugin also needs to know what content on your site is "searchable", which will be done using a GraphQL query. I've decided to define it in a separate file to avoid clutter 😌, though you may do so directly in the queries property if you wish to.

Most likely, you will also need to adjust the query and change pagePath (i.e. location of your Markdown files).

JAVASCRIPT
const escapeStringRegexp = require("escape-string-regexp")
const pagePath = `_posts`
const indexName = `Pages`
const pageQuery = `{
pages: allMarkdownRemark(
filter: {
fileAbsolutePath: { regex: "/${escapeStringRegexp(pagePath)}/" },
}
) {
edges {
node {
id
frontmatter {
title
tags
}
fields {
slug
}
excerpt(pruneLength: 5000)
}
}
}
}`
function pageToAlgoliaRecord({ node: { id, frontmatter, fields, ...rest } }) {
return {
objectID: id,
...frontmatter,
...fields,
...rest,
}
}
const queries = [
{
query: pageQuery,
transformer: ({ data }) => data.pages.edges.map(pageToAlgoliaRecord),
indexName,
settings: { attributesToSnippet: [`excerpt:20`] },
},
]
module.exports = queries

After you run gatsby build, you should then see your pages indexed in your Algolia account under "Indices". 🥳

Indices page of an Algolia app

Indices page in Algolia

Adding the UI

As the user interface heavily depends on your website and design preferences, I won't go into too much detail about it. Rather, I'll try to provide an overview of the logic behind it.

Dependencies

To interact with the Algolia API from the UI, I'll be using 🙌

BASH
npm install react-instantsearch-dom algoliasearch

The input field

The "search box", as Algolia calls it, is nothing more than a form with an input field. The connectSearchBox function takes care of all the interaction between the search query and on your indices.

JSX
import React from "react"
import { connectSearchBox } from "react-instantsearch-dom"
export default connectSearchBox(
({ refine, currentRefinement, className, onFocus }) => (
<form className={className}>
<input
className="SearchInput"
type="text"
placeholder="Search"
aria-label="Search"
onChange={e => refine(e.target.value)}
value={currentRefinement}
onFocus={onFocus}
/>
</form>
)
)

The input value is exposed by the variable currentRefinement, while the refine function allows you to modify it.

Instead of creating a custom SearchBox, you can alternatively use a pre-made widget from the library.

Search results

The search results are displayed as a simple list 📃 of articles that feature a title and a snippet. Again, react-instantsearch-dom helps us out with most of the heavy lifting.

JSX
import React from "react"
import { Link } from "gatsby"
import {
Highlight,
Hits,
Index,
Snippet,
PoweredBy,
} from "react-instantsearch-dom"
const PageHit = ({ hit }) => (
<div>
<Link to={hit.slug}>
<h4>
<Highlight attribute="title" hit={hit} tagName="mark" />
</h4>
</Link>
<Snippet attribute="excerpt" hit={hit} tagName="mark" />
</div>
)
const HitsInIndex = ({ index }) => (
<Index indexName={index.name}>
<Hits className="Hits" hitComponent={PageHit} />
</Index>
)
const SearchResults = ({ indices, className, show }) => (
<div className={className} style={!show ? { display: 'none' } : {}}>
{indices.map(index => (
<HitsInIndex index={index} key={index.name} />
))}
<PoweredBy />
</div>
)
export default SearchResults

By default, your entire search index will be shown, i.e. all your indexed pages. Thus, we hide everything using show until the user types in something, which in turn filters the results.

If you're using the free tier of Algolia, you're required to use the PoweredBy widget.

Similar to before, you can take advantage of a pre-made widget instead of creating a custom SearchResults component.

Creating a search component

Almost there, I promise! 😝 After creating the search box and search results, place them in a common Search component.

It will keep track of the search value, whether the input field is in focus as well as hook up everything to the client API of Algolia.

JSX
import React, { useState } from "react"
import algoliasearch from "algoliasearch/lite"
import { InstantSearch } from "react-instantsearch-dom"
import SearchBox from "./SearchBox"
import SearchResults from "./SearchResults"
export default function Search({ indices }) {
const [query, setQuery] = useState()
const [hasFocus, setFocus] = useState(false)
const searchClient = algoliasearch(
process.env.GATSBY_ALGOLIA_APP_ID,
process.env.GATSBY_ALGOLIA_SEARCH_KEY
)
return (
<InstantSearch
searchClient={searchClient}
indexName={indices[0].name}
onSearchStateChange={({ query }) => setQuery(query)}
>
<SearchBox onFocus={() => setFocus(true)} hasFocus={hasFocus} />
<SearchResults
show={query && query.length > 0 && hasFocus}
indices={indices}
/>
</InstantSearch>
)
}

Search in action

To finally use the search functionality, in a page of your choice, insert the following lines:

DIFF
+ import Search from "./search"
+ const searchIndices = [{ name: `Pages`, title: `Pages` }]
const SomePage = () => (
+ <Search indices={searchIndices} />
)

searchIndices specifies which indices Algolia should use from your account. If you followed my earlier instructions to the teeth 🦷, you can leave it as is.


WRITTEN BY

Code and stuff