First published December 16, 2021

Build a Gatsby Blog Post Filter

Even with multiple data sources and things to filter by, it can be done.

Blog filter input on author's personal site

Introduction

Earlier this year, I built my own website using Gatsby to serve as a centralized place to showcase my work, my talks, my blog articles and courses, and let people get to know me a little better.

Since I've been writing regularly for almost 4 years now, there's a lot of blog articles to sort through on the site, so one thing I wanted to do was make a filter on the blog page to let readers narrow down what they were interested in. There's a catch to this filter though, and it's twofold:

  1. I wanted to give readers multiple parts of a blog they could search by: blog titles, blog subtitles, and blog tags.
  2. Not all of my blog posts live on my site: many are hosted on other platforms like Medium, Hackster.io, etc. so I couldn't rely solely on Gatsby's GraphQL queries during page generation to grab all the post data needed.

It makes for a tricky situation, but certainly not an insurmountable one.

In this post, I'll show you how to build a blog filter in Gatsby that accesses data from multiple sources and filters them by multiple parameters.

Here's what the finished product looks like:

First things first, get all the post data in one place

Before we can start filtering down the blog posts we must gather together all the data from their different sources.

If we were using just Gatsby to host blog content in the form of local Markdown files or a headless CMS integration, it would be possible to use Gatsby's gatsby-source-filesystem or the Gatsby CMS integration guide of your choice to pull in all the data in one fell swoop, but what about blogs that aren't accessible this way?

Before I built this site, I posted almost all of my tech blogs on Medium. I'm in the process of moving them all here, but it's a lot of content and the going is slow.

Well here's my low-tech, interim solution: a simple JavaScript list.

Make a file for the blogs not hosted on our Gatsby site

Until I eventually get all of my blogs hosted in one place (this website), I came up with an easy, workable solution - there's not even any GraphQL queries required. My solution was creating a new JavaScript file named mediumBlogs.js. And all the file is, is a list of all my blogs on third-party sites.

Here's an example of a few of the posts and the properties I include for each blog post.

mediumBlogs.js

import moment from 'moment';
import googleJib from '../content/images/google-jib.png';
import sequelize from '../content/images/sequelize.png';

export default [
  {
    date: moment('2018-07-18').format('ll'),
    img: googleJib,
    url:
      'https://itnext.io/jib-getting-expert-docker-results-without-any-knowledge-of-docker-ef5cba294e05',
    subTitle: 'All of the containerization benefits, none of the complexity.',
    tags: ['docker', 'devops', 'java ', 'jib'],
    timeToRead: 4,
    title: 'Jib: Getting Expert Docker Results Without Any Knowledge of Docker',
  },
  {
    date: moment('2018-08-04').format('ll'),
    img: sequelize,
    url:
      'https://medium.com/@paigen11/sequelize-the-orm-for-sql-databases-with-nodejs-daa7c6d5aca3',
    subTitle: 'The ORM For SQL Databases with Node.js',
    tags: ['javascript', 'nodejs', 'sql', 'express'],
    timeToRead: 8,
    title: 'Sequelize: Like Mongoose But For SQL',
  },
  // more blogs following the same format here
]

Unfortunately there was no Medium API to pull in this sort of data programmatically, so I made up my own properties of info I wanted to include - things like date, original URL, title and subtitle, tags, a thumbnail image - you get the idea, and manually put the file together.

I may have been able to figure out a more automated way to scrape this data from Medium, but it probably would have taken me just as long to come up with a working script as it did to type up this file with links to all my posts. I wasn't in a massive rush so I would just add 5 posts at a time as I was working on building the site in my spare time.

The nice thing about doing it this way is that I was able to create my custom list of blog posts with exactly the data I wanted in the shape I needed - a shape that can be closely mimicked in my GraphQL queries to fetch locally hosted blog data, which I'll cover in the next sections.

To use this file anywhere in the Gatsby blog, it just needs to be imported into the component that needs it.

My list of blogs live in the PostListing component, so to get the Medium blog list it's as simple as importing anything else into a JS file:

PostListing.js

// other file imports above here
import mediumBlogs from '../../../data/mediumBlogs';
// other file imports below here

With the third-party post data handled, it's time to move on to the local data in Markdown files on the site.

Create a GraphQL useStaticQuery Hook to grab all the local data

After making a list of all the additional blog posts not accessible by the Gatsby site, we also need to grab the data that Gatsby is responsible for. Since I choose to host my blogs in Markdown files that are stored in a private repo on GitHub, this data can be accessed with a combination of the gatsby-source-filesystem in the gatsby-config.js file, and a custom Hook on the frontend to query the data.

Using the gatsby-source-filesystem to generate content from source data like Markdown files is fairly straightforward following the documentation on the Gatsby site: just point the filesystem plugin towards your directory folder where all the files live and it takes care of the rest.

On the client side, my version of Gatsby (v2.1 and above) supports GraphQL useStaticQuery Hooks that can run at build time. The special thing about these queries is that they don't need to be at the page level and they can be written as reusable custom Hooks not attached to a particular page or component.

There are a few limitations to useStaticQuery that makes it slightly less flexible than original, page-level queries, but it will work just fine for our purposes today.

