Auralinna.blog - Tero Auralinna's web development blogAuralinna.blogTero Auralinna's blog about intriguing world of web development. Tweaking pixels since the '90s.
GitHub issues as blog post comments

How to use GitHub issues for blog post comments (with examples for Next.js)

Published: 12/8/2021. Updated: 12/12/2021

I recently updated my blog to use GitHub issue tracking for blog post comments. The previously used Facebook Comments plugin didn't work so smoothly anymore, so it was time to evaluate alternatives.

I have been struggling for a while with problems with that comments plugin (X-Frame-Options: DENY). I tried all kinds of things but never got it working properly. Another motivation for a change was that I wanted to remove external Facebook JavaScripts from my site due to tracking and privacy issues. Also, to boost the loading performance by using less JavaScript.

I tried to find a simple and free service for the comments, but I had no luck with that path. Commento.io was one of the possible options, but I saved that for later, as they don't have a free plan. After reading several blog posts, I found that using GitHub issues could be a pretty practical approach for commenting. Also, it's possible to use GitHub issues for the actual blog post content. But as I have a working headless CMS for content management already, I only use it for comments.

At the same time, I changed my blog platform from Svelte and Sapper to the Next.js framework, as I wanted to experiment with React and Next.js. I already have experience with Vue and Nuxt and Angular(.js), but not much with React. This blog post shows an example of how I did the GitHub issues integration with Next.js.

Benefits of GitHub issues based comments

  • Get rid of external JavaScript & cookies, and enhance privacy (when using the external commenting plugin like Facebook or Disqus).
  • It's lightweight as you can manage the comment loading by yourself.
  • I see comments as content, so it's better to render comments on a server-side and get them indexed into search engines like other blog content.
  • No need for self-hosting comments or managing any database for storing those.
  • GitHub has a good UI for managing the comments and moderating them.
  • Well-defined GitHub API for fetching the comments. Also, it's easy to develop commenting and other related features further, as GitHub API supports all kinds of things. See the post ending.
  • Easily backup the comments from the same API, so if I ever must change the comments system again, I can export comments from the GitHub API and import them to another system.
  • Notifications about new comments come as a side-effect.
  • Developers are familiar with GitHub, and it just feels more natural for a blog like this than Facebook Comments, Disqus, or any alternatives.
  • It's free – which is nice for a small blog like this

The downside is that the commenting UX is not optimal, as it happens via GitHub. But that should be sufficient for a developer blog like mine. There is also the GitHub API for creating the comments, so it's possible to polish the UX later. In general, it feels like a good fit, as this blog is all about web development, and GitHub is also all about that.

Unfortunately, all the previous comments gathered by the Facebook Comments are no longer available. Luckily there weren't so many of those. So thanks for all the commenters, and sorry that your comments are now gone.

Existing library projects

While I did the comment platform research, I found the following projects that provide similar features:

  • Octomments: Using GitHub issues as a comment plugin.
  • utterances: A lightweight comments widget built on GitHub issues.
  • giscus: A comments system powered by GitHub Discussions.

Anyway, as this seemed to be an interesting coding exercise and good fuel for the blog post, I decided to implement commenting by myself. Also, this was a good opportunity to explore the GitHub APIs.

Implementing a GitHub issue based commenting with Next.js

This blog post will cut a few corners as it's not a complete tutorial. But hopefully, it gives some idea of how to implement the commenting feature. Examples are for the server-side rendering, not for the static site generation. Also, the principles should be quite easy to use with any other framework.

If you have previous experience with Next.js or a project already set up, then you are good to go. Otherwise follow their tutorial on how to get started with Next.js. The Tailwind CSS framework is in use for styling the components.

Following GitHub API endpoints will be used:

From the issue endpoint GET /repos/{owner}/{repo}/issues/{issue_number} we want to have the information if the issue has been locked or closed. The actual comments are fetched from the GET /repos/{owner}/{repo}/issues/{issue_number}/comments.

How to handle GitHub API rate limits?

