Ganesh Mani I'm a full-stack developer, Android application/game developer, and tech enthusiast who loves to work with current technologies in web, mobile, the IoT, machine learning, and data science.

How to build a comment system for Gatsby using GitHub Issues

10 min read 2898

gatsby comment system authentication with github issues

Comment systems play an important role because they enable you to interact with your blog readers. There are paid services such as Commento and Disqus available if you want to add a comment system to your blog, but in this article, you’ll learn how to build a comment system for a Gatsby blog using GitHub Issues.

Before jumping into the tutorial, let’s first see a demo and the workflow of our commenting system:

Demo workflow comment system Gatsby blog GitHub Issues

As you can see in this demo GIF, we’re able to comment in the blog. Our comments will be stored in GitHub Issues. Now, let’s see the workflow necessary to build something like this:

Workflow GitHub Authentication User Comments Issues

This workflow can be broken into three steps.

  1. When the user visits the blog, we check to see if the user is authenticated with GitHub. If so, the user can comment directly. If the user is not authenticated, then the user will need to sign in to comment on the blog.
  2. When a user clicks on the comment button, we also need to check whether an issue exists in the blog slug, title, or unique attribute of that blog. If an issue doesn’t exist, we’ll need to create a new issue.
  3. Finally, we publish the comment into GitHub Issues so it shows up in our blog comments.

Building GitHub authentication

First, we need to build GitHub authentication for our comment system. There are different ways to accomplish this, but instead of building it in Gatsby itself, we’ll use a custom server with Passport.js to handle authentication because it’s a simple and secure way to handle the user session.

Let’s create a custom server with Passport.js GitHub authentication.

npm init --yes
npm install express body-parser cors express-session passport passport-github

After that, create App.js and add the following code:

const express = require("express");
const bodyParser = require("body-parser");
const passport = require("passport");
const session = require("express-session");
const cors = require("cors");
const app = express();
const CLIENT_URL = process.env.CLIENT_URL || "http://localhost:8000";
app.use(cors({ credentials: true, origin: CLIENT_URL }));
app.use(bodyParser.json());
app.use(bodyParser.urlencoded({ extended: false }));
app.use(
  session({
    resave: true,
    saveUninitialized: true,
    secret: "123456",
  })
);
app.use(passport.initialize());
app.use(passport.session());
require("./passport");
app.get("/", (req, res) => {
  console.log("user", req.user);
  res.send("Welcome");
});
app.get("/token", (req, res) => {
  if (req.user) {
    res.status(200).json({ user: req.user });
  } else {
    res.status(200).json({ user: null });
  }
});
app.get("/auth/github", (req, res, next) => {
  req.redirect_url = req.query.url;
  passport.authenticate("github", { state: JSON.stringify(req.query.url) })(
    req,
    res,
    next
  );
});
app.get(
  "/auth/github/callback",
  passport.authenticate("github", { failureRedirect: "/login" }),
  function (req, res) {
    const redirect_url = JSON.parse(req.query.state);
    // Successful authentication, redirect home.
    res.redirect(redirect_url);
  }
);
const PORT = process.env.PORT || 4000;
app.listen(PORT, () => {
  console.log(`Server is running on PORT ${PORT}`);
});

Here, we have three endpoints: /auth/github, /auth/github/callback, and /token. They handle Passport.js GitHub authentication and Passport authentication callback and token, which returns the user information based on the cookies stored in the browser.

Next, create a file Passport.js to configure the passport.

We made a custom demo for .
No really. Click here to check it out.

const passport = require("passport");
const GitHubStrategy = require("passport-github").Strategy;
passport.use(
  new GitHubStrategy(
    {
      clientID: <YOUR APP CLIENT ID>,
      clientSecret: <YOUR APP CLIENT SECRET>,
      callbackURL: "http://localhost:4000/auth/github/callback",
      passReqToCallback: true,
      scope: ["public_repo", "repo"],
    },
    function (req, accessToken, refreshToken, profile, cb) {
      const user = {
        id: profile.id,
        name: profile.username,
        imageUrl: profile.photos[0].value,
        token: accessToken,
      };
      return cb(null, user);
    }
  )
);
passport.serializeUser(function (user, fn) {
  fn(null, user);
});
passport.deserializeUser(function (user, fn) {
  fn(null, user);
});

