Back to blog

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].vue route 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 localhost to the site's domains while running nuxi dev.
  • Empty box after navigating to a second post. bootstrap() was not called for the new container. Confirm the watch on route.path is present, or that the page component is re-created per route.
  • window is not defined during nuxi generate. Something touches window outside onMounted. Move it, or rename the component with the .client.vue suffix.
  • Two copies of the widget. The script was added twice, once by useHead and once by the component. Use one loader and keep the other's bootstrap() 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.

Ready to try EchoThread?

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

Create free account