GitHub API uses rate-limiting, so we have to deal with that somehow. For unauthenticated requests, the limit is only 60 requests per hour (from originating IP address, that is the server in this case), which are going to be used fairly fast.

By providing the authenticated user, it's possible to make up to 5000 requests per hour, which starts to be a reasonable amount. In this case, the requests are associated with the authenticated user.

In GitHub's settings, you can create a Personal Access Token. The scope can be left unselected when this token only allows access to the public data (as our comments must be public). The easiest way to use API with the token is the basic authentication, which is simple to implement with Axios.

In addition to authentication, I will cache the requests using the node-cache library. It's up to you how long you want to cache the data, but I am evaluating something like 10 seconds, which shouldn't harm the UX that much. But which will prevent the rate-limiting from kicking in.

You can also check rate limits from the GitHub API response and react to those.

curl --head -u <username>:<token> https://api.github.com/repos/<account>/<repository>/issues/1

It will display following headers:

HTTP/2 200 
server: GitHub.com
...
x-ratelimit-limit: 5000
x-ratelimit-remaining: 4997
x-ratelimit-reset: 1638814554
x-ratelimit-used: 3

Create a repository for comments

At first, you need to create a public repository for the comments. For example, my blog comments are available at: github.com/teroauralinna/auralinna.blog

I added the label blog post comments to the comment issues. Then it's easy to filter those if there is also something else in the future. The title is the blog post name, and each one has a link to the blog post.

I created the following issue template for helping the issue creation. When adding a new issue, I just need to replace the blog post title and blog post link. The label and assignee will be set by the template.

The Next.js API route endpoint for fetching the comments from the GitHub API

The following npm dependencies must be installed in addition to Next.js:

npm i axios dompurify jsdom markdown-it shiki node-cache --save
npm i @types/dompurify @types/jsdom @types/markdown-it tailwindss autoprefixer postcss --save-dev

Let's continue by adding the Next.js API route for fetching the actual comments from GitHub.

The endpoint will:

  • fetch the GitHub issue and comments related to that issue,
  • get comment thread from the cache and update the cache,
  • parse markdown and replace user mentions by the GitHub profile links,
  • highlight code blocks,
  • sanitize the generated HTML to prevent malicious code injection, and
  • return the content in the model as we consume it

GitHub also has its own Markdown API. For now, I just used the markown-it library for parsing the comments markdown. If there are any special needs for the markdown parsing, I'll probably switch to GitHub's Markdown API, but for now, local parsing seems to be fine.

./interfaces/github-api.ts: interfaces for the GitHub API responses

export enum GitHubIssueState {
  open = 'open',
  closed = 'closed'
}

export interface GitHubIssue {
  locked: boolean
  state: GitHubIssueState
}

export interface GitHubUser {
  id: number
  login: string
  html_url: string
}

export interface GitHubIssueComment {
  id: number
  body: string,
  created_at: string
  updated_at: string
  html_url: string
  user: GitHubUser
}

export interface GitHubAuthOptions {
  username: string,
  token: string
}

export interface GitHubApiOptions {
  auth: GitHubAuthOptions
}

./interfaces/blog.ts: interfaces for the internal data models

export interface CommentUser {
  id: number
  login: string
  url: string
}

export interface Comment {
  id: number
  body: string
  createdAt: Date
  updatedAt: Date
  commentUrl: string
  user: CommentUser
}

export interface CommentThread {
  active: boolean
  comments: Array<Comment>
}

./utils/sanitizer.ts: a utility function for sanitizing the HTML output

The dompurify library is used here to sanitize the HTML and prevent XSS attacks.

import createDOMPurify from 'dompurify'
import { JSDOM } from 'jsdom'

const window = new JSDOM().window 

export const sanitizeHtml = (unsafeContent: string): string => {
  // @ts-ignore
  const DOMPurify = createDOMPurify(window)
  return DOMPurify.sanitize(unsafeContent)
}

./env.development and ./env.production