We now need client ID and client secret for the configuration.

To do this, go to GitHub settings, then click developer settings:client ID GitHub authentication

Next, click on OAuth apps:OAuth apps GitHub Issues Authentication

Finally, click on new OAuth app:OAuth App GitHub Issues Authentication

After this, you can create a new app and get credentials for it. We now have the server for your comment system, so let’s build a comments section inside a Gatsby blog. I’m going to use Gatsby Starter Blog to build the comments system on top of it.

Here’s the technical workflow inside the Gatsby comments system:

 

Gatsby starter blog comments system workflow

First, we have a New Comment component that posts the comment to GitHub Issues. Then, we have Comment, which renders each component from GitHub Issues. Both New Comment and Comment should be inside templates/blog-post.js, which renders each blog.

Next, create a component inside src directory Comment/newComment.js and add the following code:

import React, { useState, useEffect } from "react"
import { getLoginUrl } from "../../utils/auth"
import { renderMarkdown, processRenderedMarkdown } from "../../utils/github"
import avatar from "./avatar.svg"
import ReactMarkdown from "react-markdown"

const NewComment = ({ user, pageUrl, onCommentSubmit }) => {
  const [comment, setComment] = useState("")
  const [activeTab, setActiveTab] = useState(0)
  const [markDown, setMarkDown] = useState(null)
  const onCommentChange = e => {
    setComment(e.target.value)
  }
  const onPreviewClick = async () => {
    setActiveTab(1)
    const markdownComment = await renderMarkdown(comment)
    setMarkDown(markdownComment)
  }
  const onNewCommentSubmit = () => {
    onCommentSubmit(comment)
    setComment("")
  }
  return (
    <article className="timeline-comment">
      {user ? (
        <a
          className="avatar"
          target="_blank"
          tabindex="-1"
          href={user.html_url}
        >
          <img height="44" width="44" alt="@ganeshmani" src={user.avatar_url} />
        </a>
      ) : (
        <img height="44" width="44" src={avatar} />
      )}
      <form className="comment" acceptCharset="UTF-8" action="javascript:">
        <header className="new-comment-header tabnav">
          <div className="tabnav-tabs" role="tablist">
            <button
              type="button"
              className="tabnav-tab tab-write"
              role="tab"
              onClick={() => setActiveTab(0)}
              aria-selected={activeTab === 0}
            >
              Write
            </button>
            <button
              type="button"
              className="tabnav-tab tab-preview"
              role="tab"
              onClick={() => onPreviewClick()}
              aria-selected={activeTab === 1}
            >
              Preview
            </button>
          </div>
        </header>
        <div className="comment-body">
          <textarea
            className="form-control"
            placeholder="Leave a comment"
            aria-label="comment"
            value={comment}
            onChange={onCommentChange}
            disabled={!user}
          >
            {activeTab === 1 && markDown ? markDown : null}
          </textarea>
          <div className="markdown-body" style={{ display: "none" }}></div>
        </div>
        <footer className="new-comment-footer">
          <a
            className="text-link markdown-info"
            tabindex="-1"
            target="_blank"
            href="https://guides.github.com/features/mastering-markdown/"
          >
            <svg
              className="octicon v-align-bottom"
              viewBox="0 0 16 16"
              version="1.1"
              width="16"
              height="16"
              aria-hidden="true"
            >
              <path
                fill-rule="evenodd"
                d="M14.85 3H1.15C.52 3 0 3.52 0 4.15v7.69C0 12.48.52 13 1.15
              13h13.69c.64 0 1.15-.52 1.15-1.15v-7.7C16 3.52 15.48 3 14.85 3zM9 11H7V8L5.5 9.92 4
              8v3H2V5h2l1.5 2L7 5h2v6zm2.99.5L9.5 8H11V5h2v3h1.5l-2.51 3.5z"
              ></path>
            </svg>
            Styling with Markdown is supported
          </a>
          {user ? (
            <button
              onClick={e => onNewCommentSubmit()}
              className="btn btn-primary"
              type="submit"
            >
              Comment
            </button>
          ) : (
            <a
              className="btn btn-primary"
              href={`${getLoginUrl(pageUrl)}`}
              target="_top"
              hidden=""
            >
              Sign in to comment
            </a>
          )}
        </footer>
      </form>
    </article>
  )
}
export default NewComment

