Skip to content

Easy client-side search for Gatsby sites

Adding search functionality to static sites is easy with Gatsby.

Senior Developer
Sep 23, 2020

Almost all sites could benefit from the ability to search its content. As a user, searching for a specific page on a marketing site or a post on a personal blog can be very convenient. Integrating search for dynamic sites, however, such as those built with WordPress or Ruby on Rails, can be a fairly involved process. Static sites, on the other hand, don’t have to deal with dynamic content, making search indexing much simpler.

We developed a plugin for Gatsby named gatsby-plugin-local-search that makes integrating client-side search easy. The plugin queries your data, compiles it into a searchable index using Lunr or FlexSearch, and makes it available to integrate in your site’s front-end however you see fit.

Here’s how to use it.

Setting up the search index

First thing we’ll need to do is add gatsby-plugin-local-search to our project.

yarn add gatsby-plugin-local-search

Next, we’ll need to register and configure the plugin in our site’s gatsby-config.js.

// gatsby-config.js const fs = require('fs') const path = require('path') module.exports = { plugins: [ // Alongside your other plugins... { resolve: 'gatsby-plugin-local-search', options: { name: 'pages', engine: 'lunr', query: fs.readFileSync( path.resolve(__dirname, 'src/localSearchQuery.graphql'), 'utf-8', ), ref: 'url', index: ['title', 'description', 'content'], store: ['url', 'title', 'description'], normalizer: ({ data }) => data.allPrismicPage.nodes.map((node) => { const content = valuesDeep(node.data?.body).join(' ') return { url: node.url, title: node.data?.meta_title ?? node.data?.title?.text, description: node.data?.meta_description ?? truncate(content, 200), content, } }), }, }, ], }

Each option here is important, so let’s describe them one-by-one.

Name

The name option identifies your search index. If you have more than one index on your site, which is not uncommon, the name option allows you to query for a specific index. For example, you may want an index for content authors and another for news posts. Just give each plugin a descriptive name of the content they hold.

Engine

The plugin allows creating indexes using different search engines and can be set using the engine option. Currently, the two supported engines are Lunr and FlexSearch. FlexSearch is generally faster and provides more indexing customization, but usually results in larger index sizes than Lunr. Both options are good and can be swapped without much effort.

Query

The query option is the GraphQL query used to gather the content to index. Because we’re working with Gatsby, we have access to all data that has been loaded into the data store. If you source data from Markdown files or a content management system using a source plugin, your query should look similar to what you’re already using to make pages.

In our example above, we’re reading a GraphQL file using fs.readFileSync. I find it easier to store the query in a dedicated .graphql file separate from gatsby-config.js as the queries can become quite large. You could also put the query directly in gatsby-config.js without issues.

Ref

In search indexes, each indexed item needs a way to identify itself. The ref option determines which field of an indexed item is used as the item’s identifier. By default, ref is configured to use the id field. This means each item will be uniquely identified by item.id. The data for this field is gathered in the normalizer option, which we’ll discuss in a bit.

We’re using the url field as our ref value in the above example since each page URL is unique.

Index

For each item we queried, we need to list which fields we want to include in our index. Because the index will need to be downloaded at run-time, this list should be a short as possible. Smaller indexes lead to faster loading and searching. But be sure to include enough content to make the search useful!

In our example, we’re going to index title, description, and content. This will allow users to search for any content on a page from our content management system.

Store

Simliar to the index option, the store option lists which fields we want access to when displaying results. The plugin will build a search index with as little data as possible, which may not include content you want to display on results. With the store option, we can create a JSON object that includes the missing content.

In our example, we didn’t include each page’s URL in the index, but we’re going to include it in the store. This will allow us to link search results to their page.

Normalizer

The normalizer option is a function that transforms the content from your query into something the search engine can process. Your query may be fetching data from multiple sources, and often times will contain deeply nested content. The normalizer option allows us to transform the complicated data into simpler, flat objects. Note that converting your data into an object with no nested objects is required to index properly.

The objects returned from this function is also the data used in the ref, index, and store options. If you set ref to a field named url, for example, be sure to return a field named url in the normalizer function.

In our example, we’re returning an object with the following fields: url, title, description, and content. The first three are straightforward: we fetch the appropriate value from our query results and assign it. The content field, however, is a little more involved. We want access to all of a page’s content when making the search index. To do this, we need to construct our query to fetch all the pieces of a page and then convert that into a single, long string.

If our content is coming from a content management system like Prismic, our query could look like this:

# src/localSearchQuery.graphql query { allPrismicPage { nodes { url data { title meta_title body { ... on PrismicPageBodyRichText { primary { content { text } } } ... on PrismicPageBodyImage { primary { image { alt } } } ... on PrismicPageBodyCallToAction { primary { call_to_action { text } button_text } } } } } } }

