Migrate from Giscus to a hosted comment system without losing threads
Giscus keeps every comment in a GitHub Discussion, which is exactly why leaving it is easy: the data is yours and the API is public. This is the step-by-step export, the conversion to a JSON import, and the snippet swap that keeps each discussion attached to the page it belongs to.
Giscus is a good piece of software. It is free, it has no server of its own, and it stores every comment in a GitHub Discussion in a repository you control. People leave it for one reason that has nothing to do with quality: to comment, a reader has to have a GitHub account and authorise the giscus app, and most readers of most blogs do not have one. If you have already decided to move, this post is the how. If you are still deciding, the best Giscus alternative post covers the which. This one assumes you are moving to EchoThread; the export steps work for any destination that can read a JSON file.
We build EchoThread, so treat our claims as claims. The GitHub parts of this guide are GitHub's own API, and you can check them against the GraphQL documentation.
What you are actually moving
Giscus maps each page to one discussion in one category of your repository. The mapping is whatever your data-mapping attribute says: by default the discussion title contains the page's pathname; the alternatives are the full URL, the tag, the og:title meta tag, a specific term you chose, or a fixed discussion number (from giscus.app, read on 3 September 2026). Top-level comments are discussion comments; replies are discussion replies, one level deep; reactions live on the discussion and on each comment.
Three things follow. The mapping tells you how to reattach each thread to its page on the new system. Replies survive, because the import format has a parent field. Reactions do not: EchoThread's import has no field for them, so they stay in GitHub. Which is the fourth point: do not delete the discussions. They are your archive and your rollback.
Step 1: read your own snippet
Open the page template that carries the giscus script and note three attributes:
<script src="https://giscus.app/client.js"
data-repo="yourname/yourrepo"
data-category-id="DIC_kwDOxxxxxxxxxxxx"
data-mapping="pathname"
...></script>data-repo and data-category-id are the two identifiers the export query needs, and data-mapping tells you what the discussion titles contain. If it is pathname, the title is the path (/blog/my-post/). If it is url, the title is the full URL. If it is title or og:title, you will need to map titles back to URLs yourself in step 3.
Step 2: export the discussions with the GraphQL API
The gh CLI can run GraphQL queries and paginate them. Save this as discussions.graphql:
query($owner: String!, $name: String!, $category: ID!, $endCursor: String) {
repository(owner: $owner, name: $name) {
discussions(first: 50, after: $endCursor, categoryId: $category) {
pageInfo { hasNextPage endCursor }
nodes {
number
title
url
createdAt
comments(first: 100) {
totalCount
nodes {
id
bodyHTML
createdAt
author { login }
replies(first: 100) {
nodes {
id
bodyHTML
createdAt
author { login }
}
}
}
}
}
}
}
}Then run it with the values from your snippet:
gh api graphql --paginate --slurp \
-F owner=yourname -F name=yourrepo \
-F category=DIC_kwDOxxxxxxxxxxxx \
-F query=@discussions.graphql > discussions.json--paginate follows endCursor until every discussion is fetched, and --slurp writes the pages as one JSON array. The query asks for bodyHTML rather than body because the import accepts HTML and GitHub has already rendered the Markdown, mentions and code blocks for you. author is null for a deleted account; the conversion below turns that into "Anonymous". If a single discussion has more than 100 comments, or a comment has more than 100 replies, check totalCount and page that discussion separately. Most blogs never hit it.
Step 3: convert to the JSON import shape
EchoThread's JSON import takes a list of threads. Each thread has a url, an optional title and identifier, and a list of comments with id, author, body, created_at, parent_id and status. The only required fields are the thread url and each comment's body; status defaults to approved, and parent_id is how a reply points at its parent.
This script does the conversion for the default pathname mapping. Set SITE to the origin your pages live on.
import json
SITE = "https://example.com" # no trailing slash
pages = json.load(open("discussions.json"))
threads = []
for page in pages:
for d in page["data"]["repository"]["discussions"]["nodes"]:
path = d["title"].strip() # pathname mapping: the title is the path
comments = []
for c in d["comments"]["nodes"]:
author = (c["author"] or {}).get("login") or "Anonymous"
comments.append({"id": c["id"], "author": author, "body": c["bodyHTML"],
"created_at": c["createdAt"], "parent_id": None})
for r in c["replies"]["nodes"]:
rauthor = (r["author"] or {}).get("login") or "Anonymous"
comments.append({"id": r["id"], "author": rauthor, "body": r["bodyHTML"],
"created_at": r["createdAt"], "parent_id": c["id"]})
if comments:
threads.append({"url": SITE + path, "title": path,
"identifier": path, "comments": comments})
json.dump({"threads": threads}, open("echothread-import.json", "w"), indent=1)
print(len(threads), "threads,", sum(len(t["comments"]) for t in threads), "comments")Two decisions are baked in. identifier is set to the path, which is what you will put in the new snippet's data-identifier, so the imported thread and the live page line up even if you later change domains. And discussions with zero comments are skipped, because a thread with nothing in it is only noise in the moderation queue.
If your mapping was url, replace SITE + path with d["title"]. If it was title or og:title, build a dictionary from your site's page titles to their URLs first; your static site generator can emit one in a few lines, and it is worth doing once rather than matching by hand.
Check the file size. The import accepts up to 10 MB per upload; a blog with a few thousand comments is well under that, and a larger one can be split by thread.
Step 4: import
- Create the site in the dashboard with the domain you publish to, and copy its API key.
- Open the site's Import page, choose the JSON file, and run it. The import runs in the background and shows progress per thread.
- Check the result: threads created, comments created, and any rows it skipped with the reason.
Imported comments appear as guest comments carrying the original author's display name, with their original dates, threaded under their parents. Deduplication is by thread identifier, so a second run of the same file adds the comments again rather than replacing them; import each file once.
Step 5: swap the snippet
Replace the giscus script with the EchoThread div on the same template, and set data-identifier to the same value the import used, the page's path:
<div id="echothread"
data-api-key="YOUR_API_KEY"
data-page-url="https://example.com/blog/my-post/"
data-identifier="/blog/my-post/"
data-page-title="My post"></div>
<script src="https://cdn.echothread.io/widget.js" async></script>In a static site generator each attribute is a template variable. There are worked versions for Hugo, Jekyll, Astro, Eleventy, Next.js and Gatsby, and a post for Docusaurus, which is where a lot of giscus installs live. Deploy, open a page that had comments, and the imported thread should be under it.
Leave the giscus category in place. It costs nothing, it holds the reactions the import could not carry, and if anything went wrong you can re-run the export.
What changes for your readers
The reason you moved: a reader no longer needs a GitHub account. On EchoThread they can comment as a guest if you allow it, or sign in with Google, GitHub, X or Facebook. Guest comments are scored by a machine-learning spam classifier before they are published, and the uncertain ones go to your moderation queue, which is the other thing giscus never had: GitHub's abuse controls protect GitHub, not your comment section. The migration checklist covers the rest of the move: redirects, the canonical URL, and telling readers.
The free plan is one site, comments included, no ads, no reader tracking. A site created from 1 October 2026 includes 10,000 page views a month on the free plan; a site created before that date keeps unmetered page views. Paid plans start at $9 a month for three sites; the pricing page has the ladder. And the move is reversible: every comment can be exported again as JSON or CSV at any time, so you are never more locked in than you were with a GitHub repository.
Discussion
Comments
This thread runs on EchoThread — the same widget you would add to your own site.