Let’s start from component props. Here, we have user, pageUrl, and onCommentSubmit. We’ll first check to see if the user is authenticated or not based on the user props.

If the user is authenticated, we’ll allow them to comment. If this is not the case, then we’ll disable the text area and comment button.

 {user ? (
            <button
              onClick={e => onNewCommentSubmit()}
              className="btn btn-primary"
              type="submit"
            >
              Comment
            </button>
          ) : (
            <a
              className="btn btn-primary"
              href={`${getLoginUrl(pageUrl)}`}
              target="_top"
              hidden=""
            >
              Sign in to comment
            </a>
  )}

When a user clicks Sign in to comment, they’ll be redirected to our server url, Passport.js authentication. Let’s import this component inside templates/blog-post.js to render the new Comment text box with a button.

import React, { useState, useEffect } from "react"
import { Link, graphql } from "gatsby"
import Bio from "../components/bio"
import Layout from "../components/layout"
import SEO from "../components/seo"
import { rhythm, scale } from "../utils/typography"
import NewComment from "../components/Comment/newComment"
import {
loadUser,
} from "../utils/github"
import { loadToken } from "../utils/auth"

const BlogPostTemplate = ({ data, pageContext, location }) => {
const [user, setUser] = useState(null)

const post = data.markdownRemark
const siteTitle = data.site.siteMetadata.title
const { previous, next,slug } = pageContext
const url = typeof window !== "undefined" ? window.location.href : ""

return (
<Layout location={location} title={siteTitle}>
<SEO
title={post.frontmatter.title}
description={post.frontmatter.description || post.excerpt}
/>
<article>
<header>
<h1
style={{
marginTop: rhythm(1),
marginBottom: 0,
}}
>
{post.frontmatter.title}
</h1>
<p
style={{
...scale(-1 / 5),
display: `block`,
marginBottom: rhythm(1),
}}
>
{post.frontmatter.date}
</p>
</header>
<section dangerouslySetInnerHTML={{ __html: post.html }} />
<hr
style={{
marginBottom: rhythm(1),
}}
/>
<footer>
<Bio />
</footer>
</article>
<NewComment user={user} pageUrl={url} onCommentSubmit={onCommentSubmit} />
<nav>
<ul
style={{
display: `flex`,
flexWrap: `wrap`,
justifyContent: `space-between`,
listStyle: `none`,
padding: 0,
}}
>
<li>
{previous && (
<Link to={previous.fields.slug} rel="prev">
← {previous.frontmatter.title}
</Link>
)}
</li>
<li>
{next && (
<Link to={next.fields.slug} rel="next">
{next.frontmatter.title} →
</Link>
)}
</li>
</ul>
</nav>
</Layout>
)
}

export default BlogPostTemplate

export const pageQuery = graphql`
query BlogPostBySlug($slug: String!) {
site {
siteMetadata {
title
}
}
markdownRemark(fields: { slug: { eq: $slug } }) {
id
excerpt(pruneLength: 160)
html
frontmatter {
title
date(formatString: "MMMM DD, YYYY")
description
}
}
}
`

If you look closely, you’ll notice that we pass user props to this component because we need to get the logged-in user information.

loggedin user token session Github

The way we do this is by adding the following code:

useEffect(() => {
    const fetchUser = async () => {
      await loadToken()
      const user = await loadUser()
      const issue = await loadIssueByTerm(slug)
      setUser(user)
    }
    fetchUser()
  }, [])

Here, we’ll receive a token from the session by connecting with our server.

export const token = { value: null, code: null }
export const BACKEND_API = "http://localhost:4000"
// tslint:disable-next-line:variable-name
export function getLoginUrl(redirect_uri) {
  return `http://localhost:4000/auth/github?url=${redirect_uri}`
}
export async function loadToken() {
  if (token.value) {
    return token.value
  }
  const url = `${BACKEND_API}/token`
  const response = await fetch(url, {
    method: "GET",
    mode: "cors",
    credentials: "include",
  })
  if (response) {
    const t = await response.json()
    if (t.user) {
      token.value = t.user.token
      return t.user.token
    }
  }
  return null
}