If you'd like to know more about this Hook, I wrote about it in-depth here.

Since the front matter in my local Markdown post files looks like this:

---
title: "Docker 102: Docker-Compose"
subTitle: 'The recipe card for getting all your Dockerized apps to work together seamlessly.'
thumbnail: ../images/docker-2.png 
featuredImage: ../images/docker-2.png
category: "devops"
date: "2018-07-07"
ogLink: 'https://itnext.io/docker-102-docker-compose-6bec46f18a0e'
publication: ITNEXT
tags:
    - docker
    - devops
omit: false
---

The custom useStaticQuery Hook to fetch and parse this data can look something like this:

usePostListingQuery.js

import { useStaticQuery, graphql } from 'gatsby';

export const usePostListingQuery = () => {
  const postListData = useStaticQuery(graphql`
    query PostQuery {
      allMarkdownRemark(
        filter: { frontmatter: { omit: { eq: false } } }
        sort: { fields: [fields___date], order: DESC }
      ) {
        edges {
          node {
            fields {
              slug
              date(formatString: "MMM D, YYYY")
            }
            timeToRead
            frontmatter {
              omit
              title
              subTitle
              tags
              date
              thumbnail {
                childImageSharp {
                  fixed(width: 200) {
                    ...GatsbyImageSharpFixed
                  }
                }
              }
            }
          }
        }
      }
    }
  `);
  return postListData;
};

This query fetches all the same data from the Markdown files' front matter as the data properties included in the custom list of third-party blog posts we made.

With this reusable custom Hook at our disposal, it can be imported into the PostListing component to fetch all the data about our local blog posts.

PostListing.js

// other imports above
import { usePostListingQuery } from '../../Hooks/usePostListingQuery';
// other imports below

