Replacing Disqus With Giscus and Github Discussions

 Table of Contents

As you might have noticed, I’ve put this website through a number of changes this year. Apart from changing the theme (sorry if you hate blue), I also switched my website metrics from Google Analytics to Umami, which is an open source, privacy focused analytics platform.

Along the same vain, it was finally time to move on from Disqus.

Disqus was the go-to commenting platform back in the 2010s, but over time they’ve become focused on profiting through selling user data and advertisements, as well as just being very bloated. The final straw was adding advertisements to their free plan, and despite them not enabling it (yet) for small, non-commercial websites like this one, it’s likely only a matter of time before they go that direction.

Prior Art

There are a number of cloud and self-hosted Disqus alternatives, but I wanted something open source, simple, no-frills, and low maintenance. For this static site hosted on Github Pages, it seemed silly to self-host a comment platform like Isso or Commento just for the <100 comments I have.

Utterances was previously a common choice which uses Github Issues, but I settled on Giscus since it uses the (more logical) Github Discussions component instead. It also seemed to be the go-to choice these days amongst bloggers, as well as being the most actively maintained of these tools.

One caveat to using a Github-based tool is that users need to have a Github account to comment and react to a post. I mostly write about tech, so most of my users likely already have Github accounts, so this isn’t too much of an issue for me. It does add a friction point of not having easy login options like Google or other social media accounts, though I don’t get a ton of comments on my blog anyway, so again, something I can live with.

Setup

Configuring Giscus

Setting up Giscus was quite straightforward, the instructions on their homepage were easy to follow. Once the Giscus app was installed and Discussions enabled on my repo, I copied the generated snippet to my website, replacing where Disqus would go.

<script src="https://giscus.app/client.js"
	data-repo="[ENTER REPO HERE]"
	data-repo-id="[ENTER REPO ID HERE]"
	data-category="[ENTER CATEGORY NAME HERE]"
	data-category-id="[ENTER CATEGORY ID HERE]"
	data-mapping="pathname"
	data-strict="0"
	data-reactions-enabled="1"
	data-emit-metadata="0"
	data-input-position="bottom"
	data-theme="preferred_color_scheme"
	data-lang="en"
	crossorigin="anonymous"
	async>
</script>

Counting Comments

One of the nice things about Disqus was its “built-in” comment counter. This was enabled with a snippet like this:

<!-- The placeholder to be replaced... -->
<span class="disqus-comment-count" data-disqus-url="{{ .Permalink }}#disqus_thread">0 comments</span>

...

<!-- ... From this script -->
<script defer id="dsq-count-scr" src="//[SHORTNAME].disqus.com/count.js" async></script>

