Back to blog

Migrate from Utterances to hosted comments

Utterances stores each page's comments as one GitHub Issue, so the export is a few gh commands and the data never leaves your hands. Here is the full move: export, convert, import, swap the snippet, and what your readers get that they did not have.

Utterances was the original "your comments are a GitHub Issue" widget, and it still works exactly as it did: a script tag, a public repository, and an issue per page created by utterances-bot the first time someone comments. What has changed is the audience. A reader has to sign in with GitHub and authorise the app to post, and a growing share of blog readers have never had a GitHub account. If that is why you are leaving, the Utterances alternative post compares the options. This one is the move itself, to EchoThread, though the export half works for any destination that reads JSON.

We build EchoThread, so treat our claims as claims. The GitHub half is GitHub's REST API, documented at docs.github.com.

How utterances stores your comments

From the project's README (read on 3 September 2026): when the widget loads, it searches the repository's issues for one matching the page by url, pathname or title, according to the issue-term attribute in your snippet; if none exists, utterances-bot creates one the first time a reader comments. Every comment on the page is a comment on that issue, in order, with no threading: utterances has no reply-to, because GitHub Issue comments are a flat list. Reactions are GitHub reactions on the comment.

So the export is: list the issues the bot created, then list each issue's comments. Threading is not a concern, because there is none to preserve. Reactions cannot be carried and stay in GitHub.

Step 1: read your snippet

<script src="https://utteranc.es/client.js"
        repo="yourname/yourrepo"
        issue-term="pathname"
        theme="github-light"
        crossorigin="anonymous"
        async>
</script>

repo is the repository and issue-term is the mapping. With pathname the issue title is the page path; with url it is the full URL; with title it is the page title, and you will need a title-to-URL map in step 3. The configurator on utteranc.es also offers og:title, a fixed term, and a specific issue number; treat those like title.

Step 2: export with the gh CLI

If you followed the utterances advice and used a dedicated repository, every issue is a comment thread. Pull them all, including closed ones:

gh api "repos/yourname/yourrepo/issues?state=all&per_page=100" \
  --paginate --slurp > issues.json

If the repository also holds real issues, filter to the bot's: add &creator=utterances-bot%5Bbot%5D to the query, or filter on user.login afterwards.

Then fetch the comments for each issue, asking GitHub for rendered HTML so you do not have to render Markdown yourself:

mkdir -p comments
jq -r '.[][].number' issues.json | while read n; do
  gh api "repos/yourname/yourrepo/issues/$n/comments?per_page=100" \
    -H "Accept: application/vnd.github.html+json" \
    --paginate --slurp > "comments/$n.json"
done

With that Accept header every comment carries a body_html field alongside body. A blog with a few hundred threads finishes in a couple of minutes, well inside the API's rate limit for an authenticated user.

Step 3: convert to the JSON import shape

EchoThread's JSON import is a list of threads, each with a url and a list of comments carrying id, author, body, created_at, parent_id and status. The required fields are the thread url and each comment's body; everything else has a default.

import json, os

SITE = "https://example.com"      # no trailing slash

issues = [i for page in json.load(open("issues.json")) for i in page]
threads = []
for issue in issues:
    if "pull_request" in issue:          # a PR is not a thread
        continue
    path = issue["title"].strip()        # issue-term="pathname": the title is the path
    f = f"comments/{issue['number']}.json"
    if not os.path.exists(f):
        continue
    comments = []
    for page in json.load(open(f)):
        for c in page:
            comments.append({
                "id": str(c["id"]),
                "author": (c.get("user") or {}).get("login") or "Anonymous",
                "body": c.get("body_html") or c.get("body") or "",
                "created_at": c["created_at"],
                "parent_id": None,
            })
    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")

parent_id is None throughout because utterances has no replies; once the comments are in EchoThread, readers can reply to them, which is a small but real upgrade for an old thread. identifier is set to the path so the new snippet can point at the same value. If your issue-term was url, use issue["title"] as the URL directly; if it was title, build the title-to-URL map from your generator's output first.

The upload limit is 10 MB per file. Split by thread if you are over it, which for a personal blog you will not be.

Step 4: import

  1. Create the site in the dashboard with the domain you publish to, and copy the API key.
  2. On the site's Import page, choose the JSON file. The import runs in the background with a per-thread progress bar and reports threads created, comments created and anything skipped.
  3. Spot-check three pages: an old one, a busy one, and one with a deleted commenter (the author shows as "Anonymous").

Imported comments appear as guest comments with the original author name and date. Deduplication is by thread identifier, so importing the same file twice adds its comments twice; run each file once.

Step 5: swap the snippet

Replace the utterances script with the EchoThread div, keeping data-identifier equal to the path the import used:

<div id="echothread"
     data-api-key="YOUR_API_KEY"
     data-page-url="https://example.com/posts/hello-world/"
     data-identifier="/posts/hello-world/"
     data-page-title="Hello, world"></div>
<script src="https://cdn.echothread.io/widget.js" async></script>

Fill the attributes from your template variables. The guides for Hugo, Jekyll, Astro, Eleventy, Next.js and Gatsby show the exact variables for each. Deploy and open a page that had comments.

Keep the issues repository. It is the archive, it holds the reactions, and archiving it on GitHub (Settings, then "Archive this repository") freezes it read-only without deleting anything. If you would rather run both for a while, you can: leave the utterances script on old posts and put EchoThread on new ones, and import the old threads whenever you are ready. Nothing in the export depends on the widget still being installed.

What your readers get

No GitHub account. Readers comment as guests if you allow it, or sign in with Google, GitHub, X or Facebook. Replies, which utterances never had. And spam filtering: guest comments are scored by a machine-learning classifier before they appear, and the uncertain ones wait in your moderation queue rather than on your page, something a public Issues repository could not offer without you watching it.

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, on the pricing page. The migration checklist covers what to tell readers and how to keep search engines happy through the switch, and every comment can be exported again as JSON or CSV whenever you like.

Discussion

Comments

This thread runs on EchoThread — the same widget you would add to your own site.

Ready to try EchoThread?

Free for your first site. Set up in under a minute.

Create free account