My TypeScript Software Design & Architecture book just prelaunched! Check out solidbook.io.
Close

How to Prerender Comments | Gatsbyjs Guide

Oct 25th, 2019 / 12 min read / Share / Edit on GitHub
Prerendering dynamic data can have several advantages. For Gatsby blogs with high engagement, comments can positively impact SEO, helping people find the content found in conversations that they search for in Google. In this article, we look at how to prerender comments on your Gatsby site.

When Disqus comments stopped being awesome, I was left without comments on my blog for a while.

Not too long after, Tania Rascia wrote an awesome guide on how to "Roll Your Own Comment" system.

I hooked it up and people started leaving (mostly) productive comments.

Today, I use the comments to answer questions about Domain-Driven Design, Clean Architecture, and Enterprise Node.js with TypeScript. They've been helpful in informing me what I should spend the majority of my time writing about.

Not only that, but I tend to get some really good questions that really force me to think, and I enjoy the challenge of trying to make myself understood.


At some point I realized that lots of people might be asking the same questions that I get asked on my blog, so it would be a good idea if Google was able to see the comments when they index my site.

Because the comments are dynamic and get loaded by the client for each route, when Google crawls my site, it doesn't wait for the comments to load.

This is the problem that prerendering solves.

In this article, I'm going to show you how you can prerender comments on your Gatsby blog.

Prerequisites

Create a GET /comments/all API route

We're going to need to pull in all the comments everytime we build our Gatsby site.

This should be relatively straightforward if you've followed Tania's guide. A simple SELECT * will do just fine. And when you get a lot of comments, it would make sense to paginate the responses.

You might not need my help here, but for context, I'll show you how I did mine.

Note: I use the Clean Architecture approach to separate the concerns of my code. Depending on amount of stuff your backend does, it might not be necessary.

Using the repository pattern to encapsulate potential complexity of interacting with a relational database, we can retrieve comments from our MySQL db by executing a raw query and mapping the results into Comment domain objects.

comments/infra/repos/implementations/commentRepo.ts
export class CommentRepo implements ICommentRepo {
  ...
  getAllComments (): Promise<Comment[]> {
    const query = `SELECT * FROM comments ORDER BY created_at DESC;`;
    return new Promise((resolve, reject) => {
      this.conn.query(query, (err, result) => {
        if (err) return reject(err);
        return resolve(result.map((r) => CommentMap.toDomain(r)));
      })
    })
  }
}
comments/domain/commentMap.ts
import { Comment } from "../models/Comment";

export class CommentMap {
  public static toDomain (raw: any): Comment {
    return {
      id: raw.id,
      name: raw.name,
      comment: raw.comment,
      createdAt: raw.created_at,
      url: raw.url,
      approved: raw.approved === 0 ? false : true,
      parentCommentId: raw.parent_comment_id
    }
  }
}

Then, all I have to do is create a new use case called GetAllComments that does just that- gets all comments.

comments/useCases/getAllComments/getAllComments.ts
import { ICommentRepo } from "../../../repos/CommentRepo";

export class GetAllComments {
  private commentRepo: ICommentRepo;

  constructor (commentRepo: ICommentRepo) {
    this.commentRepo = commentRepo;
  }

  async execute (): Promise<any> {
    const comments = await this.commentRepo.getAllComments();
    return comments;
  }
}

Now I'll write the controller:

comments/useCases/getAllComments/getAllCommentsController.ts
import { GetAllComments } from "./GetAllComments";

export const GetAllCommentsController = (useCase: GetAllComments) => {
  return async (req, res) =>  {
    try {
      const comments = await useCase.execute();
      return res.status(200).json({ comments })
    } catch (err) {
      return res.status(500).json({ message: "Failed", error: err.toString() })
    }
  }
}

Hook everything up with some manual Dependency Injection and then export the controller.

comments/useCases/getAllComments/index.ts
import { GetAllComments } from "./GetAllComments";
import { commentRepo } from "../../../repos";
import { GetAllCommentsController } from "./GetAllCommentsController";

const getAllComments = new GetAllComments(commentRepo);
const getAllCommentsController = GetAllCommentsController(getAllComments);

export {
  getAllCommentsController
}

