Cover for article.
How to add offline search to a Gatsby blog
20th August, 2020
10 min read / 1816 words
#gatsby#react#javascript#react-hooks

Search Gif

Let's take a look at how we can add offline local search 🔍 to a Gatsby blog. There are two main types of search we can use an offline search like elasticlunr and external API search engines like ElasticSearch. These are typically more scalable but also more expensive.

You can find more info here.

In this article, I will show you how to add offline search to your Gatsby blog using elasticlunr. This means your website needs to be indexed locally and will increase the bundle size as this index needs to be loaded by the client but with the scale and size of personal blogs (100s, not 1000s of blog post) this shouldn't make a massive difference. We will also look at how we can add highlighting to our search results.

Note that you need to be careful with offline search because the entire search index has to be brought into the client, which can affect the bundle size significantly - GatsbyJS

#Setup

Before we add search Gatsby blog, let's setup a simple Gatsby site using the Gatsby blog starter, you can of course skip this step and add search to an existing site.

npm -g install gatsby-cli
gatsby new my-blog-starter https://github.com/gatsbyjs/gatsby-starter-blog

#Markdown

The search component will use the data within our markdown and index it, so that the client can search with this data later. In this example I will assume your markdown files look something like the example below:

content/blog/hello-world/index.md
---
title: Hello World
date: "2015-05-01"
tags: ["food", "duck"]
---

This is my first post on my new fake blog! How exciting!

I'm sure I'll write a lot more interesting things in the future.

...

The top part of a markdown file between the --- is known as the front matter, often we can access this data as a key/value (like a Python dictionary).

Note: In this example, we will be using the MarkdownRemark, but you can use search for anything just adjust the examples below as required.

Now onto adding search to our site.

#Elasticlunr

We will use elasticlunr for our offline/local search. Luckily there is a Gatsby plugin we can use, which makes integrating it into our site very easy. First install the following plugin and the library: yarn add @gatsby-contrib/gatsby-plugin-elasticlunr-search elasticlunr.

Then open your gatsby-config.js and add the following:

gatsby-config.js
{
    resolve: `@gatsby-contrib/gatsby-plugin-elasticlunr-search`,
    options: {
      fields: [`title`, `tags`],
      resolvers: {
        MarkdownRemark: {
          title: (node) => node.frontmatter.title,
          tags: (node) => node.frontmatter.tags,
          path: (node) => node.frontmatter.slug,
        },
      },
    },
  },

Here we are telling the search plugin what (GraphQL) fields to index. In this example, we want to index the title and tags. We could also index the content if we wanted by adding the following line after path html: (node) => node.internal.content, and adding html to the fields array. You can index any field available in GraphQL, provided by the MarkdownRemark plugin (or whichever plugin you are using).

GraphQL (Optional)

Slight aside here but if you wish to explore and take a look at the data available/provided by the MarkdownRemark plugin, you can start your Gatsby site, typically using yarn develop and once the command has finished doing it's magic 🎉, visit this page http://localhost:8000/___graphql. This provides us with our GraphQL playground (an IDE) and is a great way to understand what is going on with our GraphQL queries if you don't understand.

For example, if you type the following into the main field and press the play button at the top.

query MyQuery {
  allMarkdownRemark(
    sort: { order: DESC, fields: [frontmatter___date] }
    filter: { frontmatter: { title: { ne: "Uses" } } }
  ) {
    edges {
      node {
        id
        excerpt(pruneLength: 100)
        frontmatter {
          date(formatString: "YYYY-MM-DD")
          title
          tags
        }
      }
    }
  }
}

You should see something like (in this example):

