DEV Community

loading...
Cover image for How to add search to your Gatsby site

How to add search to your Gatsby site

emma profile image Emma Goto πŸ™ Originally published at emgoto.com on ・6 min read

A search bar is a great way to make content on your Gatsby site discoverable. In this tutorial, I'll be walking you through how to add local search to Gatsby with FlexSearch.

I'll be basing the code off Gatsby's official starter blog template, gatsby-starter-blog.
We'll also be using a React search bar component I built in a previous post.

At the end of the tutorial, you will have a search bar that allows readers to search through your content:

GIF of posts being filtered as user types query in search box

Choosing a search library for Gatsby

Do you need a search library? Not always. It is possible to write a filter that finds partial matches based off post titles.
But if you have a lot of posts, or you want to search off many fields, a search library may be for you.

There are quite a few JavaScript search libraries out there that you can use.
I chose FlexSearch due to its ease of setup. It also claims to be the fastest search library. Sounds pretty good to me!

Add a search bar component to your Gatsby site

We'll be putting our search bar on the home page.

The home page uses a GraphQL page query to grab a list of all the posts, and then loops through and renders a link out to each post.

// src/pages/index.js
import React from 'react';
import PostLink from '../components/post-link';

export default ({
    data: {
        allMarkdownRemark: { nodes },
    },
}) => {
    const posts = nodes;

    return (
        <div>
            <h1>Blog</h1>
            {posts.map(post =>
                // PostLink will be a component that renders a summary of your post
                // e.g. the title, date and an excerpt
                <PostLink post={post} />
            )}
        </div>
    );
};

export const pageQuery = graphql`
  query {
    allMarkdownRemark(sort: { fields: [frontmatter___date], order: DESC }) {
      nodes {
        excerpt
        fields {
          slug
        }
        frontmatter {
          date(formatString: "MMMM DD, YYYY")
          title
        }
      }
    }
  }
`
Enter fullscreen mode Exit fullscreen mode

Create a separate search.js file to store your search bar component:

// src/components/search.js
import React from 'react';

const SearchBar = ({ searchQuery, setSearchQuery }) => (
    <form
        action="/"
        method="get"
        autoComplete="off"
    >
        <label htmlFor="header-search">
            <span className="visually-hidden">
                Search blog posts
            </span>
        </label>
        <input
            value={searchQuery}
            onInput={(e) => setSearchQuery(e.target.value)}
            type="text"
            id="header-search"
            placeholder="Search blog posts"
            name="s"
        />
        <button type="submit">Search</button>
    </form>
);
Enter fullscreen mode Exit fullscreen mode

As well as some CSS to hide our screen reader-friendly label:

// src/pages/index.css
.visually-hidden {
    clip: rect(0 0 0 0);
    clip-path: inset(50%);
    height: 1px;
    overflow: hidden;
    position: absolute;
    white-space: nowrap;
    width: 1px;
}
Enter fullscreen mode Exit fullscreen mode

I've written a separate post going into detail on how to create an accessible search bar component.

Then on our home page we can add this new component:

// src/pages/index.js
import React from 'react';
import Search from '../components/search';
import './index.css';

export default ({
    data: {
        allMarkdownRemark: { nodes },
    },
}) => {
    const { search } = window.location;
    const query = new URLSearchParams(search).get('s')
    const [searchQuery, setSearchQuery] = useState(query || '');

    const posts = nodes;

   return (
        <div>
            <h1>Blog</h1>
            <SearchBar
                searchQuery={searchQuery}
                setSearchQuery={setSearchQuery}
            />
            {posts.map(post => (
                <PostLink post={post} />
            ))}
        </div>
    );
};
Enter fullscreen mode Exit fullscreen mode

Now, you’ll have a search bar set up on your Gatsby site.

Install gatsby-plugin-local-search and FlexSearch

Now that we have our search bar, we'll need to hook it up to a search library.

The Gatsby ecosystem has plugins for every occassion - and search is no exception!

First, install gatsby-plugin-local-search:

yarn add gatsby-plugin-local-search
# or 
npm install gatsby-plugin-local-search
Enter fullscreen mode Exit fullscreen mode

