๐Ÿฟ๐Ÿฟ 10 min. read

How to Add Search Functionality to a Gatsby Blog

Monica Powell

I recently added functionality to this site to allow visitors to filter posts based on the posts description, title, and tags in an effort to allow better discovery of content. This tutorial will is based off of how I implemented a basic search on this site and will cover how to create a search filter on a site built with GatsbyJS. In particular, this tutorial walks through how to create an input field that allows users to filter a list of an entire Gatsby site's posts if the description, title or tags matches the input query. The solution proposed in this tutorial leverages GraphQL and React hooks to update the state to show appropriate data when content is filtered.

Demo of the Search Filter

filter demo from aboutmonica.com/writing

Getting Started

Prerequisites

Although, some of the implementation details can be abstracted and applied in any React application to get the most value out of this tutorial you should have:

  • Some knowledge of ES6 and React
  • Local Gatsby site with Markdown posts
    • If you have a Gatsby site without Markdown posts check out the Boilerplate Code or update the code in this tutorial to query posts from your data source instead.

If you do not yet have Markdown files on your site then you should start by adding markdown pages to Gatsby. You can also learn more about creating an index of markdown posts in the Gatsby Docs.

Boilerplate Code: Query All Posts

If you do not already have an index page listing all of your posts then create a new gatsby page for example named "writing.js" in src within the pages directory. This file will be responsible for rendering information about every post on your site.

We will be using a GraphQL page query which allows the data returned from the query to be available to the component in the data prop. The posts are returned by the page query and are equal to data.allMarkdownRemark.edges . Once we have the posts we can .map() through each of the posts and destructure the node.frontmatter with const { tags, title, date, description, slug } = node.frontmatter. This will add the title, date, description, and slug to the DOM for each post.

"Gatsby uses the concept of a page query, which is a query for a specific page in a site. It is unique in that it can take query variables unlike Gatsbyโ€™s static queries." Source: Gatsby Docs

Below is the boilerplate code that will be used throughout this tutorial:

1import React from "react"
2import { Link, graphql } from "gatsby"
3
4const BlogIndex = props => {
5 const { data } = props
6 const posts = data.allMarkdownRemark.edges
7
8 return (
9 <>
10 {/* in my site I wrap each page with a Layout and SEO component which have
11 been omitted here for clarity and replaced with a React.fragment --> */}
12
13 {/*in-line css for demo purposes*/}
14 <h1 style={{ textAlign: `center` }}>Writing</h1>
15
16 {posts.map(({ node }) => {
17 const { excerpt } = node
18 const { slug } = node.fields
19
20 const { title, date, description, slug } = node.frontmatter
21 return (
22 <article key={slug}>
23 <header>
24 <h2>
25 <Link to={slug}>{title}</Link>
26 </h2>
27
28 <p>{date}</p>
29 </header>
30 <section>
31 <p
32 dangerouslySetInnerHTML={{
33 __html: description || excerpt,
34 }}
35 />
36 </section>
37 <hr />
38 </article>
39 )
40 })}
41 </>
42 )
43}
44
45export default BlogIndex
46
47export const pageQuery = graphql`
48 query {
49 allMarkdownRemark(sort: { order: DESC, fields: frontmatter___date }) {
50 edges {
51 node {
52 excerpt(pruneLength: 200)
53 id
54 frontmatter {
55 title
56 description
57 date(formatString: "MMMM DD, YYYY")
58 tags
59 }
60 fields {
61 slug
62 }
63 }
64 }
65 }
66 }
67`

At this point you should be able to view an index of all of the posts on your site by running gatsby develop and going to http://localhost:8000/${NAME_OF_FILE}. For example, the file I created is named writing.js so I navigate to http://localhost:8000/writing to view it. The page output by the boilerplate code above should resemble the below image (i.e., each blog post is listed along with its title, date, and description). Additionally, the header for each article should navigate to the slug for the article and be a valid link.