{
  "data": {
    "allMarkdownRemark": {
      "edges": [
        {
          "node": {
            "id": "1a7e02d4-620a-5268-8149-2d8cbf26a20a",
            "excerpt": "Far far away, behind the word mountains, far from the countries Vokalia and\nConsonantia, there live…",
            "frontmatter": {
              "date": "2015-05-28",
              "title": "New Beginnings",
              "tags": ["deer", "horse"]
            }
          }
        },
        {
          "node": {
            "id": "fe83f167-8f86-51fe-a981-c5189625e270",
            "excerpt": "Wow! I love blogging so much already. Did you know that “despite its name, salted duck eggs can also…",
            "frontmatter": {
              "date": "2015-05-06",
              "title": "My Second Post!",
              "tags": ["food", "blog"]
            }
          }
        },
        {
          "node": {
            "id": "4e865c18-e797-5da8-a46d-902949a00c7f",
            "excerpt": "This is my first post on my new fake blog! How exciting! I’m sure I’ll write a lot more interesting…",
            "frontmatter": {
              "date": "2015-05-01",
              "title": "Hello World",
              "tags": ["food", "duck"]
            }
          }
        }
      ]
    }
  },
  "extensions": {}
}

As you can see this is a very familiar structure to the one we described in our search config above. If you play around with the fields on the left-hand side of the IDE, you should be able to get a better understanding of all fields you can index.

#Logic

Now we will add the relevant JSX components we need for search to our site.

#TailwindCSS (Optional)

You can follow this tutorial to add TailwindCSS. We will add TailwindCSS to this Gatsby project and we will use this to style our components. First install the following dependencies:

yarn add tailwindcss gatsby-plugin-postcss @emotion/core @emotion/styled gatsby-plugin-emotion
yarn add -D twin.macro # twin.macro allows us to use css-in-js a bit like emotion/styled-components except for tailwind
npx tailwindcss init

Then add the following to your gatsby-config.js:

gatsby-config.js
plugins: [`gatsby-plugin-postcss`, `gatsby-plugin-emotion`],

Then create a new file:

vim main.css
#...

# Contents of the file
@tailwind base;
@tailwind components;
@tailwind utilities;

# ...

Then add the following line to gatsby-browser.js:

gatsby-browser.js
import "./src/main.css";

Finally create a new file postcss.config.js and add the following:

postcss.config.js
module.exports = () => ({
  plugins: [require("tailwindcss")],
});

#Components

We will create all of the components in the following src/components folder. First, let's create the Input.jsx component for the text input, which looks something like this:

src/components/Input.jsx
import React from "react"
import tw from "twin.macro"

const Input = React.forwardRef(
  ({ className, label, onChange, placeholder = "", value }, ref) => (
    <TextInput
      ref={ref}
      aria-label={label}
      className={`bg-background text-header placeholder-main ${className}`}
      onChange={onChange}
      placeholder={placeholder}
      type="text"
      value={value}
    />
  )
)

const TextInput = tw.input`inline px-2 h-full w-full text-left inline text-lg transition duration-300`

export default Input

Since we are using twin.macro we can use syntax like const TextInput = tw.input``. Hence we can use the name TextInput. in our component, where TextInput is just an input with some tailwindcss styles we've defined.

Note we added a React forward ref so that, we can autofocus on this input later on. So when the input is shown to the client we are already focused into the input.

Next, let's create a component for SearchItem.jsx. This is a single search item found. In this case, we will only show the title and read more button. Note we are using the react-highlight-words library to highlight words from the search query.

The prop query is the search query the user typed in. In the Highlighter component the searchWords prop is given a list of words to highlight, hence we need to split the string into an array. For example, if we had the search query "A blog post", it would become ["A", "blog", "post"], and will highlight either of those words in the title (A, blog or post).

Note: Again you can extend this to include perhaps a description of the blog post (first 160 characters) etc. We are just keeping it simple for this example.

src/components/SearchItem.jsx
import { Link } from "gatsby"
import React from "react"
import Highlighter from "react-highlight-words"
import tw from "twin.macro"

const SearchItem = ({ path, title, query }) => (
  <SearchItemContainer>
    <SearchTitle>
      <Link
        className="hover:text-white hover:bg-blue-500 hover:p-1 rounded"
        to={path}
      >
        <Highlighter
          autoEscape
          highlightStyle={{ backgroundColor: "#ffd54f" }}
          searchWords={query.split(" ")}
          textToHighlight={title}
        />
      </Link>
    </SearchTitle>
    <ReadMore className="hover:text-blue-500 text-lg py-2" type="button">
      <Link to={path}>Read More</Link>
    </ReadMore>
  </SearchItemContainer>
)

