Back to blog

How to add comments to a Gatsby blog

Gatsby renders your blog to static HTML and then hydrates it into a React app, which is exactly the combination that trips up most comment widgets. This guide adds a hosted comment section with one component, explains why the script must load after hydration, and shows how to keep the widget working when Gatsby Link navigates without a page load.

Gatsby builds your blog into static HTML at build time, then hydrates it into a React application in the browser. Both halves matter for comments. The static half means the comment container has to be plain markup that survives server rendering. The React half means Gatsby's client-side routing swaps pages without reloading, so a widget that only initialises once will vanish after the first navigation.

This guide adds a hosted comment section to a Gatsby blog with a single component and no Gatsby plugin. The widget used here is EchoThread's: one script under 60 KB gzipped, loaded asynchronously after your content, with guest or signed-in commenting and a machine-learning spam filter in front of every comment. We build EchoThread, so treat our claims as claims; the Gatsby integration guide has the screenshot version of the same steps.

Prerequisites

  • A Gatsby site, version 4 or 5, with a blog post template (src/templates/blog-post.jsx or similar).
  • A siteUrl in gatsby-config.js under siteMetadata. Most starters already have it because the sitemap and RSS plugins need it.
  • An EchoThread account (create one free), a site added in the dashboard with the domain you publish to, and that site's API key.

Step 1: the component

Create src/components/Comments.jsx:

import React, { useEffect } from 'react'

const WIDGET_SRC = 'https://cdn.echothread.io/widget.js'

export default function Comments({ identifier, title, url }) {
  useEffect(() => {
    // Client-side navigation: the script is already on the page, so ask it
    // to mount into the container this render just produced.
    if (window.EchoThread && typeof window.EchoThread.bootstrap === 'function') {
      window.EchoThread.bootstrap()
      return
    }
    // First page: load the script once. It finds #echothread on its own.
    if (document.querySelector(`script[src="${WIDGET_SRC}"]`)) return
    const script = document.createElement('script')
    script.src = WIDGET_SRC
    script.async = true
    document.body.appendChild(script)
  }, [identifier])

  return (
    <div
      id="echothread"
      data-api-key="YOUR_API_KEY"
      data-identifier={identifier}
      data-page-title={title}
      data-page-url={url}
    />
  )
}

Replace YOUR_API_KEY with the key from your dashboard. It is a public key and is meant to sit in page source; it only lets visitors read and post on the domain you registered.

Two things about this component are deliberate. The

is ordinary markup, so Gatsby renders it into the static HTML and there is nothing to break during hydration. And everything that touches window or document lives inside useEffect, which React runs only in the browser, so the build never tries to load a widget in Node.

Step 2: use it in the post template

Pass the slug, title and the absolute URL from your GraphQL query. Gatsby hands every page component a location prop, and siteUrl comes from siteMetadata:

import React from 'react'
import { graphql } from 'gatsby'
import Comments from '../components/Comments'

export default function BlogPost({ data, location }) {
  const post = data.markdownRemark
  const { siteUrl } = data.site.siteMetadata
  return (
    <article>
      <h1>{post.frontmatter.title}</h1>
      <div dangerouslySetInnerHTML={{ __html: post.html }} />

      <Comments
        identifier={post.fields.slug}
        title={post.frontmatter.title}
        url={`${siteUrl}${location.pathname}`}
      />
    </article>
  )
}

export const query = graphql`
  query BlogPostBySlug($slug: String!) {
    site {
      siteMetadata {
        siteUrl
      }
    }
    markdownRemark(fields: { slug: { eq: $slug } }) {
      html
      fields {
        slug
      }
      frontmatter {
        title
      }
    }
  }
`

Build and deploy. Every post rendered by that template now has a comment section under the content.

Why the identifier is not the URL

data-identifier is what keeps a discussion attached to a post. If you omit it, the widget keys the thread to the page URL, which works until you restructure your paths, at which point every thread detaches at once. Gatsby gives you a better handle: fields.slug from gatsby-source-filesystem is derived from the file, not the route, so it survives a change from /2026/09/post/ to /blog/post/. If your content has an explicit id in front matter, that is better still.

Client-side navigation

