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.jsxor similar). - A
siteUrlingatsby-config.jsundersiteMetadata. 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 Pass the slug, title and the absolute URL from your GraphQL query. Gatsby hands every page component a Build and deploy. Every post rendered by that template now has a comment section under the content. Gatsby's If you would rather wire this once for the whole site instead of per component, Gatsby's browser API has 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. If your posts are MDX, either render The template approach is the one to prefer, because it gives every post a comment section without remembering to add it. A lot of Gatsby blogs added comments years ago with 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. 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. 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: Add 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.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
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
}
}
}
`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
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.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()
}
}MDX
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/" />Coming from gatsby-plugin-disqus
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.What readers see
Theming
<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
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
localhost to the site's domains while developing with gatsby develop.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.fields.slug before you change paths, or export and re-import threads against the new identifiers afterwards.What it costs
Discussion
Comments
This thread runs on EchoThread — the same widget you would add to your own site.