How to add comments to a SvelteKit site
SvelteKit prerenders your blog, hydrates it, then navigates between posts without reloading. This guide adds a hosted comment section with a single component, explains the prerender origin trap that puts the wrong URL in your HTML, and shows the one-line fix that keeps the widget alive across navigation.
SvelteKit will happily prerender a blog to static HTML, hydrate it in the browser, and then move between posts with client-side navigation. A comment widget has to behave under all three: its container must be plain markup the server can render, its script must run only in the browser, and it must come back when the router replaces the page.
This guide does that with one Svelte component and no adapter-specific code. 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.
Prerequisites
- A SvelteKit project with a post route such as
src/routes/blog/[slug]/+page.svelte, using mdsvex, a CMS, or your own loader. - 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 src/lib/components/Comments.svelte. This is Svelte 5 syntax; the Svelte 4 version follows.
<script>
import { onMount } from 'svelte'
let { identifier, title = '' } = $props()
const WIDGET_SRC = 'https://cdn.echothread.io/widget.js'
onMount(() => {
// Client-side navigation: the script is already on the page, so ask it
// to mount 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)
})
</script>
<div
id="echothread"
data-api-key="YOUR_API_KEY"
data-identifier={identifier}
data-page-title={title}
></div>In Svelte 4, replace the $props() line with export let identifier; export let title = ''; and leave the rest as it is.
Replace YOUR_API_KEY with the key from your dashboard. It is a public key meant to sit in page source; it only lets visitors read and post on the domain you registered.
In The Build and deploy. Every post under that route now carries a comment section. You may be tempted to pass The component above leaves SvelteKit navigates between posts without reloading, and when two posts share the same If you would rather not use a key block, replace If you put the loader in a shared module that could be imported on the server, wrap it in the Inside a component's Because everything that touches the browser lives in If the reason you are switching is the ads Disqus places on its free plan, the change is a component swap and an import. Remove the Disqus embed from your post page and render 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 emoji reactions cover the "+1" case. 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, with the spam score beside each comment, and you moderate from there. With mdsvex your posts are The widget follows the page's light or dark scheme by default. To pin it or match your palette, add attributes to the container: 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 options such as giscus and utterances, whose readers need a GitHub account to comment. The Eleventy guide shows the same install with no client-side router, and the Next.js walkthrough covers the React equivalent of the hydration and navigation questions above.onMount runs in the browser only, so nothing here executes during vite build or prerendering. The Step 2: use it in the post page
src/routes/blog/[slug]/+page.svelte, with the post coming from your +page.js or +page.server.js load function:<script>
import Comments from '$lib/components/Comments.svelte'
import { page } from '$app/state'
let { data } = $props()
</script>
<article>
<h1>{data.post.title}</h1>
{@html data.post.html}
{#key page.url.pathname}
<Comments identifier={data.post.slug} title={data.post.title} />
{/key}
</article>{#key} block is the part that matters for navigation, and it is explained below. page from $app/state is the SvelteKit 2.12+ API; the docs describe it as "a read-only reactive object with information about the current page" and note that earlier versions should "use $app/stores instead" (SvelteKit $app/state reference, read on 3 September 2026). On SvelteKit before 2.12, import page from $app/stores and write {#key $page.url.pathname}.The prerender origin trap
data-page-url={page.url.href} so the widget knows the canonical address of the post. Do not do that on a prerendered site. During prerendering SvelteKit does not know your production hostname unless you set kit.prerender.origin, so page.url.href in the generated HTML points at a placeholder origin rather than your domain, and the widget would attach the thread to a URL no reader ever visits.data-page-url out. When the attribute is missing, the widget reads the page URL from the browser at mount time, which is always the real one. If you want to set it anyway, set kit.prerender.origin in svelte.config.js to your production URL first, and verify the attribute in the built HTML before you publish.Why the identifier is not the URL
data-identifier is the key the discussion is stored under. If it is missing, the widget falls back to the page URL, and a later change from /blog/post/ to /writing/post/ detaches every thread at once. The slug from your load function is stable across that kind of move; an explicit id in the post's front matter is more stable still. Pick one before the first comment arrives, because changing it later means exporting and re-importing threads.Client-side navigation
+page.svelte, Svelte reuses the component instance and updates its props rather than creating a new one. That means onMount does not run again, and the widget stays attached to a container that has been re-rendered for a different post.{#key page.url.pathname} fixes that. When the pathname changes, Svelte destroys the inside the block and creates a fresh one, so onMount runs again, finds window.EchoThread already present, and calls bootstrap(). The widget tears down its previous instance and mounts into the new container. That method is exposed for exactly this situation, so it is the supported way to re-mount rather than a workaround.onMount with a $effect that reads identifier, so the same code re-runs whenever the prop changes. Both work; the key block is easier to read in a template.Guarding shared code
browser flag from $app/environment, which is "true if the app is running in the browser" (SvelteKit $app/environment reference, read on 3 September 2026):import { browser } from '$app/environment'
export function mountComments() {
if (!browser) return
// ...same loader as above
}onMount you do not need it, because onMount never runs on the server.Any adapter
onMount, the component does not care how the site is deployed. With adapter-static the container is written into each post's HTML at build time and the script loads when a reader opens the page. With adapter-node, adapter-vercel, adapter-netlify or adapter-cloudflare the page is rendered per request and the same script loads afterwards. If some routes are prerendered with export const prerender = true and others are not, each route follows its own rule. Nothing changes if you move hosts.Coming from Disqus
with the same identifier you were giving Disqus. Then export your threads from the Disqus admin, which produces a compressed XML file, and import it from the EchoThread dashboard; it matches each thread by identifier or URL and recreates the comments with their original dates and authors. Run the import before you deploy, so the first reader of the new build sees the old discussion rather than an empty box. The Disqus export guide covers the export step by step.What readers see
mdsvex blogs
.svx files and the layout is a Svelte component. Put in the layout rather than in each post, reading the slug and title from the front matter the layout receives. One edit, every post covered.Theming
<div
id="echothread"
data-api-key="YOUR_API_KEY"
data-identifier={identifier}
data-page-title={title}
data-theme="#111827"
data-accent-color="#ff3e00"
data-font-family="inherit"
></div>data-theme accepts light, dark or a hex background, data-accent-color sets buttons and links, and data-theme-source="--bg" reads the background from a CSS custom property, which suits a site with a theme switch. The full attribute list is in the docs.Turning comments off for one post
comments: false to the post's front matter and gate the block:{#if data.post.comments !== false}
{#key page.url.pathname}
<Comments identifier={data.post.slug} title={data.post.title} />
{/key}
{/if}Troubleshooting
localhost to the site's domains while running vite dev.{#key page.url.pathname} block.data-page-url was set from page.url.href during prerendering. Remove the attribute, or set kit.prerender.origin and rebuild.window is not defined during build. Something touches window outside onMount. Move it, or guard it with browser.What it costs
Discussion
Comments
This thread runs on EchoThread — the same widget you would add to your own site.