Add Related Posts Feature to a Gatsby Blog

Published 06 Feb 2022 · 14 min read
Learn how to add a related posts feature to a Gatsby blog with markdown.

This post will explain how to add a Related Posts feature to a Gatsby blog. The idea is to add a small number of links at the bottom of a post page, to other posts that the reader may be interested in. The display of these could be just simple text links, or include a small version of the feature photo and the title. For example, at the end of a post about Gatsby, display a few other articles about Gatsby like this:

gatsby related posts example

The related posts will be curated, that is, the author of the blog is responsible for selecting related articles for each post and populating them in the markdown (we'll be getting into the details of this shortly). If you are looking for a more automated solution, checkout the gatsby-remark-related-posts plugin. This plugin didn't suit my use case as I wanted the flexibility to occasionally choose tangential content that a text-based similarity algorithm may not select. I also found it valuable as a learning exercise to build this out myself rather than relying on a plugin.

Existing Post Page

Before getting into adding Related Posts, let's briefly look at how a typical post page is built with Gatsby using the gatsby-transformer-remark plugin. If you already have a solid understanding of Gatsby and the remark plugin, feel free to skip ahead to the adding related posts section.

This remark plugin allows you to write posts in markdown, and then exposes fields that it generates such as html and excerpt in the GraphQL server during development, under the node type allMarkdownRemark. It also exposes any frontmatter fields you add at the top of your markdown (content delimited by -- symbols).

For example, given the following project layout:

.
├── README.md
├── gatsby-config.js
├── gatsby-node.js
├── package.json
├── src
│   ├── components
│   │   ├── article.js
│   │   ├── footer.js
│   │   ├── header.js
│   │   ├── layout.js
│   ├── markdown
│   │   ├── here-is-a-post.md
│   │   ├── another-post.md
│   │   ├── yet-another-post.md
│   ├── pages
│   │   ├── index.js
│   ├── templates
│   │   ├── post.js

And some markdown content in markdown/here-is-a-post.md:

---
title: "Here is an example markdown post"
description: "This is a sample description for the post."
date: "2022-02-15"
category: "example"
featuredImage: "../images/example.jpg"
---

And here is the sample post. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. In nulla posuere sollicitudin aliquam ultrices sagittis orci a scelerisque.

The following GraphQL query would find this post and return all its data. Notice that all the fields in the markdown frontmatter can be exposed from the schema via edges -> node -> frontmatter. To execute queries in Gatsby during development, navigate to http://localhost:8000/___graphql:

{
  allMarkdownRemark(
    # limit results to only the example post
    filter: { frontmatter: { title: { eq: "Here is an example markdown post" } } }
  ) {
    edges {
      node {
        # These fields come from the top of markdown/here-is-a-post.md
        frontmatter {
          title
          description
          category
          date
          # Images are a little more complicated, making use of gatsby-plugin-sharp and gatsby-transformer-sharp plugins.
          featuredImage {
            childImageSharp {
              gatsbyImageData(width: 900, layout: CONSTRAINED)
            }
          }
        }
        # This is generated by the gatsby-transformer-remark plugin,
        # which converts the markdown content to html.
        html
      }
    }
  }
}

And here is the resulting JSON response from the GraphQL server. Notice that it pretty much mirrors the structure of the query, with the exception of being wrapped in a data object and the featuredImage field containing more details for rendering responsive images:

{
  "data": {
    "allMarkdownRemark": {
      "edges": [
        {
          "node": {
            "frontmatter": {
              "title": "Here is an example markdown post",
              "description": "This is a sample description for the post.",
              "category": "example",
              "date": "2022-02-15",
              "featuredImage": {
                "childImageSharp": {
                  "gatsbyImageData": {
                    "layout": "constrained",
                    "images": {
                      "sources": [
                        {
                          "srcSet": "/static/67f.../71a10/example.webp 225w,\n/static/67f.../901f1/example.webp 450w,\n/static/67f.../5acd1/example.webp 900w",
                          "type": "image/webp",
                          "sizes": "(min-width: 900px) 900px, 100vw"
                        }
                      ]
                    },
                    "width": 900,
                    "height": 599
                  }
                }
              }
            },
            "html": "<p>And here is the sample post. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. In nulla posuere sollicitudin aliquam ultrices sagittis orci a scelerisque.</p>..."
          }
        }
      ]
    }
  }
}

Retrieve all Posts

We've just seen an example GraphQL query and JSON response to retrieve a single post. But for building a blog, all the posts must be retrieved, and then fed one at a time to the post template for rendering. This is performed by gatsby-node.js, a file that sits at the project root. It exports a createPages function, which the Gatsby build process will invoke.

The createPages function runs a GraphQL query via a promise, to retrieve all post content in the markdown files. The promise result handler will pass these results one at a time to the createPage function. This function receives an object specifying the component, which is the path to the template to be rendered, and a context, which can contain anything, but practically speaking will contain some results of the GraphQL query that has just resolved. In this case, the post slug is passed in via context, which will then be available to the template to execute a GraphQL query to lookup details about just this $slug.

// gatsby-node.js

exports.createPages = ({ graphql, actions }) => {
  // A Gatsby function which will be used to create the post pages from GraphQL data and a template.
  const { createPage } = actions

  // This gets ALL markdown posts, sorting by most recent date first.
  // We only need to retrieve the slug so that the post template can use this
  // to lookup all the other content from in a page query (see next section).
  return new Promise(resolve => {
    graphql(`
      {
        allMarkdownRemark(sort: { fields: [frontmatter___date], order: DESC }) {
          edges {
            node {
              fields {
                slug
              }
            }
          }
        }
      }
    `).then(result => {
      result.data.allMarkdownRemark.edges.forEach(({ node }) => {
        createPage({
          path: node.fields.slug,
          component: path.resolve("./src/templates/post.js"),
          // any field provided here in context will be available to the post.js template
          // to be used in a page query to retrieve further details about this post.
          context: {
            slug: node.fields.slug,
          },
        })
      })
      resolve()
    })
  })
}

Post Template

The Post template, which is a React component, receives this JSON data as props and renders it. It does so by running a GraphQL page query at build time, using the slug field provided to it by the createPage function implemented in gatsby-node.js. This query is doing the GraphQL equivalent of something like this SQL statement: SELECT html, title, date... FROM markdown WHERE slug = $slug.

Somewhat confusingly, the page query is written after the rendering code in the template. I'm going to focus in on just the query, and come back to the rendering code in a bit:

// src/templates/post.js

const Post = props => {
  return (
    ...
  )
}
export default Post

// This is called once for each markdown file from gatsby-node.js.
// The $slug paramter is available because of the context that was
// passed in to the createPage function in gatsby-node.js.
export const query = graphql`
  query($slug: String!) {
    markdownRemark(fields: { slug: { eq: $slug } }) {
      html
      frontmatter {
        title
        date
        featuredImage {
          childImageSharp {
            gatsbyImageData(width: 900, layout: CONSTRAINED)
          }
        }
      }
      fields {
        slug
      }
    }
  }
`

Finally, the results of the page query are exposed to the Post component in its props via the data property. For example, the markdown html is available at props.data.markdownRemark.html, whereas the title is available at props.data.markdownRemark.frontmatter.title. The Post component extracts these properties and renders them using React.

For example, this Post template renders the title, publish date, a featured image and html content generated from the markdown:

// src/templates/post.js
import React from "react"
import { graphql } from "gatsby"
import { GatsbyImage } from "gatsby-plugin-image"
import Layout from "../components/layout"

const Post = props => {
  const markdown = props.data.markdownRemark
  const title = markdown.frontmatter.title
  const publishedDate = markdown.frontmatter.date
  const featuredImgFluid = markdown.frontmatter.featuredImage.childImageSharp.gatsbyImageData
  const content = markdown.html

  return (
    <Layout>
      <article>
        <h1>{title}</h1>
        <div>Published {publishedDate}</div>
        <GatsbyImage image={featuredImgFluid} />
        <div dangerouslySetInnerHTML={{ __html: content }} />
      </article>
    </Layout>
  )
}
export default Post

export const query = graphql`
  query($slug: String!) {
    ...
  }
`

Now that we have an understanding of how a basic post page gets rendered from Markdown content in Gatsby, we can move on to enhancing this process with a Related Posts feature.

Probably the most important aspect to understand when adding a new feature to a Gatsby/Markdown site is that you can add anything you want to the frontmatter fields (top section of markdown file contained within ---). There's nothing special about the fields I've shown you so far such as title, description, etc.

Customizing Frontmatter

To demonstrate this, let's add a nonsense field that has nothing to do with blogging such as favColor to the example post shown earlier:

---
title: "Here is an example markdown post"
description: "This is a sample description for the post."
date: "2022-02-15"
category: "example"
featuredImage: "../images/example.jpg"
favColor: "purple"
---

And here is the sample post. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. In nulla posuere sollicitudin aliquam ultrices sagittis orci a scelerisque.

Now let's modify the GraphQL query shown earlier that fetches this markdown post, to include the nonsense field favColor. I'm leaving out the generated html field and image field for simplicity:

{
  allMarkdownRemark(
    # limit results to only the example post
    filter: { frontmatter: { title: { eq: "Here is an example markdown post" } } }
  ) {
    edges {
      node {
        # These fields come from the top of markdown/here-is-a-post.md
        frontmatter {
          title
          description
          category
          date
          favColor
        }
      }
    }
  }
}

And here is the result - notice the new field favColor in the results:

{
  "data": {
    "allMarkdownRemark": {
      "edges": [
        {
          "node": {
            "frontmatter": {
              "title": "Here is an example markdown post",
              "description": "This is a sample description for the post.",
              "category": "example",
              "date": "2022-02-15",
              "favColor": "purple"
            }
          }
        }
      ]
    }
  }
}

Now that we've verified anything can be added to the frontmatter fields, let's add something useful.

Frontmatter Arrays

Another important concept to understand is the frontmatter field values are not limited to strings. An array value can be created by nesting a list of values with -. This is exactly what's needed to define a related field, as each markdown post will have three related posts.

Here is the markdown/here-is-a-post.md markdown file, with the nonsense field favColor replaced with a related field containing an array value, which specifies the title of three other markdown posts:

---
title: "Here is an example markdown post"
featuredImage: "../images/example.jpg"
description: "This is a sample description for the post."
date: "2022-02-15"
category: "example"
related:
  - "Related Post 1"
  - "Related Post 2"
  - "Related Post 3"
---

And here is the sample post. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. In nulla posuere sollicitudin aliquam ultrices sagittis orci a scelerisque.

Now let's run a query to fetch the example post, this time including the new related array field:

{
  allMarkdownRemark(
    # limit results to only the example post
    filter: { frontmatter: { title: { eq: "Here is an example markdown post" } } }
  ) {
    edges {
      node {
        # These fields come from the top of markdown/here-is-a-post.md
        frontmatter {
          title
          description
          category
          date
          related
        }
      }
    }
  }
}

And here are the results - notice that the related field contains an Array of strings, representing the titles of the related posts:

{
  "data": {
    "allMarkdownRemark": {
      "edges": [
        {
          "node": {
            "frontmatter": {
              "title": "Here is an example markdown post",
              "description": "This is a sample description for the post.",
              "category": "example",
              "date": "2022-02-15",
              "related": [
                "Related Post 1",
                "Related Post 2",
                "Related Post 3"
              ]
            }
          }
        }
      ]
    }
  }
}

Assume there are an additional three markdown files titled "Related Post 1", 2 and 3 such as:

---
title: "Related Post 1"
featuredImage: "../images/example.jpg"
description: "This is a sample description for related post 1."
date: "2022-02-14"
category: "example"
---

And here is the sample post. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. In nulla posuere sollicitudin aliquam ultrices sagittis orci a scelerisque.

Update Retrieval of all Posts

Recall the post template contains a page query that queries for the current content using the $slug value that was passed in from gatsby-node.js. The template will also need to query for the related posts. In order for this information to be available, gatsby-node.js must be modified to include the related field when its fetching all posts, and then provide this as context to the post template:

// gatsby-node.js

exports.createPages = ({ graphql, actions }) => {
  // A Gatsby function which will be used to create the post pages from GraphQL data and a template.
  const { createPage } = actions

  // This gets ALL markdown posts, sorting by most recent date first.
  // ### NEW: Retrieve custom frontmatter field `related` here ###
  return new Promise(resolve => {
    graphql(`
      {
        allMarkdownRemark(sort: { fields: [frontmatter___date], order: DESC }) {
          edges {
            node {
              fields {
                slug
              },
              frontmatter {
                related
              }
            }
          }
        }
      }
    `).then(result => {
      result.data.allMarkdownRemark.edges.forEach(({ node }) => {
        createPage({
          path: node.fields.slug,
          component: path.resolve("./src/templates/post.js"),
          // any field provided here in context will be available to the post.js template
          // to be used in a page query to retrieve further details about this post.
          context: {
            slug: node.fields.slug,
            // ### NEW: Pass relatedPosts array to the template here ###
            relatedPosts: node.frontmatter.related
          },
        })
      })
      resolve()
    })
  })
}

Querying for Multiple Results

The post template, will need to have its page query modified. In addition to querying for the specific markdown content to be displayed, now it must also query the relatedPosts field (provided by gatsby-node.js) to fetch each related posts title, featured image, and slug for linking.

Up until now, the example queries I've been showing you have either retrieved all posts (as used by gatsby-node.js) or have retrieved just a single post using the GraphQL eq filter. But now, the post template must query for the related posts, which is an array of string post titles. Fortunately, GraphQL has an in filter to do this. For example, the following query will fetch post information for the three related posts:

{
  allMarkdownRemark(
    # limit results to the specified posts using `in` filter
    filter: { frontmatter: { title: { in: [
      "Related Post 1",
      "Related Post 2",
      "Related Post 3"
    ] } } }
  ) {
    edges {
      node {
        frontmatter {
          title
          description
          category
          date
        }
      }
    }
  }
}

Here are the results. Notice that the edges array now contains multiple results, whereas previously when using the eq filter, it contained a list of only a single result:

{
  "data": {
    "allMarkdownRemark": {
      "edges": [
        {
          "node": {
            "frontmatter": {
              "title": "Related Post 1",
              "description": "This is a sample description for related post 1.",
              "category": "example",
              "date": "2022-02-14"
            }
          }
        },
        {
          "node": {
            "frontmatter": {
              "title": "Related Post 2",
              "description": "This is a sample description for related post 2.",
              "category": "example",
              "date": "2022-02-13"
            }
          }
        },
        {
          "node": {
            "frontmatter": {
              "title": "Related Post 3",
              "description": "This is a sample description for related post 3.",
              "category": "example",
              "date": "2022-02-12"
            }
          }
        }
      ]
    }
  }
}

Update Post Template Page Query

Armed with all this knowledge, we can now update the post template page query to fetch each related post title, featured image, and slug for linking. There is no need to fetch the html content as the related posts only display an image and title. It will use the in filter as described in the previous section.

Notice that the query now accepts two parameters - the $slug as before, and $relatedPosts, which is denoted as an array with [String!]. This is possible because earlier we modified the retrieval of all posts to provide this as context.

// src/templates/post.js

const Post = props => {
  return (
    ...
  )
}
export default Post

// This is called once for each markdown file from gatsby-node.js.
// $slug and $relatedPosts paramters are available because of the context that was
// passed in to the createPage function in gatsby-node.js.
export const query = graphql`
  query($slug: String!, $relatedPosts: [String!]!) {
    markdownRemark(fields: { slug: { eq: $slug } }) {
      html
      frontmatter {
        title
        date
        featuredImage {
          childImageSharp {
            gatsbyImageData(width: 900, layout: CONSTRAINED)
          }
        }
      }
      fields {
        slug
      }
    }
    relatedP: allMarkdownRemark(
      filter: { frontmatter: { title: { in: $relatedPosts } } }
    ) {
      edges {
        node {
          id
          frontmatter {
            title
            featuredImage {
              childImageSharp {
                gatsbyImageData(width: 270, height: 150, layout: FIXED)
              }
            }
          }
          fields {
            slug
          }
        }
      }
    }
  }
`

Notice that the new relatedP section is being defined as a sibling to the existing markdownRemark section. The resulting response object will still be wrapped in a data field as before, but will now contain both a markdownRemark field containing the current post content, and a relatedP section, containing an array of edges, representing the list of related posts. This is the power of GraphQL that you can define any structure you'd like the response object to have.

Just to visualize the data that is now available to the page template, we can execute an example query that the template would be running for one specific post, leaving out the generated html and image fields for simplicity. Sorry about all the brackets, that's the syntax of the GraphQL filter:

{
  markdownRemark(fields: { slug: { eq: "/blog/here-is-a-post/" } }) {
    frontmatter {
      title
      date
      description
    }
    fields {
      slug
    }
  }
  relatedP: allMarkdownRemark(
    filter: { frontmatter: { title: { in: [
      "Related Post 1",
      "Related Post 2",
      "Related Post 3"
    ] } } }
  ) {
    edges {
      node {
        frontmatter {
          title
        }
        fields {
          slug
        }
      }
    }
  }
}

Here is the result of the above query:

{
  "data": {
    "markdownRemark": {
      "frontmatter": {
        "title": "Here is an example markdown post",
        "date": "2022-02-15",
        "description": "This is a sample description for the post."
      },
      "fields": {
        "slug": "/blog/here-is-a-post/"
      }
    },
    "relatedP": {
      "edges": [
        {
          "node": {
            "frontmatter": {
              "title": "Related Post 1"
            },
            "fields": {
              "slug": "/blog/related-post-1/"
            }
          }
        },
        {
          "node": {
            "frontmatter": {
              "title": "Related Post 2"
            },
            "fields": {
              "slug": "/blog/related-post-2/"
            }
          }
        },
        {
          "node": {
            "frontmatter": {
              "title": "Related Post 3"
            },
            "fields": {
              "slug": "/blog/related-post-3/"
            }
          }
        }
      ]
    }
  }
}

Update Post Template JSX

Now that we know the shape of the new data available to the post template from its page query, the template JSX can be updated to render it. Specifically, we will pass the data as props to a new RelatedPosts component (described in next section), to prevent the existing Post Template from getting too cluttered:

// src/templates/post.js
import React from "react"
import { graphql } from "gatsby"
import { GatsbyImage } from "gatsby-plugin-image"
import Layout from "../components/layout"
// ### NEW: Component to render related posts
import RelatedPosts from "../components/related-posts"

const Post = props => {
  const markdown = props.data.markdownRemark
  const title = markdown.frontmatter.title
  const publishedDate = markdown.frontmatter.date
  const featuredImgFluid = markdown.frontmatter.featuredImage.childImageSharp.gatsbyImageData
  const content = markdown.html
  // ### NEW: Extract related posts from page query
  const related = props.data.relatedP

  return (
    <Layout>
      <article>
        <h1>{title}</h1>
        <div>Published {publishedDate}</div>
        <GatsbyImage image={featuredImgFluid} />
        <div dangerouslySetInnerHTML={{ __html: content }} />
      </article>
      <RelatedPosts related={related} />
    </Layout>
  )
}
export default Post

export const query = graphql`
  query($slug: String!, $relatedPosts: [String!]!) {
    ...
  }
`

The last step in the process is to define a RelatedPosts component. This component will iterate over the posts it receives as props from the post template page and render their titles and images as links, with the target of the link being the post slug:

// src/components/related-posts.js

import React from "react"
import { Link } from "gatsby"
import { GatsbyImage } from "gatsby-plugin-image"

const RelatedPosts = props => (
  <section>
    <h2>You may also like...</h2>
    <div>
      {props.related.edges.map(post => (
        <Link key={post.node.id} to={post.node.fields.slug}>
          <GatsbyImage image={post.node.frontmatter.featuredImage.childImageSharp.gatsbyImageData} />
          <p>{post.node.frontmatter.title}</p>
        </Link>
      ))}
    </div>
  </section>
)

export default RelatedPosts

Conclusion

This post has covered the basics of building blog post pages using Gatsby and the gatsby-transformer-remark plugin and how to modify the process to add a related posts feature. This involves customizing the markdown frontmatter to include an array of post titles that are related to the current post, modifying the outer query run by the gatsby-node.js build process to pass the related content to the post template, then modifying the post template query and rendering to retrieve and render the related posts.