Add Related Posts Feature to a Gatsby Blog
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:
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.
Adding Related Posts
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!]!) {
...
}
`
Related Posts Component
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.