Finally, I'll hook the controller up to our comments API (Express.js route).

app.ts
import express from 'express';
import { getAllCommentsController } from '../../../useCases/admin/getAllComments';

const commentsRouter = express.Router();

...

commentsRouter.get('/all', 
  (req, res) => getAllCommentsController(req, res)
);

...

export {
  commentsRouter
}

Testing fetching the comments

Push and deploy that code then try to get your comments! Here's what it looks like for me.

Nice! Now that we've created the data source, we need to create a plugin for Gatbsy so that it knows how to fetch it and then insert it into Gatsby's data layer so that we can prerender comments at build time.

Creating a source plugin to source comments into Gatsby's data layer

A source plugin is one of Gatsby's two types of plugins. Source plugins simply pull in data from local or remote locations.

Essential Gatsby reading: "Creating a Source Plugin".

Setup

As per the docs, we'll create a folder called plugins.

mkdir plugins

Inside that folder, let's create another folder. This will be the name of the local plugin that we're about to write.

In order to not think about it, the docs also have a reference on naming plugins.

Let's name our plugin gatsby-source-self-hosted-comments.

cd plugins
mkdir gatsby-source-self-hosted-comments

In the new subfolder, let's initialize it as an npm project, add a few dependencies, and create a gatsby-node file.

cd gatsby-source-self-hosted-comments
npm init -y
npm install --save axios
touch gatsby-node.js

Writing the plugin

The plugin needs to do two things.

  1. Fetch the comments from our API.
  2. Iterate through each comment and create a Comment graphql node for it.
plugins/gatsby-source-self-hosted-comments/gatsby-node.js
const axios = require('axios');
const crypto = require('crypto');

/**
 * @desc Marshalls a comment into the format that
 * we need it, and adds the required attributes in
 * order for graphql to register it as a node.
 */

function processComment (comment) {
  const commentData = {
    name: comment.name,
    text: comment.comment,
    createdAt: comment.createdAt,
    url: comment.url,
    approved: comment.approved,
    parentCommentId: comment.parentCommentId,
  }

  return {
    ...commentData,
    // Required fields.
    id: comment.id,
    parent: null,
    children: [],
    internal: {
      type: `Comment`,
      contentDigest: crypto
        .createHash(`md5`)
        .update(JSON.stringify(commentData))
        .digest(`hex`),
    }
  }
}

exports.sourceNodes = async ({ actions }, configOptions) => {
  const { createNode } = actions
  // Create nodes here.
  try {
    // We will include the API as a gatsby-config option when we hook the
    // plugin up. 
    const apiUrl = configOptions.url;
    // Fetch the data
    const response = await axios.get(apiUrl);
    const comments = response.data.comments;
    // Process data into nodes.
    comments.forEach(comment => createNode(processComment(comment)))
  } catch (err) {
    console.log(err);
  }

  return
} 

Tell Gatsby to use the plugin

In order to use the newly written plugin, we need to add it to our gatsby-config.js in the root folder of our project.

The name that we use is the name of the folder that we created in plugins/; that is- gatsby-source-self-hosted-comments.