Once we get the token, we can fetch the user details from GitHub.

export const loadUser = () => {
  if (token.value === null) {
    return Promise.resolve(null)
  }
  return githubFetch(githubRequest("user")).then(response => {
    if (response.ok) {
      return response.json()
    }
    return null
  })
}

const githubRequest = (relativeUrl, init) => {
  init = init || {}
  init.mode = "cors"
  init.cache = "no-cache" // force conditional request
  const request = new Request(GITHUB_API + relativeUrl, init)
  request.headers.set("Accept", GITHUB_ENCODING__REACTIONS_PREVIEW)
  if (!/^search\//.test(relativeUrl) && token.value !== null) {
    request.headers.set("Authorization", `token ${token.value}`)
  }
  return request
}

const githubFetch = request => {
  return fetch(request).then(response => {
    if (response.status === 401) {
      token.value = null
    }
    if (response.status === 403) {
      response.json().then(data => {
        if (data.message === "Resource not accessible by integration") {
          //   window.dispatchEvent(new CustomEvent("not-installed"))
        }
      })
    }
    if (
      request.method === "GET" &&
      [401, 403].indexOf(response.status) !== -1 &&
      request.headers.has("Authorization")
    ) {
      request.headers.delete("Authorization")
      return githubFetch(request)
    }
    return response
  })
}

Now, let’s see how to post a comment and create a GitHub issue if it doesn’t exist. In templates/blog-post.js, add the following code:

  const [issue, setIssue] = useState({}) 

  useEffect(() => {
    const fetchUser = async () => {
      await loadToken()
      const user = await loadUser()
      const issue = await loadIssueByTerm(slug)
      setUser(user)

      if (issue) {
        setIssue(issue)
        if (issue && issue.comments > 0) {
          const comments = await loadComments(issue.number)
          setComments(comments)
        }
      }
    }
    fetchUser()
  }, [])

const onCommentSubmit = async value => {
    let issueResult
    if (Object.keys(issue).length === 0) {
      issueResult = await createIssue(
        slug,
        url,
        post.frontmatter.title,
        post.frontmatter.description,
        "comment"
      )
      setIssue(issueResult)
    }
    const comment = await postComment(
      Object.keys(issue).length !== 0 ? issue.number : issueResult.number,
      value
    )
    const comments = await loadComments(
      Object.keys(issue).length > 0 ? issue.number : issueResult.number
    )
    setComments(comments)
  }

Now, we’ll load the issueByTerm, which is our blog slug.

      const issue = await loadIssueByTerm(slug)

Add this function inside utils/github.js:

export function loadIssueByTerm(term) {
  const q = `"${term}" type:issue in:title repo:ganeshmani/nodecli-forms`
  const request = githubRequest(
    `search/issues?q=${encodeURIComponent(q)}&sort=created&order=asc`
  )
  return githubFetch(request)
    .then(response => {
      if (!response.ok) {
        throw new Error("Error fetching issue via search.")
      }
      return response.json()
    })
    .then(results => {
      if (results.total_count === 0) {
        return null
      }
      if (results.total_count > 1) {
        // tslint:disable-next-line:no-console
        console.warn(`Multiple issues match "${q}".`)
      }
      term = term.toLowerCase()
      for (const result of results.items) {
        if (result.title.toLowerCase().indexOf(term) !== -1) {
          return result
        }
      }
      // tslint:disable-next-line:no-console
      console.warn(
        `Issue search results do not contain an issue with title matching "${term}". Using first result.`
      )
      return results.items[0]
    })
}

We’ll fetch the issue from GitHub and store it inside our component state. When a user clicks the comment button, we call the function onCommentSubmit.

Here, we’ll check if the issue exists. If not, then we create the issue.

if (Object.keys(issue).length === 0) {
      issueResult = await createIssue(
        slug,
        url,
        post.frontmatter.title,
        post.frontmatter.description,
        "comment"
      )
      setIssue(issueResult)
}

