How to add comments to a Nuxt site
Nuxt renders on the server, then takes over in the browser, and a comment widget has to respect both. This guide adds a hosted comment section with one component, shows where it goes in a Nuxt Content blog, and handles the route change that otherwise leaves a reader looking at an empty box.
Nuxt gives you server-side rendering, static generation or both, and then a client-side router that swaps pages without a reload. A comment widget has to fit that shape: its container must be safe to render on the server, its script must run only in the browser, and it must come back when the router moves the reader to the next post.
This guide does that with one Vue component and no module. The widget is EchoThread's, a single script under 60 KB gzipped that loads asynchronously after your content and lets readers comment as guests or sign in, with a machine-learning spam filter in front of every comment. We build EchoThread, so treat our claims as claims. The examples use Nuxt 3 syntax, which is unchanged in Nuxt 4.
Prerequisites
- A Nuxt 3 or Nuxt 4 project with a blog page, either a
pages/blog/[slug].vueroute or a Nuxt Content catch-all. - An EchoThread account (create one free), a site added in the dashboard with the domain you deploy to, and that site's API key.
Step 1: the component
Create components/EchoThreadComments.vue:
<script setup>
const props = defineProps({
identifier: { type: String, required: true },
title: { type: String, default: '' },
})
const WIDGET_SRC = 'https://cdn.echothread.io/widget.js'
const route = useRoute()
function mount() {
// Route change: the script is already on the page, so re-mount it into
// the container this render produced.
if (window.EchoThread && typeof window.EchoThread.bootstrap === 'function') {
window.EchoThread.bootstrap()
return
}
// First page: load the script once. It finds #echothread by itself.
if (document.querySelector(`script[src="${WIDGET_SRC}"]`)) return
const script = document.createElement('script')
script.src = WIDGET_SRC
script.async = true
document.body.appendChild(script)
}
onMounted(mount)
// If Nuxt reuses this page component between two posts instead of
// re-creating it, the identifier changes without a new mount.
watch(() => route.path, () => nextTick(mount))
</script>
<template>
<div
id="echothread"
data-api-key="YOUR_API_KEY"
:data-identifier="props.identifier"
:data-page-title="props.title"
></div>
</template>
Replace YOUR_API_KEY with the key from your dashboard. It is a public key designed to live in page source; it only lets visitors read and post on the domain you registered.
Nothing here needs <ClientOnly>. The <div> is plain markup that is harmless to server-render, and onMounted never runs on the server, so the script is only ever requested by a browser. If you prefer to keep the component out of the server build entirely, name the file EchoThreadComments.client.vue and Nuxt renders it on the client only; the Nuxt docs describe <ClientOnly> as the wrapper whose default slot "will be tree-shaken out of the server build" (Nuxt <ClientOnly> reference, read on 3 September 2026), and the .client.vue suffix has the same effect for a whole component.
There is deliberately no data-page-url. When the attribute is omitted the widget reads the page URL from the browser at mount time, which is always the real, absolute address. Building the URL yourself during server rendering means getting the origin right in every environment, and there is no benefit for the effort.
Step 2: use it on the post page
In a route-based blog, pages/blog/[slug].vue:
<script setup>
const route = useRoute()
const { data: post } = await useFetch(`/api/posts/${route.params.slug}`)
</script>
<template>
<article v-if="post">
<h1>{{ post.title }}</h1>
<div v-html="post.html"></div>
<EchoThreadComments :identifier="post.slug" :title="post.title" />
</article>
</template>
Nuxt auto-imports components from components/, so there is no import line.
Nuxt Content
If your posts are Markdown rendered by Nuxt Content v3, the page is a catch-all that queries the collection by path and renders it with <ContentRenderer> (Nuxt Content docs, read on 3 September 2026). Add the component under the renderer:
<script setup>
const route = useRoute()
const { data: page } = await useAsyncData(route.path, () =>
queryCollection('blog').path(route.path).first()
)
</script>
<template>
<article v-if="page">
<ContentRenderer :value="page" />
<EchoThreadComments :identifier="page.path" :title="page.title" />
</article>
</template>
page.path is the document's route path, which Nuxt Content derives from the file's location. It is a reasonable identifier for a blog whose URL structure is settled. If you might reorganise later, add an id field to each document's front matter and pass that instead; the thread then follows the post through any move.
Why the identifier matters
data-identifier is the key the discussion is stored under. Without it the widget uses the page URL, and a URL change detaches every thread at once. A slug or an explicit front-matter id survives the restructures that a URL does not. Pick one before the first comment arrives, because changing it afterwards means exporting and re-importing threads.
Route changes
Nuxt's <NuxtLink> navigates without a page load. On the second post a reader opens, the widget script is already present but the container it mounted into is gone. The component handles both cases: onMounted covers a freshly created page component, and the watch on route.path covers a page component that Nuxt kept alive and re-rendered with new params. In each case it calls window.EchoThread.bootstrap(), which the widget exposes for single-page hosts, and the widget tears down its previous instance and mounts into the new container.
Static generation, server rendering or hybrid: does it matter?
No, and that is the point of keeping the browser work inside onMounted. With nuxi generate the container <div> is written into each post's static HTML and the script is requested when a reader opens the page. With a Nitro server rendering on demand, the same <div> is rendered per request and the same script loads afterwards. With routeRules marking the blog as prerendered and the rest of the site as dynamic, each page follows its own rule and the component does not care which it got. There is no adapter-specific code and nothing to change when you move between hosting setups.
The one thing that does vary is the identifier. On a purely static blog, page.path is fine because paths are decided at build time. On a site where editors can rename posts in a CMS, use the CMS's record id instead, so the thread survives a slug edit.
Coming from Disqus
If the reason you are here is the ads Disqus places on its free plan, the switch is a component swap plus an import. Remove the Disqus embed component from your post page and render <EchoThreadComments> with the same identifier you were passing to Disqus. Then export your threads from the Disqus admin, which produces a compressed XML file, and import that file from the EchoThread dashboard; it matches each thread by identifier or URL and recreates the comments with their original dates and authors. Do the import before you deploy, so no reader meets an empty box. The Disqus export guide covers the export step by step.
What readers see
Readers get a comment box under the post that works without an account: a guest leaves a name and comments, or signs in with Google, GitHub, X or Facebook if they prefer. Replies nest under the comment they answer, and emoji reactions cover the "+1" case. On your side, every comment is scored for spam before it appears and can be held for approval; the dashboard shows what is waiting, with the spam score beside each comment, and you moderate from there.
Loading the script through useHead instead
Some teams prefer every third-party script to be declared in one place. Nuxt's useHead accepts a script array where "each element is mapped to a <script> tag" with src and async as properties (Nuxt useHead reference, read on 3 September 2026):
useHead({
script: [{ src: 'https://cdn.echothread.io/widget.js', async: true }],
})
If you go that way, keep the onMounted/watch calls to window.EchoThread.bootstrap() in the component, because the script initialises once on load and needs to be told about each new container after that.
Theming
The widget follows the page's light or dark scheme by default. To pin it or match your palette, add attributes to the container:
<div id="echothread"
data-api-key="YOUR_API_KEY"
:data-identifier="props.identifier"
:data-page-title="props.title"
data-theme="#0f172a"
data-accent-color="#00dc82"
data-font-family="inherit"></div>
data-theme accepts light, dark or a hex background; data-theme-source="--my-css-variable" reads the background from a CSS custom property instead, which suits sites with a theme toggle. The full list is in the docs.
Turning comments off for one post
Add comments: false to the post's front matter and gate the component:
<EchoThreadComments v-if="page.comments !== false" :identifier="page.path" :title="page.title" />
Troubleshooting
- Nothing renders. Check the API key in the dashboard and that the registered domain matches the one you are viewing. Add
localhostto the site's domains while runningnuxi dev. - Empty box after navigating to a second post.
bootstrap()was not called for the new container. Confirm thewatchonroute.pathis present, or that the page component is re-created per route. window is not definedduringnuxi generate. Something toucheswindowoutsideonMounted. Move it, or rename the component with the.client.vuesuffix.- Two copies of the widget. The script was added twice, once by
useHeadand once by the component. Use one loader and keep the other'sbootstrap()call.
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 options such as giscus, whose readers need a GitHub account to comment. The same component pattern appears in the Next.js walkthrough for React, and the Eleventy guide shows the version with no client-side router at all.
Discussion
Comments
This thread runs on EchoThread — the same widget you would add to your own site.
No comments yet.