Giscus doesn’t have a built in option for that (see this issue, but I was able to achieve it fairly easily with a bit of Javascript. In my Hugo template:

<!-- The placeholder to be replaced... -->
<span id="giscus-comment-count-{{ $context.File.UniqueID }}" class="giscus-comment-count" data-url="{{ $context.RelPermalink }}">
    <a href="{{ $context.RelPermalink }}#comments">0 comments</a>
</span>

<!-- ... From this script -->
{{ $giscusJs := resources.Get "js/giscus-comments.js" }}
{{ if $giscusJs }}
    {{ $giscusJs := $giscusJs | resources.Minify }}
    <script src="{{ $giscusJs.RelPermalink }}" defer></script>
{{ end }}

And in giscus-comments.js:

// Giscus Comment Count Manager
// Uses PostMessage API to get comment counts from Giscus iframes

class GiscusCommentManager {
  constructor() {
    this.setupMessageListener();
  }

  setupMessageListener() {
    window.addEventListener("message", (event) => {
      if (event.origin !== "https://giscus.app") return;
      if (!(typeof event.data === "object" && event.data.giscus)) return;

      const giscusData = event.data.giscus;
      const currentPath = window.location.pathname;

      // Check if this contains discussion data with comment count
      if ("discussion" in giscusData) {
        const discussion = giscusData.discussion;

        if (discussion && typeof discussion.totalCommentCount === "number") {
          // Calculate total count including both comments and replies
          const commentCount = discussion.totalCommentCount || 0;
          const replyCount = discussion.totalReplyCount || 0;
          const totalCount = commentCount + replyCount;

          // Update comment count displays on current page
          this.updateCommentCountDisplays(
            currentPath,
            totalCount
          );

          console.log(
            `Giscus: Page ${currentPath} has ${commentCount} comments and ${replyCount} replies (${totalCount} total)`
          );
        } else if (discussion === null) {
          // Discussion doesn't exist (404), default to 0 comments
          this.updateCommentCountDisplays(currentPath, 0);
          console.log(`Giscus: Page ${currentPath} has no discussion (defaulting to 0 comments)`);
        }
      }

      // Handle error messages (e.g., when discussion doesn't exist)
      if ("error" in giscusData) {
        const error = giscusData.error;
        
        // Default to 0 comments on any error
        this.updateCommentCountDisplays(currentPath, 0);
      }
    });
  }

  updateCommentCountDisplays(url, count) {
    // Update comment count elements for this URL on the current page
    const elements = document.querySelectorAll(`[data-url="${url}"]`);
    elements.forEach((element) => {
      const link = element.querySelector("a") || element;
      if (count === 0) {
        link.innerHTML = "0 comments";
      } else if (count === 1) {
        link.innerHTML = "1 comment";
      } else {
        link.innerHTML = `${count} comments`;
      }
    });
  }

  // Initialize comment count manager
  initialize() {
    // The PostMessage listener will automatically update counts when Giscus loads
    // No need for stored counts - fresh data every time
  }
}

// Initialize the comment manager when DOM is loaded
document.addEventListener("DOMContentLoaded", function () {
  window.giscusCommentManager = new GiscusCommentManager();
  window.giscusCommentManager.initialize();
});

One mildly annoying thing is that this counter can take a few seconds to update since the page needs to wait for the Giscus widget to fully load, but it’s a small (and acceptable) price to pay to avoid having user data being tracked and sold!

The Great Migration

Giscus unfortunately doesn’t have a built in way to migrate comments from Disqus to Github Discussions. There are quite a few blog posts about this topic, but they were in languages like Java, Ruby, .NET, none of which I have set up on my laptop. Some of them also did things like attempting to map the Disqus usernames to Github users, but I didn’t want to bother with that since the hit rate seems low anyway.

Predominantly being a Python developer, I figured this would also be a good opportunity to see how far vibe coding can take me through this problem and use it to create a script tailored to my heart’s desires.

Exporting from Disqus

Fortunately, Disqus allows exporting comments to XML. After requesting an export here, I shortly received an email with the contents.

Converting Disqus to Github Discussions

There were a few tricky parts with the conversion:

  • Disqus has support for multiple thread levels, Github only has one level
  • Export only has usernames of the commenters
  • Github has a GraphQL rate limit of 5000 requests/hr
  • Unless you create a separate Github account, all imported comments will show up under your Github user

The last point might be a deal breaker for some, but I wanted to keep this migration as simple as possible, so I dealt with it. The imported comments will have a header that shows who and when it would be posted, which is sufficient enough for my needs. Update: I added support for posting via a Github App, which removes this issue!

The full script is in this gist, and it was definitely way more lines than I thought it’d be. But it does have some nice features like:

  • Having a --dry-run flag to test parsing without hitting the Github API
  • Idempotency by keeping track of published comments in a state file

The vibe coding did take quite a few back-and-forths to get right, but I’m overall impressed with the output and speed.

Script Usage (click to expand):
$ uv run disqus_to_giscus.py --help

usage: disqus_to_giscus.py [-h] [--repo-owner REPO_OWNER] [--repo-name REPO_NAME] [--category-name CATEGORY_NAME] [--dry-run] [--output OUTPUT] [--state-file STATE_FILE]
                           [--app-id APP_ID] [--private-key-path PRIVATE_KEY_PATH] [--installation-id INSTALLATION_ID]
                           xml_file

Migrate Disqus comments to GitHub Discussions

positional arguments:
  xml_file              Path to Disqus XML export file

options:
  -h, --help            show this help message and exit
  --repo-owner REPO_OWNER
                        GitHub repository owner/organization name (required for real migration)
  --repo-name REPO_NAME
                        GitHub repository name (required for real migration)
  --category-name CATEGORY_NAME
                        GitHub Discussion category name (default: Announcements)
  --dry-run             Preview migration without posting to GitHub (recommended)
  --output OUTPUT       Output file for dry run preview (default: migration_preview.md)
  --state-file STATE_FILE
                        State file for tracking migration progress (default: migration_state.json)

GitHub App Authentication:
  Use GitHub App for authentication instead of personal access token

  --app-id APP_ID       GitHub App ID (can also be set via GITHUB_APP_ID environment variable)
  --private-key-path PRIVATE_KEY_PATH
                        Path to GitHub App private key file (can also be set via GITHUB_APP_PRIVATE_KEY_PATH environment variable)
  --installation-id INSTALLATION_ID
                        GitHub App installation ID (can also be set via GITHUB_APP_INSTALLATION_ID environment variable)

Environment Variables:
  Authentication (choose one):
  GITHUB_TOKEN                    Personal access token for GitHub API

  OR for GitHub App authentication:
  GITHUB_APP_ID                   GitHub App ID
  GITHUB_APP_PRIVATE_KEY_PATH     Path to GitHub App private key file
  GITHUB_APP_INSTALLATION_ID      GitHub App installation ID

State Tracking:
  The script maintains a local state file (migration_state.json by default) to track
  which discussions and comments have been successfully created. This makes the script
  idempotent - you can safely re-run it after failures and it will resume where it
  left off without creating duplicates.

Authentication Methods:
  1. Personal Access Token (original method):
     - Set GITHUB_TOKEN environment variable
     - Token needs 'repo' and 'write:discussion' scopes

  2. GitHub App (recommended for organizations):
     - Create a GitHub App with discussions:write permission
     - Install the app on your repository/organization
     - Provide --app-id, --private-key-path, --installation-id
     - Or set corresponding environment variables

Examples:
  # Dry run (recommended first)
  python disqus_to_giscus.py export.xml --dry-run

  # Real migration with personal access token
  export GITHUB_TOKEN="your_token_here"
  python disqus_to_giscus.py export.xml --repo-owner myusername --repo-name myrepo

  # Real migration with GitHub App
  python disqus_to_giscus.py export.xml --repo-owner myusername --repo-name myrepo \
    --app-id 123456 --private-key-path /path/to/private-key.pem --installation-id 789012

  # With custom category name
  python disqus_to_giscus.py export.xml --repo-owner myusername --repo-name myrepo --category-name "General"

  # Custom output file for dry run
  python disqus_to_giscus.py export.xml --dry-run --output custom_preview.md

  # Custom state file location
  python disqus_to_giscus.py export.xml --repo-owner myusername --repo-name myrepo --state-file my_migration.json

Using the --dry-run mode will export the parsed comments into a migration_preview.md file, in case you want to edit any processing/formatting to suit your own needs.

$ uv run disqus_to_giscus.py justinmklam-2025-08-26T15_34_53.793894-all.xml --dry-run

🚀 Starting Disqus to GitHub Discussions migration
📖 Parsing Disqus XML: justinmklam-2025-08-26T15_34_53.793894-all.xml
✅ Parsed 277 threads with comments
🔍 Performing dry run - generating preview to migration_preview.md
📊 Existing state: 4 discussions, 28 comments already created
✅ Dry run complete! Preview saved to migration_preview.md
📊 Summary: 8 new threads ready for migration, 4 already exist
🚫 Skipped 265 threads with no comments

Option 1: Posting from a Personal Account

If you want to avoid having to go through the extra steps of creating a Github App, and are okay with all the imported comments being under your account, then this method is for you. If not, move on to the next section!

For actually running the migration to Github Discussions, be sure to create a new personal access token and set it as GITHUB_TOKEN in your environment.

I would recommend testing it out on a new repository first to make sure everything works and looks as expected. I did this with a new giscus-import-test repo, and after verifying the results, I changed the repo to the final repo.

$ uv run disqus_to_giscus.py \
  justinmklam-2025-08-26T15_34_53.793894-all.xml \
  --repo-owner justinmklam \
  --repo-name personal-blog \
  --category-name "Blog Comments"

Now, all my comments are in this website’s repo under the Discussions tab here!

Code and user comments, all in one repo!

As mentioned before, the comment threads all appear under my Github handle, which is not ideal but it’s an acceptable trade-off for a free, no-hassle commenting platform.

Example of an imported comment thread, but all under your own account.

Option 2: Posting from a Github App

After doing the previous option, I did a bit more reading and found out that creating a Github App to do the posting isn’t actually that much more work, and the end result is much cleaner since the comments would then be posted under a bot account. This makes it much clearer that the comments are imported from another platform, and that it’s not just you having a conversation with yourself.

I should probably have just started with this since I did have to delete all the previously created discussions, but it was pretty quick and the end result is much nicer.

Steps to set up a Github App:

  1. Go to your Github developer settings and create a new app
    1. Give it a name
    2. Set a homepage url (this can be anything)
    3. Disable webhooks
    4. Set repository permissions to have read/write access to Discussions
    5. Upload a custom logo if desired
    6. Generate a private key and download it (*.private-key.pem file)
  2. Install the app to your target repository
  3. Make note of the app’s:
    1. App ID -> from the app’s about page
    2. Installation ID -> go to your Applications, click “Configure” for your app, then use the numeric id in the URL path

Then run the same script, but with a few different options (and this time, GITHUB_TOKEN isn’t required):

$ uv run disqus_to_giscus.py \
  justinmklam-2025-08-26T15_34_53.793894-all.xml \
  --repo-owner justinmklam \
  --repo-name personal-blog \
  --category-name "Blog Comments" \
  --app-id 12345 \
  --private-key-path giscus-comment-bot.2025-08-27.private-key.pem \
  --installation-id 67890

Example of an imported comment thread using a Github App. Nice!

Closing Thoughts

I was able to get the migration done in an evening, so I’d call that a success! Claude Code definitely did a lot of heavy lifting, but it was a neat experiment in using AI for menial, one-off automations that would normally be more painful and time consuming. And now, all my website code and comments are in one repository, free of advertisements and selling of user data.

Hope this helps anyone else planning to migrate away from Disqus!