Index Page of All Posts

list of posts after setting up initial template

Why Query All of The Posts?

Before filtering the posts its helpful fetch all of the posts before we return a filtered subset from all of the posts. On my site, I used a page query on the /writing/ page to retrieve data for all the blog posts from my site so that I can construct a list of posts. The results of the page query are available to this component within the data prop to the component i.e., (const { data } = props).

The boilerplate code above is a variation of the GraphQL query that my site uses to pull in each post along with its excerpt, id, frontmatter (title, category, description, date, slug, and tags). The blog posts are in the allMarkdownRemark as edges and can be accessed like const posts = data.allMarkdownRemark.edges.You can use the above-provided query to return metadata and slugs for all posts OR if you already have a query to return an index of all blog posts then feel free to use that.

Below is a photo that shows the data that the above GraphQL query returned for my site. You can view the data returned by that query for your particular site in an interactive format by running gatsby develop and navigating to http://localhost:8000/___graphql and pressing run. If you go to http://localhost:8000/___graphql and scroll down you should see that there is metadata being returned for every single post on your site which is exactly what we are trying to capture before we filter posts.

Sample Data in GraphiQL
output data

How to Filter Posts by User Input

Capture User Input with Input Event

Now that we have the boilerplate code setup let's get back to the task at hand which is to filter the posts based on user input. How can we capture what query a user is searching for and update the DOM with the appropriate post(s) accordingly? Well, there are various types of browser events including, input, keypress, click, drag and drop. When these events occur JavaScript can be written to respond based on the type and value of the event.

Since we are having users type a search query into a <input> we can process their query as they type. We will be focusing on the inputevent which triggers whenever the value in an input field changes. The input event changes with each keystroke which is in contrast to the change event which is fired once for each submission (i.e., pressing enter) for <input>,<select> and <textarea> elements. You can read more about how React handles events in the React docs.

Create Input Element with onChange event handler

We already have the post data we need to filter available in the data prop so let's create an element to allow users to type in their search query. <input/> will have an onChange property that calls a function handleInputChange whenever the <input/> changes and an Input event is fired. In other words, onChange calls another function which handles the Input event which fires every time someone types in our <Input/>. So if someone typed "React" into an <input/>. It will trigger 5 events with the following values ("R", "Re", "Rea", "Reac", "React").

Note: The <input/> should go below the <h1> and outside of the posts.map.

