How to Build a Related Posts Component with Gatsby.js

Here's how to build a "Related Posts" component for your Gatsby.js site.

Translated by readers to: Russian

You know how some blogs have a section after reading an article that says "Related Posts" or "You might also like" (hint: check the bottom of this article 😜).

I wanted to make something like that for my Gatsby site so I can present readers with other articles they might potentially be interested in.


Who is this article for?

This is a post for people who already know the basics of how to build sites with Gatsby.js + Netlify. If you're not familar with it yet, definitely check out Gatsby and their excellent tutorials. If you'd like ME PERSONALLY to educate you on the topic, make sure to nag me and I'll see what I can do.

How we're going to do it

Since this is a site about Design Patterns and Principles, I'm going to show you one way to build something like this using the very fancy Builder Pattern in a way that doesn't feel fancy but feels powerful.

High level explanation

On my site, I organize all of my blog posts as Articles.

Each Article has one Category and can have many Tags.

If you haven't already figured out how to set up tags and categorys, check out this cool guide on how to do that.

What I want to do is look at the Categories and Tags of all of my other articles and compute a similarity score with the current article to determine which articles are most similar to it.

Get Articles From a Graphql query

I've put together a SimilarArticles.js file that exposes a query (1.) of the same name.

The query returns every single article on my site, pulling in all the attributes I need from the article (or as I've included in my template-url, blog-post) markdown files to render an article, including the Category and Tags.

// SimilarArticles.js

import React from 'react'
import PropTypes from 'prop-types'
import { StaticQuery, graphql } from "gatsby"
import { getPostsFromQuery } from '../../../../utils/blog'
import ArticleCard from './ArticleCard'
import { SimilarArticlesFactory } from './SimilarArticlesFactory'
import "../styles/SimilarArticles.sass"

const SimilarArticlesComponent = ({ articles }) => (
  <section className="similar-articles">
    {articles.map((article, i) => (
      <ArticleCard {...article.article} key={i}/>
    ))}
  </section>
)

// (1.) Query for articles
export default (props) => (
  <StaticQuery
    query={graphql`
      query SimilarArticles {    
        posts: allMarkdownRemark(
          sort: { order: DESC, fields: [frontmatter___date] }
          filter: {
            frontmatter: {
              templateKey: { eq: "blog-post" }
              published: { eq: true }
            }
          }
          limit: 1000
        ) {
          edges {
            node {
              fields {
                slug
                readingTime {
                  text
                }
              }
              frontmatter {
                title
                date
                description
                tags
                category
                image
              }
            }
          }
        }
      }
    `}
    render={data => {
      const { category, tags, currentArticleSlug } = props;

      // (2.) Marshall the response into articles
      const articles = getPostsFromQuery(data.posts);

      // (3.) Use a SimilarArticlesFactory to get my similar articles
      const similarArticles = new SimilarArticlesFactory(
        articles, currentArticleSlug
      )
      .setMaxArticles(4)
      .setCategory(category)
      .setTags(tags)
      .getArticles()

      // (4.) Render it
      return (
        <SimilarArticlesComponent
          articles={similarArticles}
        />
      )
    }}
  />
)

After I get all of my queries, I marshall them all (2.) into actual articles.

The Graphql responses are a little bit nested. Since I query articles often, I wrote a utility function to strip articles from a query.

Rank articles with the SimilarArticlesFactory

At (3.), things get fun.

I pass in all of the articles in addition to the currentArticleSlug of THIS article.

From what gets returned, I'm able to call setMaxArticles(num: number), setCategories(category: string), and setTags(tags: string[]) before calling getArticles().

This is what's known as the builder pattern.

It works by returning this after calling the setter.

It's a Creational pattern that allows you to create objects in a more declarative way.

Let's look at the SimilarArticlesFactory.js.

// SimilarArticlesFactory.js
import { includes, orderBy } from 'lodash'

export class SimilarArticlesFactory {
  // (1.) Create by passing in articles, currentSlug
  constructor (articles, currentArticleSlug) {
    // (2.) Don't include the current article in articles list
    this.articles = articles.filter(
      (aArticle) => aArticle.slug !== currentArticleSlug);

    this.currentArticleSlug = currentArticleSlug;
    // (3.) Set default values
    this.maxArticles = 3;
    this.category = null;
    this.tags = []
  }

  // (4.) Builder pattern usage
  setMaxArticles (m) {
    this.maxArticles = m;
    return this;
  }

  setCategory (aCategory) {
    this.category = aCategory;
    return this;
  }

  setTags (tagsArray) {
    this.tags = tagsArray;
    return this;
  }

  getArticles () {
    const { category, tags, articles, maxArticles } = this;
    // (5.) We use an Identity Map to keep track of score
    const identityMap = {};

    if (!!tags === false || tags.length === 0) {
      console.error('SimilarArticlesFactory: Tags not provided, use setTags().')
      return [];
    }

    if (!!category === false) {
      console.error('SimilarArticlesFactory: Category not provided, use setCategory().')
      return [];
    }

    function getSlug (article) {
      return article.slug;
    }

    function addToMap (article) {
      const slug = getSlug(article);
      if (!identityMap.hasOwnProperty(slug)) {
        identityMap[slug] = {
          article: article,
          points: 0
        }
      }
    }

    // (7.) For category matches, we add 2 points
    function addCategoryPoints (article, category) {
      const categoryPoints = 2;
      const slug = getSlug(article);

      if (article.category === category) {
        identityMap[slug].points += categoryPoints;
      }
    }

    // (8.) For tags matches, we add 1 point
    function addTagsPoints (article, tags) {
      const tagPoint = 1;
      const slug = getSlug(article);
      
      article.tags.forEach((aTag) => {
        if (includes(tags, aTag)) {
          identityMap[slug].points += tagPoint;
        }
      })
    }

    function getIdentityMapAsArray () {
      return Object.keys(identityMap).map((slug) => identityMap[slug]);
    }
    
    // (6.) Map over all articles, add to map and add points
    for (let article of articles) {
      addToMap(article);
      addCategoryPoints(article, category);
      addTagsPoints(article, tags)
    }
    
    // (9.) Convert the identity map to an array
    const arrayIdentityMap = getIdentityMapAsArray();

    // (10.) Use a lodash utility function to sort them 
    // by points, from greatest to least
    const similarArticles = orderBy(
      arrayIdentityMap, ['points'], ['desc']
    )

    // (11. Take the max number articles requested)
    return similarArticles.splice(0, maxArticles);
  }
}

The most interesting parts of this class are the setters, that return this, which allows that nice method chaining we saw earlier, and the getArticles() method.

We mentioned that we wanted to score articles based on similarity, right?

Well, this is one way we can do it.

I map over all of the articles (6.) and add them to an Identity Map (which is a fancy term for a hash table or a JavaScript object).

Identity Map

The identity map ends up looking a bit like this. An object with slugs as it's keys.

The way we identify two articles is by their slug.

While I'm doing that, I also look at the category for each article (7.).

If the current article has the category in common with this article that we're looping over, then we give it 2 points.

We do the same thing for tags (8.) but instead, we give the article 1 point for each tag that it has that's also in the current article.

At the end, we turn the entire thing into an array (9.) and then sort all of the articles (10. using lodash) from most to least points before slicing off the maxArticles requested, which was 4.


That's pretty much it! That's how you can build your own Similar Articles/Posts component with Gatsby.js.

It's a pretty common thing to see on blogs but I haven't seen it much in other Gatsby.js sites, because it doesn't come right out of the box.


Need more?

Scroll down a little bit to see it in action!

Need even more?

You can view all of the source code for this site on GitHub, it's fully open-sourced.



Discussion

Liked this? Sing it loud and proud πŸ‘¨β€πŸŽ€.


9 Comments

Submit
Max
a year ago

This is exactly what I needed. Thanks!!

John
a year ago

This is very nice

sravya
9 months ago

It goes without saying I am more confident how fast a static site is but I wish there were more tools available for static site generators. Relieved a friend recommended INK FOR ALL, it has a setting for exporting pages as markdown docs. It also helps with search engine optimization: http://bit.ly/2ECXoDa


Sophia
9 months ago

hello

If static site generators like Jekyll and Hugo are so fashionable, then why aren’t there more tools available for them? For now, my favorite option is the INK for ALL word processor: http://bit.ly/2ECXoDa

Ingo
8 months ago

I've been looking for ideas for exactly this requirement. Thanks!

Roy
8 months ago

Nice, this is what I was looking for. Thanks!

John
7 months ago

Hey ik new to gatsbyjs, great post! But question perhaps you can help me with. Whats the efficiency of this approuch? Does the computing of getting all related post happen on the client side or server side? If on the client side then wouldnt it be very inefficient if you have Thousands of blog posts? Wouldnt it be more interesting if we can have a dynamic graphql query that fetches related posts based on some variables during build time ?

Beto
4 months ago

Omg, what a clever solution. Thank you very much for the inspiration

Laurent
4 months ago

We've recently moved our blog to Gatsby and this article covers exactly what we need!

I'll now implement it ;-)

Thanks!

Laurent - simplebackups.io


Stay in touch!



About the author

Khalil Stemmler,
Developer Advocate @ Apollo GraphQL ⚑

Khalil is a software developer, writer, and musician. He frequently publishes articles about Domain-Driven Design, software design and Advanced TypeScript & Node.js best practices for large-scale applications.



View more in Web Development



You may also enjoy...

A few more related articles

How To Optimize Your Gatsby Blog For SEO
Gatsby is a great tool for creating static websites, landing pages, and blogs. This post will show you how you can optimize your b...
Over $85 billion spent on fixing bad code [bootcamps, junior devs, JavaScript, and software design principles]
More and more money is being spent by companies on maintaining bad JavaScript code. Here's where I think our industry needs to cha...
TypeScript vs. JavaScript [Pros / cons]
TypeScript is arguably one of the best things to come to the JavaScript world helping developers comfortably create complex enterp...
Cleaner Code with Async / Await Tutorial
Asynchronous programming in JavaScript used to be difficult to write clean code with. Today we have options. You can write asynchr...

Want to be notified when new content comes out?

Join 5000+ other developers learning about Domain-Driven Design and Enterprise Node.js.

I won't spam ya. πŸ–– Unsubscribe anytime.

Get updates