CreateIssue would look like this:

export function createIssue(issueTerm, documentUrl, title, description, label) {
  const url = `https://api.github.com/repos/<USERNAME>/<REPO NAME>/issues`
  const request = new Request(url, {
    method: "POST",
    body: JSON.stringify({
      title: issueTerm,
      body: `# ${title}\n\n${description}\n\n[${documentUrl}](${documentUrl})`,
    }),
  })
  request.headers.set("Accept","application/vnd.github.squirrel-girl-preview")
  request.headers.set("Authorization", `token ${token.value}`)
  return fetch(request).then(response => {
    if (!response.ok) {
      throw new Error("Error creating comments container issue")
    }
    return response.json()
  })
}

Afterward, post the comment to the GitHub Issues:

//it comes inside onCommentSubmit
await postComment(
      Object.keys(issue).length !== 0 ? issue.number : issueResult.number,
      value
    )
    const comments = await loadComments(
      Object.keys(issue).length > 0 ? issue.number : issueResult.number
    )
    setComments(comments)

The postComment and loadComments functions are:

export function postComment(issueNumber, markdown) {
  const url = `repos/<USERNAME>/<REPO NAME>/issues/${issueNumber}/comments`
  const body = JSON.stringify({ body: markdown })
  const request = githubRequest(url, { method: "POST", body })
  const accept = `application/vnd.github.VERSION.html+json,application/vnd.github.squirrel-girl-preview`
  request.headers.set("Accept", accept)
  return githubFetch(request).then(response => {
    if (!response.ok) {
      throw new Error("Error posting comment.")
    }
    return response.json()
  })
}
function commentsRequest(issueNumber) {
  const url = `repos/<USERNAME>/<REPO NAME>/issues/${issueNumber}/comments`
  const request = githubRequest(url)
  const accept = `application/vnd.github.VERSION.html+json,application/vnd.github.squirrel-girl-preview`
  request.headers.set("Accept", accept)
  return request
}
export function loadComments(issueNumber) {
  const request = commentsRequest(issueNumber)
  return githubFetch(request).then(response => {
    if (!response.ok) {
      throw new Error("Error fetching comments.")
    }
    return response.json()
  })
}

Now, we have posted the comment into GitHub Issues and loaded it. Let’s render the comments in our blog.

Add the following code, inside templates/blog-post.js:

 {Object.keys(issue).length > 0 &&
        comments &&
        comments.map(comment => {
          return <Comment comment={comment} />
        })}

Now, create a component Comment/index.js and add the following code:

