Easy client-side search for Gatsby sites
Adding search functionality to static sites is easy with Gatsby.
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:
- Add
gatsby-plugin-local-serach
togatsby-config.js
and set options. - Query for the index and store on your search page.
- Pass those into the search engine’s hook along with a search query.
- 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!