This query will return a list of objects with deeply nested objects and strings. To turn the content into a single string, we can use a helper function like this:

/** * Determines whether the passed object is a plain object. * * @param value The value to check. * * @returns true if value is a plain object, false otherwise. */ export const isPlainObject = (value) => isMapOrSet(value) ? false : value !== null && typeof value === 'object' && !Array.isArray(value) /** * Recursively accumulates a list of values from an input. * * @remarks * Only object values, not their keys, are included in the result. * * @example * valuesDeep({ names: ['Foo', 'Bar'], opts: { last: false } }) * // => ['Foo', 'Bar', false] * * @param input The input with values. * @param initialValues The base list of values to which accumulated values will be added. * * @returns An array of the input's values gathered recursively. */ export const valuesDeep = (input, initialValues = []) => { if (isPlainObject(input)) return [ ...initialValues, ...Object.values(input).map((val) => valuesDeep(val, initialValues)), ].flat() if (Array.isArray(input)) return [ ...initialValues, ...input.map((val) => valuesDeep(val, initialValues)), ].flat() return [...initialValues, input] }

Now all we have to do is call valuesDeep with our content object and join the values into a string:

const content = valuesDeep(node.data?.body).join(' ')

And with the normalize option set up, gatsby-plugin-local-search is fully configured. The plugin is now creating a search index with our content any time we start up the development server or build the site.

Displaying the results

Creating the index is half the work. The other half is displaying the results. gatsby-plugin-local-search makes it easy to query for the index and store we just made and display it on a page.

Query for the index and store

On the search results page, query for the index and store using a GraphQL query. We typically opt for useStaticQuery, but it could also be added to the page’s root query.

const queryData = useStaticQuery(graphql` query { localSearchPages { index store } } `)

Take note of the localSearchPages field. Since we named our index “pages,” the field is named localSearchPages. If we had named the index “authors,” we would query for localSearchAuthors instead.

index is the exported search index for the engine we picked. This will be used with the search engine’s library to retrieve search results.

store is the JSON object containing any extra data used for displaying each result. This will also be used with the search engine’s library to retrieve search results.

Hook it all up

We now have all the search index data we need. Next, we need to feed it into our search engine to get results.

If you opted to use Lunr, we’ll use react-lunr’s useLunr hook. If you opted for FlexSearch, we’ll use react-use-flexsearch’s useFlexSearch hook.

# For Lunr indexes: yarn add react-lunr # Or for FlexSearch indexes: yarn add react-flexsearch

Both hooks have the same API: provide a search query, an index, and a store. On your search results page, use the search engine’s hook like the following:

const index = queryData.localSearchPages.index const store = queryData.localSearchPages.store // If you're using Lunr: const results = useLunr('search term', index, store) // Or if you're using FlexSearch: const results = useFlexSearch('search term', index, store)

results will be an array of search results with the data we queried, normalized, and selected in gatsby-config.js at the beginning of this post. With that list, it’s just a matter of mapping over it and rendering some components.

A full example page would look something like this:

// src/pages/search.js import * as React from 'react' import { useStaticQuery } from 'gatsby' import { useLunr } from 'react-lunr' const SearchPage = () => { const queryData = useStaticQuery(graphql` query { localSearchPages { index store } } `) const index = queryData.localSearchPages.index const store = queryData.localSearchPages.store const [query, setQuery] = useState('') const results = useLunr(query, index, store) return ( <main> <h1>Search</h1> <label> <span>Search query</span> <input name="query" value={query} onChange={(event) => setQuery(event.target.value)} /> </label> <h2>Results</h2> {results.length > 0 ? ( <ul> {results.map((result) => ( <li key={result.url}>{result.title}</li> ))} </ul> ) : ( <p>No results!</p> )} </main> ) } export default SearchPage

Wrap up

We touched three files and gained fully-featured search for all of our site’s content. For search that will automatically update as your content does, that’s a pretty small amount of effort.

In summary, using the plugin looks like this:

  1. Add gatsby-plugin-local-serach to gatsby-config.js and set options.
  2. Query for the index and store on your search page.
  3. Pass those into the search engine’s hook along with a search query.
  4. Render the search results.

There are some advanced features in gatsby-plugin-local-search that we didn’t cover in this post. For example, the plugin allows you to provide custom indexing options to the search engine if you want to tweak its indexing method. You can also lazy-load your index and store if they’re getting too large to load on page entry.

We’ll save those features for separate blog posts. Until then, check out the documentation for gatsby-plugin-local-search on GitHub.

We’re open-source-focused developers, so feel free to report issues or submit PRs on the repository if you come across bugs or have new feature ideas. Happy indexing!