I ran Disqus on this site for years, and it did its job. Comments appeared, people used them occasionally, and I didn't have to think about it much. That stability was valuable, but it wasn't enough anymore.
Over time I kept running into the same friction: Disqus felt like a separate system bolted onto the side of my site. The comments lived somewhere else, moderated through a different interface, owned by a service I didn't fully control. And yeah, the ads bothered me too.
I wanted something that felt more integrated with how I actually work. Something that used GitHub since I'm already there constantly. Something open source so I could see what was actually happening. And I wanted to own the discussion data directly.
That's when I started looking seriously at Giscus.
The appeal
Giscus is fundamentally different from Disqus. Instead of maintaining its own comment database, it uses GitHub Discussions as the backend. Every conversation becomes a discussion in a GitHub repo I control. For a technical blog, that's oddly perfect—the same person reading the post is probably on GitHub anyway.
The tradeoff was real though: comments now require a GitHub account. Disqus let people sign in through Twitter, Google, Facebook, and a dozen other services. Giscus was GitHub-only. I sat with that constraint for a while, thinking about whether it would make the site feel too narrow. But this is a technical blog, and the audience skews heavily toward people who already have GitHub accounts. For the few readers without one, I could add an email reply option as a fallback. For this site, that felt like the right call.
What I needed to preserve
I'd built some guardrails into the Disqus implementation that I wasn't going to lose. Posts could opt out of comments through frontmatter. Future-dated posts automatically stayed comment-free in production. Drafts never showed comments at all. These weren't afterthoughts—they were deliberate friction I'd added to prevent embarrassing situations where unready content got public discussion.
The good news was that none of this logic was actually tied to Disqus. It was all just conditional rendering. So I could swap out the comment provider completely and keep all the safety guards in place. That meant I could test the new system without worrying about accidentally exposing something I didn't want public yet.
Getting ready, actually
Before I touched any code, I ran through a checklist. Giscus needs a public repository to work—comments live in GitHub Discussions, so that repo has to be open. Discussions needed to be enabled there. And the Giscus GitHub App needed permission to interact with that repo.
That repository didn't have to be my main site repo. I could've used any repo I controlled. I ended up creating a separate comments repository just to keep things clean.
Then I did something I'd learned the hard way on other projects: I tested against a real production deploy, not just local dev. CSP issues and iframe loading quirks almost always hide until you hit actual production infrastructure. I didn't want to discover problems after switching live.
Oh, and before I did anything destructive: I exported my existing Disqus comments. Disqus has an export tool in the admin panel that archives everything. Giscus doesn't import those comments—they won't automatically migrate—but at least I wasn't losing the history. Some of those old conversations were valuable, and keeping them in cold storage felt responsible.
The actual migration
Installing Giscus was straightforward. Out with disqus-react, in with @giscus/react:
npm uninstall disqus-react
npm install @giscus/react
In my blog post template, I already had a conditional block that decided whether to render comments. I just swapped the component:
import Giscus from '@giscus/react';
Then I replaced the old DiscussionEmbed call with the new Giscus component, reusing the same conditional boundary I already had.
Next came the trickier part: figuring out how Giscus should match discussions to posts. Giscus has several strategies—you can map by URL, pathname, post title, or an explicit term. I went with mapping="specific" and used the post title as the term. That way each blog post gets one discussion in GitHub tied to its title, and if I ever change the URL structure, the discussion doesn't break. I also set strict="1" to make that mapping deterministic.
<Giscus
repo="owner/comments-repo"
repoId="REPO_ID"
category="Announcements"
categoryId="CATEGORY_ID"
mapping="specific"
term={frontmatter.title}
strict="1"
reactionsEnabled="0"
emitMetadata="0"
inputPosition="bottom"
theme={giscusTheme}
lang="en"
/>
(The repo and category IDs are placeholders—you get those from the Giscus UI.)
The publish-date gating stayed exactly the same:
const isProductionBuild = process.env.NODE_ENV === 'production';
const isCurrentPostPublished = toTimestamp(frontmatter.publishedAt) <= Date.now();
const shouldShowComments =
frontmatter.allowComments === true && (!isProductionBuild || isCurrentPostPublished);
This still lets me see comments everywhere locally, but prevents them from showing up on unpublished posts in production.
The part that bit me
Here's where I hit actual friction: the Content Security Policy.
I'd thought about this abstractly before, but the reality was harsher. Disqus embeds came from disqus.com, and I had that in my CSP. When I switched to Giscus, suddenly comments wouldn't load, the iframe was being blocked, the script refused to run.
The problem was CSP rules. Giscus embeds come from https://giscus.app, and I had to add that domain to the relevant directives:
Content-Security-Policy:
script-src 'self' ... https://giscus.app;
frame-src 'self' ... https://giscus.app;
I spent longer than I'd like to admit wondering why comments weren't loading when the code looked correct. I checked imports, props, console errors — nothing obvious. Then I deployed to staging, opened dev tools, and there it was in the CSP violations: frame-src was rejecting the giscus.app iframe.
If comments mysteriously fail to load, check your CSP first.
The smaller decisions
I kept reactions disabled. Giscus supports them, but I wanted actual comments, not emoji responses.
I also made comments lazy-load. Instead of rendering Giscus immediately, I put a button where the comments would go. Readers have to click "Show comments" to load the iframe and hit GitHub Discussions. The comments section doesn't load unless someone explicitly wants it — no GitHub API call, no mounted iframe, lighter page by default. One extra click for people who do comment, which felt worth it.
const [showComments, setShowComments] = useState(false);
{showComments ? (
<CommentsShell>
<Giscus ... />
</CommentsShell>
) : (
<button type="button" onClick={() => setShowComments(true)}>
Show comments
</button>
)}
I also made sure the Giscus theme synced with my site's light/dark mode. A dark-themed site with a light comment embed looks broken — mapping the theme prop fixed that.
And because not everyone has a GitHub account, I added a link in the post template that says "Reply via email" with the post title pre-filled as the subject line. For anyone who wants to respond but doesn't want to set up an account, there's still an on-ramp.
What's different now
The discussions live in GitHub Discussions, which I'm in constantly. I see notifications there. I moderate from there. The data is somewhere I understand and control.
Operationally, once the configuration was right, it's been simple. The CSP surprise aside, nothing broke. The site didn't suddenly feel different to readers, but behind the scenes the infrastructure now matches the rest of my stack better.
The GitHub-only requirement is the real constraint. Some people will opt out of participating because of it. I made peace with that trade-off because the audience fit made sense. For a broader consumer blog where you need wide participation, this would be the wrong choice. For a technical site where most readers are already in the GitHub ecosystem, it works.
How it's changed since
This post is a snapshot from March 2026. The actual production setup has drifted since — I moved away from the hosted giscus.app embed to running my own comments service, reactions went from disabled to enabled, and the React integration evolved from @giscus/react directly to a custom wrapper. None of that makes the old setup wrong; it just means I kept iterating.
The migration itself was straightforward once I understood the CSP issue. Giscus also just fits better here — the infrastructure matches the rest of my stack, the comments feel like part of the site instead of a separate widget, and I'm not looking at an ads dashboard to moderate anything.