const PostListing = () => {
  const localSitePosts = usePostListingQuery();

Combine the data into one list

Now that the PostListing component's getting data from both our data sources, it's time to put them together in the right order. Still in the PostListing component, we can declare a useState Hook called fullPostList, which is what will be rendered in the component's JSX. And inside of a useEffect Hook, which will run on component load, we'll call a function named getAndFormatAllPosts() which will combine the localSitePosts queried via GraphQL from the Markdown files and the mediumBlogs list from the mediumBlogs.js file.

PostListing.js

  const [fullPostList, setFullPostList] = useState([]);

  // get all Markdown post date with this custom Hook
  const localSitePosts = usePostListingQuery();

  const getAndFormatAllPosts = (posts) => {
    const postList = posts.allMarkdownRemark.edges.map((postEdge) => {
      return {
        path: postEdge.node.fields.slug,
        tags: postEdge.node.frontmatter.tags,
        thumbnail: postEdge.node.frontmatter.thumbnail.childImageSharp.fixed,
        title: postEdge.node.frontmatter.title,
        subTitle: postEdge.node.frontmatter.subTitle,
        date: postEdge.node.fields.date,
        timeToRead: postEdge.node.timeToRead,
      };
    });
    // access the Medium blog list imported into the component
    const fullPostList = postList.concat(mediumBlogs);
    // sort the combined posts by date
    const sortedPostsList = sortArrayByDate(fullPostList); 
    // set the full array of posts into component state
    setFullPostList(sortedPostsList);
  };

  useEffect(() => {
    // combine and format all the blog posts on component load
    getAndFormatAllPosts(localSitePosts);
  }, []);

First, getAndFormatAllPosts() loops through all the Markdown posts fetched by the usePostListingQuery() Hook and creates a list of objects with properties to match the mediumBlogs' object properties.

Then, once all the localSitePosts are formatted, the mediumBlogs list is combined with them, and everything is sorted by date (that's the sortArrayByDate() helper function), and this full list is set in our local state Hook fullPostList.

Whew! Quite a lot happened here, but now we have a single list of objects with the same properties, time to filter them down.

Set up an input filter above the blog posts

Ok, there's our whole list of blogs, sorted and rendered in our PostListing component's JSX. The next step is adding an input at the top of the component that a user can type into.

If you're familiar with React and inputs, you know that the only way to register keystrokes in an input is with an onChange() function that takes in events from the DOM, and then updates the component's state based on those events. For our purposes, we need the component to use whatever's in the input and narrow down the blog posts visible based on their title, subTitle and tag properties.

Add variables for the query values

To help us know when to show the full list of posts versus a narrowed down list based on if the input is empty or not, we need to add a couple of new variables to our component. One is a constant named emptyQuery to set the input's value to an empty string when the component first loads, and the second is a new useState Hook that starts out as an object with an empty filteredPostList array and a query property that defaults to the value of emptyQuery.

PostListing.js

  const emptyQuery = '';
  const [state, setState] = useState({
    filteredPostList: [],
    query: emptyQuery
  });

Create a filterPosts() function

Our new variables enable us to write an onChange() function for the input that will let us narrow down the results and return them to the component to display.

We're going to name this function filterPosts(), and every time a new keystroke (event) is added to the input this function will run. It will start by setting the current event.target.value from the DOM equal to a variable locally scoped to the function named query.

Next, we'll take the fullPostList state and filter through all the posts looking to see which post.title, post.subTitles and post.tags match the query. Don't forget to use something like toLowerCase() to ensure case sensitivity doesn't accidentally disqualify a post from making the list.

Once the local query variable and new filteredPostList array have been created in the filterPosts() function, the component's state object is set with the new values.

PostListing.js

  const filterPosts = (event) => {
    const query = event.target.value;
    const filteredPostList = fullPostList.filter((post) => {
      return (
        post.title.toLowerCase().includes(query.toLowerCase()) ||
        (post.subTitle &&
          post.subTitle.toLowerCase().includes(query.toLowerCase())) ||
        (post.tags &&
          post.tags.join('').toLowerCase().includes(query.toLowerCase()))
      );
    });

    // set the component's state with our newly generated query and list variables
    setState({
      query,
      filteredPostList
    });
  };

This function's not so complicated, right? In reality we just have to check multiple properties in each post object in the array to see if our query exists in any of them. The post.tags is a little tricky because it's an array of strings instead of a single string, but simply using join('') on the tags before checking to see if the query is included takes care of this too.

Render the newly filtered list

The final step is to take our new filteredPostList and query pieces of state, determine if the query is not an empty array (which would mean the search input is empty), and based on that render either the fullPostList or filteredPostList in the component.

To do this part, we can create a new boolean called hasSearchResults that will simply indicate if the filteredPostList exists and the query is not an empty string, and based on this boolean, we'll use another local variable called posts that will tell the component which list to use.

This is how we'll use just one local variable, posts, to render our list of blog posts, regardless of which state variable is actually being rendered by the JSX (fullPostList or filteredPostList). Check out this code snippet to see what I mean.

PostListing.js

  const { filteredPostList, query } = state;
  const hasSearchResults = filteredPostList && query !== emptyQuery;
  const posts = hasSearchResults ? filteredPostList : fullPostList;

 return (
    <>
      {!posts.length && query === emptyQuery && (
        <span className="post-wrapper">
        <span className="post-search-wrapper page-body">
            <input
              className="searchInput"
              type="search"
              aria-label="Search"
              placeholder="Filter blog posts by title or tag"
              onChange={(e) => filterPosts(e)}
            ></input>
          </span>
          <div className="posts-wrapper wide-page-body">
            {posts.length ? (
              posts.map((post, index) => (
                {/* more JSX code to render posts */}

Pretty cool, huh?

Handle no results

There's one last scenario left to handle: if the search term in the input doesn't match any of the values in the blog posts. Luckily, that's fairly straightforward to deal with as well.

For this situation, we can let the JSX do the heavy lifting - no extra state variables required. When the possibility occurs that whatever input string a user has searched for doesn't exist in any of the posts being filtered through, we need to show no posts and let the user know nothing matches their search params.

The simplest way to do this in the JSX is by checking the posts.length, which we already do in order to map over all the blog posts and display them on the page. We'll take this statement's ternary and change it from rendering null if posts.length does not exist and make it render a message for the user instead.

Here's what the whole component's JSX looks like (slightly condensed for brevity): as long as the blog posts have loaded but the amount of ones filtered according to the user input is 0, the message displayed to the user says "Sorry, no search results match your query.".

PostListing.js

return (
  <>
    {!posts.length && query === emptyQuery && (
      <span className="post-wrapper">
        <span className="post-search-wrapper page-body">
          <input
            className="searchInput"
            type="search"
            aria-label="Search"
            placeholder="Filter blog posts by title or tag"
            onChange={(e) => filterPosts(e)}
          ></input>
        </span>
        <div className="posts-wrapper wide-page-body">
          {posts.length ? (
            posts.map((post, index) => (
              <article className="post" key={index}>
                <p className="post-date">
                  {post.date}&nbsp;
                  {'\u2022'} 
                  {post.timeToRead} min read
                </p>
                <Link to={`/blog${post.path}`} key={post.title}>
                  <p className="post-title">{post.title}</p>
                  <Img fixed={post.thumbnail} />
                  <p>{post.subTitle}</p>
                </Link>
                <PostTags tags={post.tags} />
              </article>
            ))
          ) : (
            <div className="empty-results">
              <h2>Sorry, no search results match your query.</h2>
            </div>
          )}
        </div>
      </span>
    </>
  )}
);

Isn't it nice how cleanly that can be handled with a ternary? I like it when React makes things easy for me.

Conclusion

Filtering and inputs are not the simplest thing to build when it comes to web development, and filters that check multiple properties in multiple objects for a match are even harder. But it can be done.

Giving users the ability to narrow down results based on their interests (which hopefully align with article titles, subtitles, and tags) is a really nice feature to offer, and it was a fun challenge to build in Gatsby. And, surprisingly, not as complicated as I originally expected it to be.

Check back in a few weeks — I’ll be writing more about JavaScript, React, ES6, or something else related to web development.

Thanks for reading. I hope you can put this custom built filter to use in your own React applications. I know I'm always grateful for filter inputs like this and I think your users will be too.

References & Further Resources

Want to be notified first when I publish new content? Subscribe to my newsletter.