import React from "react"
import moment from "moment"
const displayAssociations = {
  COLLABORATOR: "Collaborator",
  CONTRIBUTOR: "Contributor",
  MEMBER: "Member",
  OWNER: "Owner",
  FIRST_TIME_CONTRIBUTOR: "First time contributor",
  FIRST_TIMER: "First timer",
  NONE: "",
}
const Comment = ({ comment }) => {
  return (
    <div>
      <article className="timeline-comment">
        <a
          className="avatar"
          href={comment.user.html_url}
          target="_blank"
          tabindex="-1"
        >
          <img
            alt={`@${comment.user.login}`}
            height="44"
            width="44"
            src={comment.user.avatar_url}
          />
        </a>
        <div className="comment">
          <header className="comment-header">
            <span className="comment-meta">
              <a
                className="text-link"
                href={comment.user.html_url}
                target="_blank"
              >
                <strong>{comment.user.login}</strong>
              </a>
              commented
              <a className="text-link" href={comment.html_url} target="_blank">
                {moment(comment.created_at).fromNow()}
              </a>
            </span>
            <div className="comment-actions">
              <span className="author-association-badge">
                {displayAssociations[comment.author_association]}
              </span>
            </div>
          </header>
          <div
            dangerouslySetInnerHTML={{ __html: comment.body_html }}
            className="markdown-body markdown-body-scrollable"
          ></div>
          <div
            className="comment-footer"
            reaction-count="14"
            // reaction-url="https://api.github.com/repos/taniarascia/comments/issues/comments/668954710/reactions"
          >
            <details className="details-overlay details-popover reactions-popover">
              <summary tabindex="-1">
                <svg
                  className="octicon"
                  style={{ marginRight: "3px" }}
                  viewBox="0 0 7 16"
                  version="1.1"
                  width="7"
                  height="16"
                  aria-hidden="true"
                >
                  <path
                    fillRule="evenodd"
                    d="M4 4H3v3H0v1h3v3h1V8h3V7H4V4z"
                  ></path>
                </svg>
                <svg
                  className="octicon"
                  viewBox="0 0 16 16"
                  version="1.1"
                  width="16"
                  height="16"
                  aria-hidden="true"
                >
                  <path
                    fillRule="evenodd"
                    d="M8 0C3.58 0 0 3.58 0 8s3.58 8 8 8 8-3.58 8-8-3.58-8-8-8zm4.81 12.81a6.72 6.72 0 0 1-2.17 1.45c-.83.36-1.72.53-2.64.53-.92 0-1.81-.17-2.64-.53-.81-.34-1.55-.83-2.17-1.45a6.773 6.773 0 0 1-1.45-2.17A6.59 6.59 0 0 1 1.21 8c0-.92.17-1.81.53-2.64.34-.81.83-1.55 1.45-2.17.62-.62 1.36-1.11 2.17-1.45A6.59 6.59 0 0 1 8 1.21c.92 0 1.81.17 2.64.53.81.34 1.55.83 2.17 1.45.62.62 1.11 1.36 1.45 2.17.36.83.53 1.72.53 2.64 0 .92-.17 1.81-.53 2.64-.34.81-.83 1.55-1.45 2.17zM4 6.8v-.59c0-.66.53-1.19 1.2-1.19h.59c.66 0 1.19.53 1.19 1.19v.59c0 .67-.53 1.2-1.19 1.2H5.2C4.53 8 4 7.47 4 6.8zm5 0v-.59c0-.66.53-1.19 1.2-1.19h.59c.66 0 1.19.53 1.19 1.19v.59c0 .67-.53 1.2-1.19 1.2h-.59C9.53 8 9 7.47 9 6.8zm4 3.2c-.72 1.88-2.91 3-5 3s-4.28-1.13-5-3c-.14-.39.23-1 .66-1h8.59c.41 0 .89.61.75 1z"
                  ></path>
                </svg>
              </summary>
            </details>
          </div>
        </div>
      </article>
    </div>
  )
}
export default Comment

Now you should have a simple commenting system for your Gatsby blog using GitHub Issues. This is a simple version to get a comments system for your blog, but we can add more features to it. For example, you could add reactions and spam filtering to your blog.

Let me know in the comments if you want to build those features on top of what we built in this tutorial.

 

You come here a lot! We hope you enjoy the LogRocket blog. Could you fill out a survey about what you want us to write about?

    Which of these topics are you most interested in?
    ReactVueAngularNew frameworks
    Do you spend a lot of time reproducing errors in your apps?
    YesNo
    Which, if any, do you think would help you reproduce errors more effectively?
    A solution to see exactly what a user did to trigger an errorProactive monitoring which automatically surfaces issuesHaving a support team triage issues more efficiently
    Thanks! Interested to hear how LogRocket can improve your bug fixing processes? Leave your email:

    : Full visibility into your web apps

    LogRocket is a frontend application monitoring solution that lets you replay problems as if they happened in your own browser. Instead of guessing why errors happen, or asking users for screenshots and log dumps, LogRocket lets you replay the session to quickly understand what went wrong. It works perfectly with any app, regardless of framework, and has plugins to log additional context from Redux, Vuex, and @ngrx/store.

    In addition to logging Redux actions and state, LogRocket records console logs, JavaScript errors, stacktraces, network requests/responses with headers + bodies, browser metadata, and custom logs. It also instruments the DOM to record the HTML and CSS on the page, recreating pixel-perfect videos of even the most complex single-page apps.

    .
    Ganesh Mani I'm a full-stack developer, Android application/game developer, and tech enthusiast who loves to work with current technologies in web, mobile, the IoT, machine learning, and data science.

    Leave a Reply