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.
npm install gatsby-plugin-algolia
Connect the API
Next on the list, connect 🔌 the plugin to your Algolia account using your API keys.
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.
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.
// Add this to the toprequire("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).
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". 🥳
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 🙌
- Algolia Search - provides the API client, and
- React InstantSearch - a React component library from Algolia to easily build search interfaces.
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.
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.
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.
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:
+ 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.