Gatsby's Link component changes routes without a full page load. On the second post a reader opens, the widget script is already on the page but the old container is gone. The component above handles that: on every mount it checks for window.EchoThread.bootstrap, which the widget exposes for exactly this case, and calls it so the widget mounts into the new container. The [identifier] dependency means it runs again if the same template renders a different post.

If you would rather wire this once for the whole site instead of per component, Gatsby's browser API has onRouteUpdate, which "is called when the user changes routes, including on the initial load of the app" (gatsby-browser reference, read on 3 September 2026). In gatsby-browser.js:

export const onRouteUpdate = () => {
  if (document.getElementById('echothread') && window.EchoThread) {
    window.EchoThread.bootstrap()
  }
}

Either approach works. The per-component version keeps the logic next to the markup it depends on, which is easier to reason about a year from now.

MDX

If your posts are MDX, either render from the MDX page template the same way as above, or import the component inside an individual .mdx file:

import Comments from '../components/Comments'

Your content here.

<Comments identifier="my-post" title="My post" url="https://example.com/blog/my-post/" />

The template approach is the one to prefer, because it gives every post a comment section without remembering to add it.

Coming from gatsby-plugin-disqus

A lot of Gatsby blogs added comments years ago with gatsby-plugin-disqus, and the reason they are reading this is the ads Disqus now places on its free plan. The swap is mechanical. Remove the plugin from gatsby-config.js, delete the component call in your post template, and render in its place with the same slug you were passing as identifier. Keeping the identifier the same is what lets the history follow you.

Then bring the comments across. Disqus's admin exports a compressed XML file of every thread; the EchoThread dashboard's import accepts that file directly, matches each thread by its identifier or URL, and recreates the comments with their original dates and authors. Do the import before you deploy the new template, so the first reader to open a post sees the old discussion under the new widget rather than an empty box. The Disqus export guide covers the export step by step.

What readers see

Under each post, readers get a comment box that works without an account. A guest leaves a name and comments; a reader who prefers to sign in can use Google, GitHub, X or Facebook. Replies nest under the comment they answer, and readers can react with emoji rather than write "+1". Nothing about the reader is tracked beyond what they type.

On your side, every comment is scored for spam before it appears and can be held in a moderation queue until you approve it. The dashboard shows what is waiting across all of your sites, with the spam score beside each comment, so clearing a queue takes a minute rather than an evening.

Theming

The widget picks up a light or dark theme from the page by default. To pin it, or to match a custom background and accent, pass a few more attributes through the component:

<div
  id="echothread"
  data-api-key="YOUR_API_KEY"
  data-identifier={identifier}
  data-page-title={title}
  data-page-url={url}
  data-theme="dark"
  data-accent-color="#663399"
/>

data-theme accepts light, dark or any hex colour, data-accent-color sets buttons and links, and data-font-family="inherit" makes the widget use your site's font. The full attribute list is in the docs.

Turning comments off for one post

Add comments: false to a post's front matter, query it, and render conditionally:

{post.frontmatter.comments !== false && (
  <Comments identifier={post.fields.slug} title={post.frontmatter.title} url={`${siteUrl}${location.pathname}`} />
)}

Troubleshooting

  • Nothing renders. Check the API key in the dashboard, and check that the domain registered for the site matches the one you are viewing. Add localhost to the site's domains while developing with gatsby develop.
  • The widget disappears after clicking a link. The script loaded, but nothing called bootstrap() on the new page. Make sure the component's effect runs on mount, or add the onRouteUpdate hook above.
  • window is not defined during gatsby build. Something touches window outside useEffect. Everything browser-specific belongs inside the effect.
  • Threads detached after a URL change. The identifier was the URL. Switch to fields.slug before you change paths, or export and re-import threads against the new identifiers afterwards.

What it costs

EchoThread's Hobby plan is free for one site, with comments included, no ads and no reader tracking. Sites created from 1 October 2026 include 10,000 page views a month on Hobby; a site created before that date keeps unmetered page views. Paid plans start at $9 a month for three sites and 100,000 page views; see pricing for the full ladder.

If you are still choosing, the static-site comment systems roundup compares hosted widgets with GitHub-backed tools such as giscus and utterances, whose readers need a GitHub account to comment. The Next.js walkthrough covers the same hydration and routing questions for React's other static framework, and the Eleventy guide is the no-JavaScript-framework version of this post.

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