Environment variables will hold the GitHub API endpoint URLs.

COMMENTS_API_URL=https://api.github.com/repos/<account>/<repository>/issues
COMMENTS_PUBLIC_URL=https://github.com/<account>/<repository>/issues

In the local development env, use the env.local file to store the GitHub authentication credentials. Don't commit this file into version control. This post doesn't contain exact details on how to manage environment variables in production. That depends on where and how you are hosting your Next.js project.

./env.local

GITHUB_USERNAME=<username>
GITHUB_PERSONAL_ACCESS_TOKEN=<access token>

./api/github-http.ts: GitHub API functions

API functions for fetching the data. You can also use this library octokit/core.js (extendable client for GitHub's REST & GraphQL APIs).

import axios, { 
  AxiosBasicCredentials, 
  AxiosRequestConfig 
} from 'axios'

import { 
  GitHubIssue, 
  GitHubIssueComment,
  GitHubApiOptions,
  GitHubAuthOptions
} from '@/interfaces/github-api'

const getRequestConfig = (auth: GitHubAuthOptions): AxiosRequestConfig => {
  const basicCredentials: AxiosBasicCredentials = {
    username: auth.username,
    password: auth.token
  }
  return {
    auth: basicCredentials
  }
}

export const getIssue = async (id: number, options: GitHubApiOptions): Promise<GitHubIssue> => {
  const response = await axios.get(`${process.env.COMMENTS_API_URL}/${id}`, getRequestConfig(options.auth))
  return response.data
}

export const getComments = async (id: number, options: GitHubApiOptions): Promise<Array<GitHubIssueComment>> => {
  const response = await axios.get(`${process.env.COMMENTS_API_URL}/${id}/comments`, getRequestConfig(options.auth))
  return response.data
}

./pages/api/v1/comments/[id].ts: API route endpoint

Next we'll add the actual endpoint for getting the comments.

import type { NextApiRequest, NextApiResponse } from 'next'

import NodeCache from 'node-cache'

import markdown from 'markdown-it'

import { 
  getHighlighter, 
  Highlighter
} from 'shiki'

import { 
  Comment, 
  CommentThread 
} from '@/interfaces/blog'

import { 
  GitHubIssue,
  GitHubIssueComment, 
  GitHubIssueState,
  GitHubApiOptions,
  GitHubAuthOptions
} from '@/interfaces/github-api'

import { sanitizeHtml } from '@/utils/sanitizer'

import { 
  getComments, 
  getIssue 
} from 'api/http-github'

const replaceUserMentions = (html: string): string => 
  html
    .replace(/@[a-z\d](?:[a-z\d]|-(?=[a-z\d])){0,38}/g, '<a href="https://github.com/$&">$&</a>')
    .replace(/https:\/\/github.com\/@/g, 'https://github.com/')

const commentsCache = new NodeCache({ stdTTL: 10 })

export default async function handler(
  req: NextApiRequest,
  res: NextApiResponse<CommentThread>
) {
  try {
    const id: number = parseInt(req.query.id as string)

    const getCacheKey = (id: number): string => `comment-thread-${id}`
    const cachedCommentThread: CommentThread | undefined = commentsCache.get(getCacheKey(id))

    if (cachedCommentThread) {
      console.info(`Comment thread found from the cache: ${getCacheKey(id)}. TTL: ${commentsCache.getTtl(getCacheKey(id))}. Stats: ${JSON.stringify(commentsCache.getStats())}`)
      res.send(cachedCommentThread)
      return
    }

    const authOptions: GitHubAuthOptions = {
      username: process.env.GITHUB_USERNAME!,
      token: process.env.GITHUB_PERSONAL_ACCESS_TOKEN!
    }
    const options: GitHubApiOptions = { auth: authOptions }
    const gitHubIssue: GitHubIssue = await getIssue(id, options)
    const gitHubComments: Array<GitHubIssueComment> = await getComments(id, options)

    const { codeToHtml }: Highlighter = await getHighlighter({ 
      theme: 'github-light' 
    })

    const md = markdown({
      html: true,
      highlight: (code: string, lang: string) => codeToHtml(code, { lang })
    })

    const comments: Array<Comment> = gitHubComments.map((el: GitHubIssueComment) => {
      const comment: Comment = {
        id: el.id,
        body: sanitizeHtml(replaceUserMentions(md.render(el.body))),
        createdAt: new Date(el.created_at),
        updatedAt: new Date(el.updated_at),
        commentUrl: el.html_url,
        user: {
          id: el.user.id,
          login: el.user.login,
          url: el.user.html_url
        }
      }
      return comment
    })

    const isActive = !gitHubIssue.locked && gitHubIssue.state !== GitHubIssueState.closed

    const commentThread: CommentThread = {
      active: isActive,
      comments
    }

    commentsCache.set(getCacheKey(id), commentThread)

    console.info(`Comment thread set to the cache: ${getCacheKey(id)}`)

    res.send(commentThread)
  } catch (error) {
    console.error(error)

    res
      .status(500)
      .send({
        active: false,
        comments: []
      })
  }
}

Now, if everything is correct, you should be able to see the comments in the local dev env in the URL: http://localhost:3000/api/v1/comments/1

VS Code like code syntax highlighting with Shiki

I'm using Shiki Syntax Highlighter with markdown-it to parse Markdown and highlight code blocks in comments. I have used Prism.js and Highlight.js before, but so far, Shiki does the best job of getting the syntax highlight colorized correctly.

It uses TextMate grammar to tokenize strings, and colors the tokens with VS Code themes. In short, Shiki generates HTML that looks exactly like your code in VS Code, and it works great in your static website generator. Quote from https://shiki.matsu.io

Shiki is easy to integrate into the markdown parsing. These lines will do the job:

const { codeToHtml }: Highlighter = await getHighlighter({ 
  theme: 'material-palenight' 
})

const md = markdown({
  html: true,
  highlight: (code: string, lang: string) => codeToHtml(code, { lang })
})

And then the actual parsing:

const comments: Array<Comment> = gitHubComments.map((el: GitHubIssueComment) => {
  const comment: Comment = {
    ...
    body: sanitizeHtml(replaceUserMentions(md.render(el.body))),
    ...
  }
  return comment
})

Components for the comment list

The next thing to do is to add components for displaying the comments.

In the comment list, it's possible to toggle the comment button visibility by the isCommentingOpen prop. It's disabled if the GitHub issue state is set as closed, or the conversation is locked.

./components/CommentList/CommentList.tsx: the CommentList component will render the comment list and comment button

import { Comment } from '@/components'
import { Comment as CommentInterface } from '@/interfaces/blog'

const CommentList = ({ 
  comments, 
  commentThreadUrl,
  isCommentingOpen
}: { 
  comments: Array<CommentInterface>, 
  commentThreadUrl: string,
  isCommentingOpen: boolean
}) => {
  return (
    <div>
      {comments.length > 0 ? 
        comments.map(comment => 
          <Comment 
            key={comment.id} 
            comment={comment} 
          />
        ) : <p className="text-center">Be the first commenter?</p>
      }

      {isCommentingOpen &&
      <div className="text-center pt-2">
        <a 
          className="px-6 py-2 bg-black text-white uppercase inline-block transition-transform duration-200 hover:scale-125 focus:scale-125 active:scale-100 focus:shadow hover:shadow"
          href={commentThreadUrl}
          target="_blank"
          rel="noopener noreferrer"
        >
          Comment the Blog Post on GitHub
        </a>
      </div>
      }
    </div>
  )
}

export default CommentList

./components/Comment/Comment.tsx: the Comment component will render a single comment

The Comment.module.css file includes styles for the comment's Markdown content. The Tailwind Typography plugin could work well here also.

import styles from './Comment.module.css'
import { Comment as CommentInterface } from '@/interfaces/blog'

const Comment = ({ comment }: { comment: CommentInterface }) => {
  return (
    <div className="bg-gray-100 mb-4 p-4">
      <p className="text-base text-gray-600">
        <span>Comment by </span> 
        <a 
          href={comment.user.url} 
          className="font-medium underline hover:text-electric-violet"
          target="_blank"
          rel="noopener noreferrer"
        >
          {comment.user.login}
        </a> 
        <span> at </span> 
        <a 
          href={comment.commentUrl}
          className="font-medium underline hover:text-electric-violet"
          target="_blank"
          rel="noopener noreferrer"
        > 
          {new Date(comment.createdAt).toLocaleDateString()} 
        </a>. 
        {comment.createdAt !== comment.updatedAt && 
          <span> Updated at {new Date(comment.updatedAt).toLocaleDateString()}.</span>
        }
      </p>
      <div 
        className={styles.comment}
        dangerouslySetInnerHTML={{ __html: comment.body }}
      />
    </div>
  )
}

export default Comment

./api/http.ts: the API function for getting the comments from the Next.js API route

import axios from 'axios'

import { CommentThread } from '@/interfaces/blog'

export const getComments = async (commentThreadId: number): Promise<CommentThread> => {
  const response = await axios.get(`${process.env.LOCAL_API_BASE_PATH}/comments/${commentThreadId}`)
  return response.data
}

Finally, let's add a page for displaying the comments. As I use Next.js server-side rendering, I'm fetching the data in getServerSideProps function.

The following is not a complete code example. Implementation depends on where the comment thread ID (the GitHub issue ID) is stored. The code example just set it statically to be the number 1.

./pages/post/[slug].tsx: the page where the comments are visible

export async function getServerSideProps() {
  try {
    const commentThreadId: number = 1
    const commentThreadUrl: string = `${process.env.COMMENTS_PUBLIC_URL}/${commentThreadId}#new_comment_field`

    let commentThread: CommentThread = {
      active: false,
      comments: []
    }

    if (commentThreadId) {
      commentThread = await getComments(commentThreadId)
    }

    return {
      props: {
        isCommentingOpen: commentThread.active,
        comments: commentThread.comments,
        commentThreadUrl,
        commentThreadId
      }
    }
  } catch (error) {
    return {
      notFound: true
    }
  }
}

JSX/TSX

In the template part, you can render the comment list by the CommentList component, which should now have the required data available.

{commentThreadId && 
  <CommentList 
    comments={comments} 
    commentThreadUrl={commentThreadUrl}
    isCommentingOpen={isCommentingOpen}
  />
}

Publishing a new blog post with comments

When I create a new blog post, I will create a new issue to my GitHub repository and link the issue ID into a blog post. That will enable the commenting.

Future development ideas

Here are a few ideas I left away from the initial phase.

  • Pagination or load more comments feature. Now it uses the default limit value the GitHub API has.
  • Implement reactions for blog posts via GitHub Reactions API.
  • Create comments directly on a site via GitHub Issue Comments API.
  • Better rate limit handling. For example, when the rate limit is activated, show a link to continue the conversation in GitHub.
  • Automatically create a new GitHub issue when a blog post is published.

The post updated at 11.12.2021:

  • Added the chapter about code syntax highlighting with Shiki
  • Changed marked to markdown-it library

Comment by oobleck at 2/25/2023.

Here’s a test comment. I’m exploring this mechanism for my own blog. Are there #hashtags?

Comment by mkerr at 3/2/2024.

Interesting Idea

Latest CodePens

I am an experienced web developer with an eye for solid UI/UX design. I have specialized in front-end development, responsive web design, design systems, modern web frameworks, and content management systems. I also have experience in mobile apps development and back-end coding with PHP, Node.js, and Java. So I have a full stackish background, but I'm enjoying most building robust and beautiful front-ends with performance, accessibility, and testability in mind.

© Tero Auralinna

Auralinna.fiSunset with Bubbles: Travel and Photography Blog

The icon "ellipsis" is provided by loading.io