1<h1 style={{ textAlign: `center` }}>Writing</h1>
2 <input
3 type="text"
4 aria-label="Search"
5 placeholder="Type to filter posts..."
6 onChange={handleInputChange}
7 />
8 {posts.map(({ node }) => {

The page should now visibly have an <input/> element. However, it will not yet be functional as handleInputChange has not been added yet.

Visible Input Element

visible input element on DOM

useState() to Store Filtered Data and Query Information in State

Before implementing onChange let's set the default state with useState() for our search input with the default query as an empty string and filteredData as an empty array. You can read more about the useState() hook in the React docs.

1const posts = data.allMarkdownRemark.edges
2 const emptyQuery = ""
3 const [state, setState] = useState({
4 filteredData: [],
5 query: emptyQuery,
6 })
7 return (

Implement onChange to Filter Posts by <input/> Event Value

This handleInputChange function takes the Input event in which the event.target.value is the query string that is being searched for. handleInputChange also has access to our props which contain all of the posts for the site. So we can filter all of the site's posts based on the query and return filteredPosts.

In order to process the event (which fires on each keystroke) we need to implement handleInputChange. handleInputChange receives an Input event. The target.value from the event is the string that the user typed and we will store that in the query variable.

Inside of handleInputChange we have access to the posts and the query so let's update the code to .filter() the posts based on the query. First, we should standardize the casing of the fields and the query with .toLowerCase() so that if someone types "JaVAsCriPt" it should return posts that match "JavaScript". For our .filter() if any of the three conditions that check if the post contains the query evaluates to true then that post will be returned in the filteredData array.

After we filter the data in handleInputChange the state should be updated with the current query and the filteredData that resulted from that query.

1const [state, setState] = useState({
2 filteredData: [],
3 query: emptyQuery,
4 })
5
6const handleInputChange = event => {
7 const query = event.target.value
8 const { data } = props
9
10 // this is how we get all of our posts
11 const posts = data.allMarkdownRemark.edges || []
12
13
14 // return all filtered posts
15 const filteredData = posts.filter(post => {
16 // destructure data from post frontmatter
17 const { description, title, tags } = post.node.frontmatter
18 return (
19 // standardize data with .toLowerCase()
20 // return true if the description, title or tags
21 // contains the query string
22 description.toLowerCase().includes(query.toLowerCase()) ||
23 title.toLowerCase().includes(query.toLowerCase()) ||
24 (tags && tags
25 .join("") // convert tags from an array to string
26 .toLowerCase()
27 .includes(query.toLowerCase()))
28 )
29 })
30
31 // update state according to the latest query and results
32 setState({
33 query, // with current query string from the `Input` event
34 filteredData, // with filtered data from posts.filter(post => (//filteredData)) above
35 })
36}
37
38return (
39 <>

Now if you type in the <Input/> now it still won't update the list of posts because we are always rendering the same posts regardless of if we have filteredData available in the state or not. But if you were to console.log(event.target.value) in handleInputChange we can confirm that handleInput is firing properly by typing "React". Even though the page doesn't visually change the console output should be something like:

1r writing.js:1
2re writing..js:1
3rea writing..js:1
4reac writing.js:1
5react writing.js:1

Display Filtered Posts

We are already storing filteredData and query in state but let's rename posts to allPosts so that we can make the value of posts conditional based on whether or not a user has typed a search query and should see their filtered search query results as posts or if they have yet to type a query then we should display all of the blog posts.

1const BlogIndex = props => {
2const { filteredData, query } = state
3const { data } = props
4 // let's rename posts to all posts
5const allPosts = data.allMarkdownRemark.edgess
6const emptyQuery = ""

For the posts we need to decide whether to return all of the posts or the filtered posts by checking state and conditionally rendering either all of the posts OR just the filtered posts based on whether or not we have filteredData and the query != emptyQuery.

The below code updates our render logic accordingly.

1const { filteredData, query } = state
2// if we have a fileredData in state and a non-emptyQuery then
3// searchQuery then `hasSearchResults` is true
4const hasSearchResults = filteredData && query !== emptyQuery
5
6// if we have a search query then return filtered data instead of all posts; else return allPosts
7const posts = hasSearchResults ? filteredData : allPosts

Summary

You should now have a working post filter on your blog index page (if not check out the Final Code below). At a high-level the steps taken to implement filtering were:

  1. create a page query to implement a blog index page which lists all of the posts
  2. create an input field on the blog index page with an onChange event handler to process keystrokes in our input field
  3. filter all of the posts on the blog index page based on the current query (from input event) and use useState() to update the state with the search query and filtered data
  4. update rendering logic to either display all of the posts or the filtered posts on the blog index page based on whether or not there's a query in state

Below is the final code as outlined in the tutorial. However, this is just the baseline for search and you may want to make the functionality more robust by adding additional features such as autocomplete suggestions, displaying the number of results (based on length of posts) and providing an empty state with messaging for when there are zero results (based on filteredData being an empty array).

Final Code

1import React, { useReact } from "react"
2import { Link, graphql } from "gatsby"
3
4const BlogIndex = props => {
5 const { data } = props
6 const allPosts = data.allMarkdownRemark.edges
7
8 const emptyQuery = ""
9
10 const [state, setState] = useState({
11 filteredData: [],
12 query: emptyQuery,
13 })
14
15 const handleInputChange = event => {
16 console.log(event.target.value)
17 const query = event.target.value
18 const { data } = props
19
20 const posts = data.allMarkdownRemark.edges || []
21
22 const filteredData = posts.filter(post => {
23 const { description, title, tags } = post.node.frontmatter
24 return (
25 description.toLowerCase().includes(query.toLowerCase()) ||
26 title.toLowerCase().includes(query.toLowerCase()) ||
27 (tags &&
28 tags
29 .join("")
30 .toLowerCase()
31 .includes(query.toLowerCase()))
32 )
33 })
34
35 setState({
36 query,
37 filteredData,
38 })
39 }
40
41 const { filteredData, query } = state
42 const hasSearchResults = filteredData && query !== emptyQuery
43 const posts = hasSearchResults ? filteredData : allPosts
44
45 return (
46 <>
47 <h1 style={{ textAlign: `center` }}>Writing</h1>
48
49 <div className="searchBox">
50 <input
51 className="searchInput"
52 type="text"
53 aria-label="Search"
54 placeholder="Type to filter posts..."
55 onChange={handleInputChange}
56 />
57 </div>
58
59 {posts.map(({ node }) => {
60 const { excerpt } = node
61
62 const { slug } = node.fields
63 const { tags, title, date, description } = node.frontmatter
64 return (
65 <article key={slug}>
66 <header>
67 <h2>
68 <Link to={slug}>{title}</Link>
69 </h2>
70
71 <p>{date}</p>
72 </header>
73 <section>
74 <p
75 dangerouslySetInnerHTML={{
76 __html: description || excerpt,
77 }}
78 />
79 </section>
80 <hr />
81 </article>
82 )
83 })}
84 </>
85 )
86}
87
88export default BlogIndex
89
90export const pageQuery = graphql`
91 query {
92 allMarkdownRemark(sort: { order: DESC, fields: frontmatter___date }) {
93 edges {
94 node {
95 excerpt(pruneLength: 200)
96 id
97 frontmatter {
98 title
99 description
100 date(formatString: "MMMM DD, YYYY")
101
102 tags
103 }
104
105 fields {
106 slug
107 }
108 }
109 }
110 }
111 }
112`

This article was published on November 26, 2019.


Don't be a stranger! ๐Ÿ‘‹๐Ÿพ

Thanks for reading "How to Add Search Functionality to a Gatsby Blog". Join my mailing list to be the first to receive my newest web development content, my thoughts on the web and learn about exclusive opportunities.

    ย 

    I wonโ€™t send you spam. Unsubscribe at any time.

    Webmentions

    238
    • Sibelius Seraphini
    • Sergey Chernyshev
    • Art Rosnovsky
    • TheRealDine
    • cyberjobmentor
    • christine
    • Memphis โœŒ๐Ÿผ
    • _๐Ÿ
    • Nep Montanez
    • Bobby
    • Brett Sinclair
    • Elizabeth Modupeoluwa Chanbang
    • Carolyn Stransky
    • Maria Alejandra Ferreira torres
    • Deserie
    • A Year in Tri ๐Ÿ‘ฉโ€๐Ÿ’ป๐Ÿƒ๐Ÿปโ€โ™€๏ธ๐Ÿ›ธ
    • Mahmoud Abdelwahab
    • Rubรฉn Garcรญa
    • Linda T.
    • swyx
    • Irena Jovanovska
    • Elicia
    • loafing loaf
    • :party-corgi:
    • Fran Lucchini #YoApruevo
    • Fatou
    • erdle ๐Ÿ
    • Tanner Dolby
    • Paulie Rodriguez
    • Sophia Li
    • Kevin Collas-Arundell
    • Sedarkstian07
    • maggie ๐Ÿ‘ฉ๐Ÿฝโ€๐Ÿ’ป๐ŸŒฑ
    • Tania Rascia
    • Rae #BLM
    • Joe Graham
    • Anil Chaudhary
    • Aien ๐ŸŒฑ
    • Paulo Elias
    • a-prepare-fication
    • +20