gatsby-config.js
{
  ...
  plugins: [
    {      resolve: `gatsby-source-self-hosted-comments`,      options: {        url: 'https://khalil-stemmler-backend.herokuapp.com/comments/all/'      }    },  ...
}

Test retrieving comments from Gatsby with the GraphiQL explorer

Gatsby comes with a GraphQL explorer that we can use to see the current data in Gatsby's data layer.

In order to bring it up, let's first clear Gatsby's cache by running gatsby clean and then starting Gatsby locally with gatsby develop.

If you navigate to localhost:8000/__graphql, we can run an allComment query to return all the comments.

{
  allComment {
    edges {
      node {
        name
        parentCommentId
        text
        url
        createdAt
        approved
      }
    }
  }
}

If all is well, you should see your comments!

Lovely.

Loading comments into Gatsby on startup was the first step. Now we need to write some queries and hook up our prerendered comments to the blog post template.

Updating the Blog Post template to load comments

Originally, the only thing the blog post template needed to load was the blog post that matches the $id provided at build time as context variables.

Now, we also want to load the comments.

We can load them both by aliasing the markdownRemark as post and aliasing the allComment query as comments.

templates/blog-post.js
export const pageQuery = graphql`
  query BlogPostByID($id: String!) {
    post: markdownRemark(id: { eq: $id }) {      id
      html
      fields {
        slug
        readingTime {
          text
        }
      }
      frontmatter {
        date
        updated
        title
        templateKey
        description
        tags
        image
        category
        anchormessage
      }
    }
    comments: allComment {      edges {        node {          ...CommentFields        }      }    }  }
`

In the same file, we do 3 things to handle the query.

  1. We deconstruct the post from props.data
  2. We get the comments from the current blog post by filtering in on the slug.
  3. We pass the comments to our Article component.
templates/blog-post.js
const BlogPost = (props) => {
  const { post } = props.data  const { fields, frontmatter, html } = post;  const { slug } = fields;
  const {
    title,
    image,
    description,
    date,
    updated,
    category,
    tags
  } = frontmatter;

  const comments = props.data.comments.edges    .filter((c) => slug.indexOf(c.node.url) !== -1)    .map((c) => ({ ...c.node}));
  let seoTags = tags ? tags : [];
  seoTags = seoTags.concat(category);

  return (
    <Layout
      seo={{
        title,
        keywords: seoTags,
        image,
        description,
        pageType: PageType.ARTICLE,
        datePublished: date,
        dateModified: updated,
        slug,
        cardSize: TwittterCardSize.SMALL
      }}
    >
      <div className="article-layout-container">
        <Article
          {...fields}
          {...frontmatter}
          html={html}
          comments={comments}        />
        <ArticleSideContent/>
      </div>
    </Layout>
  )
}

Presenting prerendered data on the server and live data in production

The goal for us is to ensure that when the site is built on the server, it renders the pre-loaded content. This is what's good for SEO. That's the whole reason why we're doing this.

But we also want to make sure that when someone lands on a blog post, they're seeing the most up to date comments.

In the Article component, we feed the comments through to a Comments component.

article.js
export class Article extends React.Component {
  ...
  render () {
    return (
      <div>
        ...
        <Comments comments={comments}/>      </div>
    )
  }
}

In the Comments component is where the action happens.

Here's the gist of it.

comments.js
import PropTypes from 'prop-types'
import React from 'react';
import Editor from './Editor';
import Comment from './Comment';
import { TextInput } from '../../shared/text-input';
import { SubmitButton } from '../../shared/buttons';
import "../styles/Comments.sass"
import { commentService } from '../../../services/commentService';

export class Comments extends React.Component {
  constructor (props) {
    super(props);

    this.maxCommentLength = 3000;
    this.minCommentLength = 10;

    this.state = {
      isFetchingComments: true, 
      comments: [],
      name: '',
      commentText: '',
      commentSubmitted: false,
    }
  }

  ...

  async getCommentsFromAPI () {
    try {
      const url = window.location.pathname;
      this.setState({ ...this.state, isFetchingComments: true });
      const comments = await commentService.getComments(url);
      this.setState({ ...this.state, isFetchingComments: false, comments });
    } catch (err) {
      this.setState({ ...this.setState, isFetchingComments: false, comments: [] })
    }
  }

  componentDidMount () {
    this.getCommentsFromAPI();
  }

  sortComments (a, b) {
    return new Date(a.createdAt) - new Date(b.createdAt)
  }

  isReply (comment) {
    return !!comment.parentCommentId === true;
  }

  presentComments (comments) {
    const replies = comments.filter((c) => this.isReply(c));
    
    comments = comments
      .filter((c) => !this.isReply(c))
      .map((c) => {
        const commentReplies = replies.filter((r) => r.parentCommentId === c.id);
        if (commentReplies.length !== 0) {
          c.replies = commentReplies.sort(this.sortComments);
        };
        return c;
      })

    return comments
      .sort(this.sortComments)
  }

  getRealTimeComments () {
    return this.presentComments(this.state.comments);
  }

  getPrerenderedComments () {
    return this.presentComments(this.props.comments ? this.props.comments : []);
  }

  getComments () {
    return typeof window === 'undefined' 
      ? this.getPrerenderedComments() 
      : this.getRealTimeComments();
  }

  render () {
    const comments = this.getComments();
    const { commentText } = this.state;
    const numComments = comments.length;
    const hasComments = numComments !== 0;

    return (
      <div className="comments-container">
        <h3>{numComments} {numComments === 1 ? 'Comment' : 'Comments'}</h3>
        {!hasComments ? <p>Be the first to leave a comment</p> : ''}
        <TextInput
          placeholder="Name"
          value={this.state.name}
          onChange={(e) => this.updateFormField('name', e)}
        />
        <Editor 
          text={commentText}
          handleChange={(e) => this.updateFormField('commentText', e)}
          maxLength={3000}
          placeholder="Comment"
        />
        <SubmitButton
          text="Submit"
          // icon
          onClick={() => this.submitComment()}
          loading={false}
          disabled={!this.isFormReady()}
        />
        {comments.map((c, i) => <Comment {...c} key={i}/>)}
      </div>
    )
  }
}

Comments.propTypes = {
  comments: PropTypes.arrayOf(PropTypes.shape({
    approved: PropTypes.bool.isRequired,
    createdAt: PropTypes.string,
    id: PropTypes.string,
    name: PropTypes.string,
    text: PropTypes.string,
    url: PropTypes.string.isRequired
  }))
}

The idea is that the comments passed to this component throuugh props are prerendered comments while the comments that we retrieve and save to state by calling getCommentsFromAPI() within componentDidMount() are the live, real-time comments.

We can get the correct comments in context by testing to see if window is defined or not.

comments.js
getComments () {
    return typeof window === 'undefined' 
      ? this.getPrerenderedComments() 
      : this.getRealTimeComments();
  }

If window isn't defined, then the code is running in a server; otherwise, it's being run by a real browser (in which case, we'd want to present the real-time comments).

That should do it!

Verify that comments are preloaded

We can verify that comments are preloaded by creating a local build and then checking the resulting HTML in the public folder.

Build the site using gatsby build.

gatsby build

Then navigate to an index.html file for one of your blog posts that you know has comments.

For me, I know that Igor left a comment on the Domain-Driven Design Intro article.

Using CMD + F and searching for "Igor", I found it.


Conclusion

We just learned how to create a source plugin and prerender comments on a Gatsby site!

I've been a huge Gatsby fan ever since it came out, and I've really been enjoying how customizable these jam stack setups are.

If you're running a Gatsby website with some engagement and you've rolled your own commenting system, it wouldn't be a bad idea to improve your website's visibility this way.

Resources

Check out the following resources:



Discussion

Liked this? Sing it loud and proud 👨‍🎤.


1 Comment

Submit
Elie
a month ago

What are the limitations here? If you have tens of thousands of comments will this break? What about spam comments?


Khalil Stemmler
a month ago

Performance will definitely be an issue if you have that many comments.


In that case, you would want to paginate your `comments/all` API and then query until you've retrieved them all.


As you get more and more comments, builds will take slightly longer because it has to pull all the comments in to your graph.


I know there's a way to cache stuff, but I haven't explored it yet.


Regarding spam, I have a private admin dashboard that at any point in time, I can turn on off auto-approve comments. Right now, everyone's comments are automatically approved.


If people start getting nasty, I'll turn it back on.


I'm going to update the query to only retrieve comments `WHERE approved = 1`.


That's one way to deal with that.


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
Jun 19th, 2018 / 6 min read
Gatsby is a great tool for creating static websites, landing pages, and blogs. This post will show you how you can optimize your b...
I'm Learning Everything GraphQL in 2020
Nov 19th, 2019 / 5 min read
GraphQL is the latest API style on the block. It's a modern way for us to pull data out of your system (queries) and make changes ...
Over $85 billion spent on fixing bad code [bootcamps, junior devs, JavaScript, and software design principles]
Jun 7th, 2019 / 10 min read
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]
May 11th, 2019 / 4 min read
TypeScript is arguably one of the best things to come to the JavaScript world helping developers comfortably create complex enterp...

Want to be notified when new content comes out?

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

I won't spam ya. 🖖 Unsubscribe anytime.

Get updates