This plugin handles integrating your Gatsby site with a search engine library. On top of this plugin, we’ll also need to install our search library, FlexSearch:

yarn add flexsearch react-use-flexsearch
# or 
npm install flexsearch react-use-flexsearch
Enter fullscreen mode Exit fullscreen mode

We’re also installing a react-use-flexsearch hook, which will make it easier to use FlexSearch later.

Update your Gatsby config file

As with all Gatsby plugins, once you have installed the plugin you will need to add it to your Gatsby config file.

// gatsby-config.js
plugins: [
    {
        resolve: 'gatsby-plugin-local-search',
        options: {
            name: 'pages',
            engine: 'flexsearch',
            query: /** TODO **/,
            ref: /** TODO **/,
            index: /** TODO **/,
            store: /** TODO **/,
            normalizer: /** TODO **/,
        }
    },
Enter fullscreen mode Exit fullscreen mode

I’ve left most of the options blank, since these are going to be individual to your site. We’ll be covering them one-by-one below.

Adding the query value

The first value we need to add to our plugin options is the query. This GraphQL query needs to grab the data for all your posts.
This is the same query that we used earlier on the home page of our Gatsby site:

query: `
  query {
    allMarkdownRemark(sort: { fields: [frontmatter___date], order: DESC }) {
      nodes {
        excerpt
        fields {
          slug
        }
        frontmatter {
          date(formatString: "MMMM DD, YYYY")
          title
        }
      }
    }
  }
`
Enter fullscreen mode Exit fullscreen mode

Choosing a ref value

The ref is a value unique to each blog post. If your posts have unique slugs, you can use that.

ref: 'slug'
Enter fullscreen mode Exit fullscreen mode

πŸ’‘ What is a slug?

If you have a post living at the URL website.com/foo-bar, the slug is the foo-bar bit. A slug value is usually calculated in your gatsby-node.js file.

If your site doesn’t have slugs, GraphQL provides an ID for each of your posts, so you can use that for your ref:

query {
    allMarkdownRemark(sort: { fields: [frontmatter___date], order: DESC }) {
        nodes {
            id
Enter fullscreen mode Exit fullscreen mode

Adding an index value

Our next value is the index. This is the array of values that you want FlexSearch to search from.
The most likely thing you’ll be adding is the title, but you might also want users to search the post's excerpt or tags as well.

index: ['title', 'excerpt']
Enter fullscreen mode Exit fullscreen mode

Adding a store value

Next is the store. When FlexSearch returns search results, this is the data you want in those results.
For example if you're going to render the date under every post, you'll want the date value.

You’ll also need to include in the store your ref and index values as well.

store: ['title', 'excerpt', 'date', 'slug']
Enter fullscreen mode Exit fullscreen mode

Adding a normalizer value

The final step is the normalizer.
FlexSearch expects all the values that you listed above in the store to be returned in a flat shape like this:

{
    title: 'Foo',
    excerpt: 'Blah blah salted duck eggs'
    date: '2020-01-01',
    slug: 'foo-bar'
}
Enter fullscreen mode Exit fullscreen mode

We need a function that will transform the data from our GraphQL query into the expected shape:

normalizer: ({ data }) =>
    data.allMarkdownRemark.nodes.map(node => ({
        title: node.frontmatter.title,
        excerpt: node.excerpt,
        date: node.frontmatter.date,
        slug: node.fields.slug,
    })),
Enter fullscreen mode Exit fullscreen mode

Add your FlexSearch engine to your search bar

Now that we’ve set up FlexSearch, we can finally start using it for our search bar.

// src/pages/index.js
import React, { useState } from 'react';
import { graphql } from 'gatsby';
import { useFlexSearch } from 'react-use-flexsearch';

export default ({
    data: {
        localSearchPages: { index, store },
        allMarkdownRemark: { nodes },
    },
}) => {
    const { search } = window.location;
    const query = new URLSearchParams(search).get('s');
    const [searchQuery, setSearchQuery] = useState(query || '');

    const posts = nodes;
    const results = useFlexSearch(searchQuery, index, store);

    return (
        <div>
            <h1>Blog</h1>
            <Search
                searchQuery={searchQuery}
                setSearchQuery={setSearchQuery}
            />
            {posts.map(post => (
                <LinkComponent post={post} />
            ))}
        </div>
    );
};

export const pageQuery = graphql`
  query {
    localSearchPages {
      index
      store
    }
    allMarkdownRemark(sort: { fields: [frontmatter___date], order: DESC }) {
      nodes {
        excerpt
        fields {
          slug
        }
        frontmatter {
          date(formatString: "MMMM DD, YYYY")
          title
        }
      }
    }
  }
`
Enter fullscreen mode Exit fullscreen mode

Make sure to un-normalize the data

The results returned from the FlexSearch hook are going to be in a β€œflat” shape like this:

{
    title: 'Foo',
    tags: ['tag'],
    date: '2020-01-01',
    slug: 'foo-bar'
}
Enter fullscreen mode Exit fullscreen mode

Our link component will be expecting the post to be the same shape as what our GraphQL query returns.
So we can write a function to put this data back into its expected shape:

export const unFlattenResults = results =>
    results.map(post => {
        const { date, slug, tags, title } = post;
        return { slug, frontmatter: { title, date, tags } };
    });
Enter fullscreen mode Exit fullscreen mode

And now we can use our results value:

const results = useFlexSearch(searchQuery, index, store);
const posts = unflattenResults(results);

return (
    <>
        <h1>Blog</h1>
        <Search
            searchQuery={searchQuery}
            setSearchQuery={setSearchQuery}
        />
        {posts.map(post => (
            <LinkComponent post={post} />
        ))}
    </>
);
Enter fullscreen mode Exit fullscreen mode

Accounting for an empty query

The FlexSearch engine will return no results if you have an empty query. The behaviour that you want here instead is to show all the results.

When the search query is empty, we can fall back to using the original data we were getting from our GraphQL query.

const results = useFlexSearch(searchQuery, index, store);
// If a user has typed in a query, use the search results.
// Otherwise, use all posts
const posts = searchQuery ? unflattenResults(results) : nodes;

return (
    <>
        <h1>Blog</h1>
        <Search
            searchQuery={searchQuery}
            setSearchQuery={setSearchQuery}
        />
        {posts.map(post => (
            <LinkComponent post={post} />
        ))}
    </>
);
Enter fullscreen mode Exit fullscreen mode

Now, you will have finished setting up the search bar set up on your Gatsby site!
With search implemented, your readers can now look for the content that is most relevant to them.

GIF of posts being filtered as user types query in search box

Discussion

pic
Editor guide
Collapse
bellons91 profile image
Davide Bellone

Great article! Thank you! I'm going to add search to my blog as soon as possible! :)

Collapse
emma profile image
Emma Goto πŸ™ Author

You're welcome! Keen to see it in action!

Collapse
bellons91 profile image
Davide Bellone

I'm trying your solution, but I keep having an error about the usage of useState.
My index page derives from a component:

import React, { useState } from 'react';
//other imports

export default class IndexPage extends React.Component<PageProps> {
  public render() {
    // many things
    const { search } = window.location;
    const query = new URLSearchParams(search).get('s'); 
    const [searchQuery, setSearchQuery] = useState(query || '');
Enter fullscreen mode Exit fullscreen mode

So, when I run the project, I get an error: Invalid hook call. Hooks can only be called inside of the body of a function component.

The official React documentation states that this error is due to the fact that I'm running this code inside a React Component..

class Bad3 extends React.Component {
  render() {
    // πŸ”΄ Bad: inside a class component
    useEffect(() => {})
    // ...
  }
}
Enter fullscreen mode Exit fullscreen mode

So, how would you solve it?

Thread Thread
emma profile image
Emma Goto πŸ™ Author

Yes, so you can write a React component in one of two ways: as a functional component (the "newer" way), and as a class component.

You could convert your class component to a functional component:

const IndexPage = (pageProps) => {
    const { search } = window.location;
    const query = new URLSearchParams(search).get('s'); 
    const [searchQuery, setSearchQuery] = useState(query || '');
}

export default IndexPage;
Enter fullscreen mode Exit fullscreen mode

And that would let you use the useState hook. You can still use class components, but I think it makes things a little bit more complicated :)