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:
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:
This workflow can be broken into three steps.
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.
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:
Next, click on OAuth apps:
Finally, click on new OAuth app:
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:
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.
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.
Install LogRocket via npm or script tag. LogRocket.init()
must be called client-side, not
server-side
$ npm i --save logrocket // Code: import LogRocket from 'logrocket'; LogRocket.init('app/id');
// Add to your HTML: <script src="https://cdn.lr-ingest.com/LogRocket.min.js"></script> <script>window.LogRocket && window.LogRocket.init('app/id');</script>
Hey there, want to help make our blog better?
Join LogRocket’s Content Advisory Board. You’ll help inform the type of content we create and get access to exclusive meetups, social accreditation, and swag.
Sign up nowCreate a multi-lingual web application using Nuxt 3 and the Nuxt i18n and Nuxt i18n Micro modules.
Use CSS to style and manage disclosure widgets, which are the HTML `details` and `summary` elements.
React Native’s New Architecture offers significant performance advantages. In this article, you’ll explore synchronous and asynchronous rendering in React Native through practical use cases.
Build scalable admin dashboards with Filament and Laravel using Form Builder, Notifications, and Actions for clean, interactive panels.
One Reply to "How to build a comment system for Gatsby using GitHub Issues"
This blog was… how do you say it? Relevant!! Finally I’ve found something which helped me. Cheers!