const SearchItemContainer = tw.div`my-10`

const SearchTitle = tw.h2`text-2xl font-semibold`

const ReadMore = tw.button`hover:text-blue-500 text-lg py-2`

export default SearchItem

Next, we have a component we will call SearchItems.jsx, which will be a list of the search results and look something like:

src/components/SearchItems.jsx
import React from "react"

import SearchItem from "./SearchItem"

const SearchItems = ({ results, query }) => (
  <ul>
    {results.map(page => (
      <li key={page.id}>
        <SearchItem path={`${page.path}`} query={query} title={page.title} />
      </li>
    ))}
  </ul>
)

export default SearchItems

Now onto the main component, the component that will actually work out the results to show to the client. We will call this component Search.jsx:

src/components/Search.jsx
import { Index } from "elasticlunr"
import React, { useState, useEffect } from "react"
import tw from "twin.macro"

import Input from "./Input"
import SearchItems from "./SearchItems"

const Search = ({ searchIndex }) => {
  const index = Index.load(searchIndex)
  const [query, setQuery] = useState("")
  const [results, setResults] = useState([])
  const searchInput = React.createRef()

  useEffect(() => {
    searchResults("blog")
    searchInput.current.focus()
  }, [])

  function searchResults(searchQuery) {
    const res = index.search(searchQuery, { expand: true }).map(({ ref }) => {
      return index.documentStore.getDoc(ref)
    })
    setResults(res)
  }

  return (
    <SearchContainer>
      <SearchInputContainer>
        <Input
          ref={searchInput}
          className="px-2"
          label="Search"
          onChange={event => {
            const searchQuery = event.target.value
            setQuery(searchQuery)
            searchResults(searchQuery)
          }}
          placeholder="Search"
          value={query}
        />
      </SearchInputContainer>
      <SearchItems query={query} results={results} />
    </SearchContainer>
  )
}

const SearchContainer = tw.div`max-w-screen-md mx-auto pt-8`

const SearchInputContainer = tw.div`flex w-full text-left h-12 text-lg focus-within:shadow-outline my-8`

export default Search

Let's break this down:

src/components/Search.jsx
const index = Index.load(searchIndex);
const [query, setQuery] = useState("");
const [results, setResults] = useState([]);
const searchInput = React.createRef();

The first part will be used to store some variables we need later on. Like storing the current query the client has typed into the search, the current search results and a reference to the search input so we can focus into it.

src/components/Search.jsx
useEffect(() => {
  searchResults("blog");
  searchInput.current.focus();
}, []);

Next, the useEffect hook is called as soon as the component mounts, so as soon as the component mounts we will focus into the searchInput component searchInput.current.focus() and we pre-fill the search with any blog post with "blog" in it's title/tags searchResults("blog").

src/components/Search.jsx
function searchResults(searchQuery) {
  const res = index.search(searchQuery, { expand: true }).map(({ ref }) => {
    return index.documentStore.getDoc(ref);
  });
  setResults(res);
}

This is the actual function which gets our search results. It makes the query with elasticlunr and stores the results in out state hook variable result using the set function setResults(res). The first part of the function does most of the heavy lifting returning a list of possible results to show to the client.

src/components/Search.jsx
<Input
  ref={searchInput}
  className="px-2"
  label="Search"
  onChange={(e) => {
    const searchQuery = event.target.value;
    setQuery(searchQuery);
    searchResults(searchQuery);
  }}
  placeholder="Search"
  value={query}
/>

Note: e.target.query is the current value in the text input.

Finally when taking a look at the input you can see the ref={searchInput} we defined above being assigned here, so we can focus on this component. Next on any change i.e. a keypress we call the onChange function. Where we update the query with the new search query setQuery(searchQuery) again using a state hook. Then we call the searchResults(searchQuery) function which will update the results.

This is then shown to the client using our SearchItems component defined above like so: <SearchItems query={query} results={results} />.

Finally, we have a "SearchBar.tsx", this is the component we will use to tie everything together.

src/components/SearchBar.jsx
import styled from "@emotion/styled"
import { graphql, StaticQuery } from "gatsby"
import React, { useState } from "react"
import tw from "twin.macro"

import Search from "./Search"

const SearchBar = () => {
  const [showSearch, setShowSearch] = useState(false)

  function hideSearch(event) {
    if (event.target.placeholder !== "Search") {
      setShowSearch(false)
    }
  }

  return (
    <SearchComponent>
      <h1
        className="hover:cursor-pointer text-orange-800 text-2xl my-10"
        onClick={() => setShowSearch(!showSearch)}
      >
        Search
      </h1>

      <SearchOverlay
        onClick={e => hideSearch(e)}
        onKeyPress={e => hideSearch(e)}
        role="presentation"
        showSearch={showSearch}
      >
        <StaticQuery
          query={graphql`
            query SearchIndexQuery {
              siteSearchIndex {
                index
              }
            }
          `}
          render={data => (
            <SearchContainer>
              {showSearch && (
                <Search searchIndex={data.siteSearchIndex.index} />
              )}
            </SearchContainer>
          )}
        />
      </SearchOverlay>
    </SearchComponent>
  )
}

const SearchComponent = tw.div`flex-grow flex`

const SearchContainer = tw.div`overflow-y-scroll h-screen w-full`

const SearchOverlay = styled.div`
  opacity: ${props => (props.showSearch ? 1 : 0)};
  display: ${props => (props.showSearch ? "flex" : "none")};
  transition: opacity 150ms linear 0s;
  background: rgba(255, 255, 255, 0.9);
  ${tw`fixed inset-0 bg-opacity-50 z-50 m-0 items-center justify-center h-screen w-screen`};
`

export default SearchBar

Normally I would use a search icon which when pressed would show the search overlay. However to keep things simple we will just use the text "Search", which when clicked on will show our search overlay to the client.

src/components/SearchBar.jsx
<h1
  className="hover:cursor-pointer text-orange-800 text-2xl my-10"
  onClick={() => setShowSearch(!showSearch)}
>
  Search
</h1>

The main job of this component is to toggle the search on/off. To do this we use a state hook like so:

src/components/SearchBar.jsx
const [showSearch, setShowSearch] = useState(false);

function hideSearch(event) {
  if (event.target.placeholder !== "Search") {
    setShowSearch(false);
  }
}

Where we have a function to hide the search if the user clicks anything outside of the search. Hence the if statement event.target.placeholder.

src/components/SearchBar.jsx
<StaticQuery
  query={graphql`
    query SearchIndexQuery {
      siteSearchIndex {
        index
      }
    }
  `}
  render={(data) => (
    <SearchContainer>
      {showSearch && <Search searchIndex={data.siteSearchIndex.index} />}
    </SearchContainer>
  )}
/>

The next interesting part is the Graphql query to get the search index from elasticlunr. We this pass as searchIndex prop to our Search component we created above. This is the same search index we search against the current user query. We also use conditional rendering we only show the Search component when showSearch is true.

And that's it! We successfully added search to our Gatsby blog alongside search highlighting. Thanks for reading.

#Appendix

    Table of contents

  • Setup
  • Markdown
  • Search
  • Elasticlunr
  • GraphQL (Optional)
  • Logic
  • TailwindCSS (Optional)
  • Components
  • Appendix

Similar Posts

Add an 'edit post' button to your Gatsby blog

7th September, 2020

5 min read

In this article, we will look at how we can add an "edit post" button, to your Gatsby blog. When…

#gatsby#react#git#javascript

How to add your own type definitions to DefinitelyTyped

19th April, 2019

9 min read

Recently I started using TypeScript (TS) with React Native. Now I won't be going over the benefits…

© Copyright 2020, Haseeb Majid. All Rights Reserved.
Contact me at hello@haseebmajid.dev