> BLOG/POSTS/HOW TO ADD FORKIVERSE COMMENTS TO YOUR SVELTEKIT BLOG
← Back to blog (or backspace)

How to Add Forkiverse Comments to Your SvelteKit Blog

Jan 31 2026

A complete guide to implementing Mastodon-powered comments with instance selection

After my previous post about launching Forkiverse comments, I wanted to share a detailed technical breakdown, so here it is. A complete guide to adding Mastodon/Fediverse-powered comments to your SvelteKit blog, including the instance selector that lets readers comment from their own Fediverse home.

The Architecture

The system works by linking each blog post to a corresponding Mastodon post, which in my case will always be on The Forkiverse instance. When you publish a blog post, you also post about it on The Forkiverse, and that’s how your Mastodon post URL is linked. The comment system then:

  1. Searches your Mastodon account’s posts to find the one containing your blog post URL.
  2. Fetches the replies to that post using Mastodon’s public API.
  3. Renders them as a threaded comment section.
  4. Lets readers reply via their own Fediverse instance.

There are no authentication tokens needed for reading because Mastodon’s public API handles everything.

Project Structure

Here’s what we’re building:

src/
├── lib/
│   ├── server/
│   │   └── mastodon.ts          # Server-side Mastodon API logic
│   ├── components/
│   │   └── MastodonComments.svelte  # The comment UI component
│   └── types/
│       └── mastodon.ts          # TypeScript interfaces
└── routes/
    ├── api/
    │   ├── mastodon-comments/
    │   │   └── +server.ts       # Comments API endpoint
    │   └── fediverse-instances/
    │       └── +server.ts       # Instance search endpoint
    └── blog/posts/[slug]/
        └── +page.svelte         # Blog post page (includes comments)

Step 1: Define the Types

First, create the TypeScript interfaces for Mastodon’s API responses and our comment structure.

// src/lib/types/mastodon.ts

// Mastodon API response types
export interface MastodonAccount {
	id: string;
	username: string;
	acct: string;
	display_name: string;
	url: string;
	avatar: string;
	avatar_static: string;
}

export interface MastodonStatus {
	id: string;
	created_at: string;
	in_reply_to_id: string | null;
	url: string;
	content: string;
	account: MastodonAccount;
	card?: {
		url: string;
	} | null;
	replies_count: number;
	favourites_count: number;
}

export interface MastodonContext {
	ancestors: MastodonStatus[];
	descendants: MastodonStatus[];
}

// Our comment types
export interface MastodonComment {
	id: string;
	author: {
		name: string;
		handle: string;
		avatar: string;
		url: string;
	};
	content: string;
	createdAt: string;
	url: string;
	favouritesCount: number;
	children: MastodonComment[];
}

export interface MastodonCommentsResponse {
	mastodonPostUrl: string | null;
	comments: MastodonComment[];
	commentCount: number;
	favouritesCount: number;
}

Step 2: Server-Side Mastodon Logic

This is the core of the system. It searches your Mastodon posts, finds the one linking to your blog post, and fetches replies.

// src/lib/server/mastodon.ts

import type {
	MastodonAccount,
	MastodonComment,
	MastodonCommentsResponse,
	MastodonContext,
	MastodonStatus
} from '$lib/types/mastodon';

// Configuration - change these to match your setup
const MASTODON_INSTANCE = 'https://theforkiverse.com';
const MASTODON_USERNAME = 'your_username';
const SITE_BASE_URL = 'https://yoursite.com';

const CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes
const MAX_PAGES = 5;
const PAGE_SIZE = 40;

const EMPTY_RESPONSE: MastodonCommentsResponse = {
	mastodonPostUrl: null,
	comments: [],
	commentCount: 0,
	favouritesCount: 0
};

interface CacheEntry {
	data: MastodonCommentsResponse;
	timestamp: number;
}

const cache = new Map<string, CacheEntry>();
const statusIdCache = new Map<string, string>();
let cachedAccountId: string | null = null;

async function lookupAccountId(): Promise<string | null> {
	if (cachedAccountId) return cachedAccountId;

	const res = await fetch(`${MASTODON_INSTANCE}/api/v1/accounts/lookup?acct=${MASTODON_USERNAME}`);
	if (!res.ok) return null;

	const account: MastodonAccount = await res.json();
	cachedAccountId = account.id;
	return account.id;
}

async function findStatusForSlug(accountId: string, slug: string): Promise<MastodonStatus | null> {
	// Check cache first
	const cachedStatusId = statusIdCache.get(slug);
	if (cachedStatusId) {
		const res = await fetch(`${MASTODON_INSTANCE}/api/v1/statuses/${cachedStatusId}`);
		if (res.ok) return res.json();
		statusIdCache.delete(slug);
	}

	const targetUrl = `${SITE_BASE_URL}/blog/posts/${slug}`;
	let maxId: string | undefined;

	// Paginate through your posts to find the one with this URL
	for (let page = 0; page < MAX_PAGES; page++) {
		const params = new URLSearchParams({
			limit: String(PAGE_SIZE),
			exclude_replies: 'true',
			exclude_reblogs: 'true'
		});
		if (maxId) params.set('max_id', maxId);

		const res = await fetch(`${MASTODON_INSTANCE}/api/v1/accounts/${accountId}/statuses?${params}`);
		if (!res.ok) return null;

		const statuses: MastodonStatus[] = await res.json();
		if (statuses.length === 0) break;

		for (const status of statuses) {
			// Check if post content or link card contains our blog URL
			if (status.content.includes(targetUrl)) {
				statusIdCache.set(slug, status.id);
				return status;
			}
			if (status.card?.url?.includes(targetUrl)) {
				statusIdCache.set(slug, status.id);
				return status;
			}
		}

		maxId = statuses[statuses.length - 1].id;
	}

	return null;
}

Next, add the functions to build the comment tree and sanitize HTML:

// Still in src/lib/server/mastodon.ts

function sanitizeHtml(html: string): string {
	// Allowlist approach - only permit safe tags and attributes
	const allowedTags: Record<string, string[]> = {
		p: ['class'],
		br: [],
		a: ['href', 'rel', 'class', 'target'],
		span: ['class'],
		img: ['src', 'alt', 'title', 'class', 'width', 'height'],
		strong: [],
		em: [],
		code: [],
		pre: [],
		blockquote: []
	};

	let result = '';
	let pos = 0;

	while (pos < html.length) {
		const tagStart = html.indexOf('<', pos);

		if (tagStart === -1) {
			result += html.slice(pos);
			break;
		}

		if (tagStart > pos) {
			result += html.slice(pos, tagStart);
		}

		const tagEnd = html.indexOf('>', tagStart);
		if (tagEnd === -1) break;

		const tagContent = html.slice(tagStart + 1, tagEnd);
		const isClosing = tagContent.startsWith('/');
		const tagPart = isClosing ? tagContent.slice(1) : tagContent;
		const spaceIndex = tagPart.indexOf(' ');
		const tagName = (spaceIndex === -1 ? tagPart : tagPart.slice(0, spaceIndex))
			.toLowerCase()
			.replace(//$/, '');

		if (tagName in allowedTags) {
			if (isClosing) {
				result += `</${tagName}>`;
			} else {
				const allowedAttrs = allowedTags[tagName];
				const attrs = parseAttributes(tagContent, allowedAttrs);
				const selfClosing = tagContent.endsWith('/') || tagName === 'br' || tagName === 'img';
				result += `<${tagName}${attrs}${selfClosing ? ' /' : ''}>`;
			}
		}

		pos = tagEnd + 1;
	}

	return result;
}

function parseAttributes(tagContent: string, allowedAttrs: string[]): string {
	if (allowedAttrs.length === 0) return '';

	const attrs: string[] = [];
	const attrRegex = /([a-z][a-z0-9-]*)s*=s*(?:"([^"]*)"|'([^']*)'|([^s>]+))/gi;
	let match;

	while ((match = attrRegex.exec(tagContent)) !== null) {
		const attrName = match[1].toLowerCase();
		const attrValue = match[2] ?? match[3] ?? match[4] ?? '';

		if (allowedAttrs.includes(attrName)) {
			// Block dangerous URL schemes
			if (attrName === 'href') {
				const lowerValue = attrValue.toLowerCase().trim();
				if (lowerValue.startsWith('javascript:') || lowerValue.startsWith('data:')) {
					continue;
				}
			}
			// Only allow HTTPS for images
			if (attrName === 'src') {
				if (!attrValue.toLowerCase().startsWith('https://')) {
					continue;
				}
			}
			attrs.push(`${attrName}="${escapeHtml(attrValue)}"`);
		}
	}

	return attrs.length > 0 ? ' ' + attrs.join(' ') : '';
}

function escapeHtml(text: string): string {
	return text
		.replace(/&/g, '&amp;')
		.replace(/</g, '&lt;')
		.replace(/>/g, '&gt;')
		.replace(/"/g, '&quot;');
}

function buildCommentTree(descendants: MastodonStatus[], rootId: string): MastodonComment[] {
	const commentMap = new Map<string, MastodonComment>();

	// Create all comment nodes
	for (const status of descendants) {
		commentMap.set(status.id, {
			id: status.id,
			author: {
				name: status.account.display_name || status.account.username,
				handle: `@${status.account.acct}`,
				avatar: status.account.avatar,
				url: status.account.url
			},
			content: sanitizeHtml(status.content),
			createdAt: status.created_at,
			url: status.url,
			favouritesCount: status.favourites_count,
			children: []
		});
	}

	// Build tree structure
	const roots: MastodonComment[] = [];
	for (const status of descendants) {
		const comment = commentMap.get(status.id)!;
		if (status.in_reply_to_id === rootId) {
			roots.push(comment);
		} else {
			const parent = commentMap.get(status.in_reply_to_id!);
			if (parent) {
				parent.children.push(comment);
			} else {
				roots.push(comment);
			}
		}
	}

	return roots;
}

function countComments(comments: MastodonComment[]): number {
	let count = 0;
	for (const comment of comments) {
		count += 1 + countComments(comment.children);
	}
	return count;
}

async function fetchReplies(statusId: string): Promise<MastodonComment[]> {
	const res = await fetch(`${MASTODON_INSTANCE}/api/v1/statuses/${statusId}/context`);
	if (!res.ok) return [];

	const context: MastodonContext = await res.json();
	return buildCommentTree(context.descendants, statusId);
}

export async function getMastodonComments(
	slug: string,
	refresh = false
): Promise<MastodonCommentsResponse> {
	// Check cache
	if (!refresh) {
		const cached = cache.get(slug);
		if (cached && Date.now() - cached.timestamp < CACHE_TTL_MS) {
			return cached.data;
		}
	}

	const accountId = await lookupAccountId();
	if (!accountId) {
		cache.set(slug, { data: EMPTY_RESPONSE, timestamp: Date.now() });
		return EMPTY_RESPONSE;
	}

	const status = await findStatusForSlug(accountId, slug);
	if (!status) {
		cache.set(slug, { data: EMPTY_RESPONSE, timestamp: Date.now() });
		return EMPTY_RESPONSE;
	}

	const comments = await fetchReplies(status.id);
	const response: MastodonCommentsResponse = {
		mastodonPostUrl: status.url,
		comments,
		commentCount: countComments(comments),
		favouritesCount: status.favourites_count
	};

	cache.set(slug, { data: response, timestamp: Date.now() });
	return response;
}

Step 3: API Endpoints

Create a simple API endpoint to serve comments:

// src/routes/api/mastodon-comments/+server.ts

import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { getMastodonComments } from '$lib/server/mastodon';

export const GET: RequestHandler = async ({ url }) => {
	const slug = url.searchParams.get('slug');
	if (!slug) {
		return json({ error: 'Missing slug parameter' }, { status: 400 });
	}

	const refresh = url.searchParams.get('refresh') === 'true';
	const data = await getMastodonComments(slug, refresh);
	return json(data);
};

For the instance selector feature, we use the Fediverse Observer API to search instances:

// src/routes/api/fediverse-instances/+server.ts

import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';

interface FediverseInstance {
	domain: string;
	name: string | null;
	softwarename: string | null;
	total_users: number | null;
	active_users_monthly: number | null;
}

interface CachedData {
	instances: FediverseInstance[];
	fetchedAt: number;
}

const CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
let cache: CachedData | null = null;

async function fetchInstances(): Promise<FediverseInstance[]> {
	const query = `{
		nodes {
			domain
			name
			softwarename
			total_users
			active_users_monthly
		}
	}`;

	const response = await fetch('https://api.fediverse.observer/', {
		method: 'POST',
		headers: { 'Content-Type': 'application/json' },
		body: JSON.stringify({ query })
	});

	if (!response.ok) {
		throw new Error(`Fediverse Observer API error: ${response.status}`);
	}

	const data = await response.json();
	return data.data?.nodes ?? [];
}

async function getInstances(): Promise<FediverseInstance[]> {
	const now = Date.now();

	if (cache && now - cache.fetchedAt < CACHE_TTL_MS) {
		return cache.instances;
	}

	const instances = await fetchInstances();
	cache = { instances, fetchedAt: now };
	return instances;
}

export const GET: RequestHandler = async ({ url }) => {
	const query = url.searchParams.get('q')?.toLowerCase().trim();
	const limit = Math.min(parseInt(url.searchParams.get('limit') ?? '10', 10), 50);

	if (!query || query.length < 2) {
		return json({ instances: [] });
	}

	const allInstances = await getInstances();

	const matches = allInstances
		.filter((instance) => {
			if (!instance.domain) return false;
			const domainMatch = instance.domain.toLowerCase().includes(query);
			const nameMatch = instance.name?.toLowerCase().includes(query);
			return domainMatch || nameMatch;
		})
		.sort((a, b) => {
			// Prioritize exact prefix matches
			const aStartsWith = a.domain.toLowerCase().startsWith(query) ? 1 : 0;
			const bStartsWith = b.domain.toLowerCase().startsWith(query) ? 1 : 0;
			if (aStartsWith !== bStartsWith) return bStartsWith - aStartsWith;

			// Then by active users
			const aUsers = a.active_users_monthly ?? 0;
			const bUsers = b.active_users_monthly ?? 0;
			return bUsers - aUsers;
		})
		.slice(0, limit)
		.map((instance) => ({
			domain: instance.domain,
			name: instance.name,
			software: instance.softwarename,
			users: instance.active_users_monthly ?? instance.total_users
		}));

	return json({ instances: matches });
};

Step 4: The Comment Component

Now the fun part — the Svelte component that renders comments and handles the instance selector.

<!-- src/lib/components/MastodonComments.svelte -->
<script lang="ts">
	import { onMount } from 'svelte';
	import type { MastodonComment, MastodonCommentsResponse } from '$lib/types/mastodon';

	interface Props {
		slug: string;
	}

	let { slug }: Props = $props();

	let comments: MastodonComment[] = $state([]);
	let mastodonPostUrl: string | null = $state(null);
	let favouritesCount = $state(0);
	let commentCount = $state(0);
	let loading = $state(true);
	let error = $state(false);

	// Instance selector state
	let showInstanceSelector = $state(false);
	let instanceSearchQuery = $state('');
	let selectedInstance: string | null = $state(null);
	let searchResults: Array<{
		domain: string;
		name: string | null;
		software: string | null;
		users: number | null;
	}> = $state([]);
	let isSearching = $state(false);
	let searchTimeout: ReturnType<typeof setTimeout> | undefined;

	const DEFAULT_INSTANCE = 'theforkiverse.com';
	const STORAGE_KEY = 'fediverse-instance';
	const POPULAR_INSTANCES = ['mastodon.social', 'hachyderm.io', 'fosstodon.org'];

	const effectiveInstance = $derived(selectedInstance || DEFAULT_INSTANCE);
	const isUsingCustomInstance = $derived(effectiveInstance !== DEFAULT_INSTANCE);

	function normalizeInstance(input: string): string | null {
		let normalized = input.trim().toLowerCase();
		normalized = normalized.replace(/^https?:///, '');
		normalized = normalized.split('/')[0];
		if (
			!normalized ||
			!/^[a-z0-9]([a-z0-9-]*[a-z0-9])?(.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$/.test(normalized)
		) {
			return null;
		}
		return normalized;
	}

	function saveInstance(instance: string | null) {
		if (instance && instance !== DEFAULT_INSTANCE) {
			localStorage.setItem(STORAGE_KEY, instance);
		} else {
			localStorage.removeItem(STORAGE_KEY);
		}
		selectedInstance = instance;
		showInstanceSelector = false;
		searchResults = [];
	}

	function handleSearchInput() {
		if (searchTimeout) clearTimeout(searchTimeout);

		const query = instanceSearchQuery.trim();
		if (query.length < 2) {
			searchResults = [];
			isSearching = false;
			return;
		}

		isSearching = true;
		searchTimeout = setTimeout(async () => {
			const res = await fetch(`/api/fediverse-instances?q=${encodeURIComponent(query)}&limit=8`);
			if (res.ok) {
				const data = await res.json();
				searchResults = data.instances ?? [];
			}
			isSearching = false;
		}, 300);
	}

	async function fetchComments(refresh = false) {
		try {
			const params = new URLSearchParams({ slug });
			if (refresh) params.set('refresh', 'true');

			const res = await fetch(`/api/mastodon-comments?${params}`);
			if (!res.ok) {
				error = true;
				return;
			}

			const data: MastodonCommentsResponse = await res.json();
			mastodonPostUrl = data.mastodonPostUrl;
			comments = data.comments;
			commentCount = data.commentCount;
			favouritesCount = data.favouritesCount;
			error = false;
		} catch {
			error = true;
		} finally {
			loading = false;
		}
	}

	function handleComment() {
		if (!mastodonPostUrl) return;

		if (isUsingCustomInstance) {
			// Remote interaction: search for the post on user's instance
			const searchUrl = `https://${effectiveInstance}/search?q=${encodeURIComponent(mastodonPostUrl)}`;
			window.open(searchUrl, '_blank', 'noopener');
		} else {
			// Direct link to the original post
			window.open(mastodonPostUrl, '_blank', 'noopener');
		}
	}

	function formatDate(isoDate: string): string {
		return new Date(isoDate).toLocaleDateString('en-US', {
			month: 'short',
			day: 'numeric',
			year: 'numeric'
		});
	}

	onMount(() => {
		fetchComments();
		const savedInstance = localStorage.getItem(STORAGE_KEY);
		if (savedInstance) {
			selectedInstance = savedInstance;
		}
	});
</script>

Now add the template for rendering comments recursively using Svelte’s snippets:

<!-- Continued in the same file -->

{#snippet commentNode(comment: MastodonComment)}
	<div class="comment">
		<div class="comment-header">
			<img src={comment.author.avatar} alt={comment.author.name} />
			<div>
				<a href={comment.author.url} target="_blank" rel="noopener">
					{comment.author.name}
				</a>
				<span>{comment.author.handle}</span>
			</div>
			<a href={comment.url} target="_blank" rel="noopener">
				{formatDate(comment.createdAt)}
			</a>
		</div>
		<div class="comment-content">
			{@html comment.content}
		</div>
	</div>
	{#if comment.children.length > 0}
		<div class="comment-replies">
			{#each comment.children as child (child.id)}
				{@render commentNode(child)}
			{/each}
		</div>
	{/if}
{/snippet}

<section class="comments-section">
	{#if loading}
		<p>Loading comments...</p>
	{:else if error}
		<p>Failed to load comments.</p>
	{:else if !mastodonPostUrl}
		<p>No Mastodon post found for this article.</p>
	{:else}
		<div class="comments-header">
			<button onclick={handleComment}>
				Comment via {effectiveInstance === DEFAULT_INSTANCE
					? 'The Forkiverse'
					: effectiveInstance}
			</button>
			<button onclick={() => showInstanceSelector = !showInstanceSelector}>
				(Change Instance)
			</button>
		</div>

		{#if showInstanceSelector}
			<div class="instance-selector">
				<input
					type="text"
					bind:value={instanceSearchQuery}
					oninput={handleSearchInput}
					placeholder="Search instances..."
				/>

				{#if searchResults.length > 0}
					{#each searchResults as result (result.domain)}
						<button onclick={() => saveInstance(result.domain)}>
							{result.domain}
							{#if result.software}
								<span>({result.software})</span>
							{/if}
						</button>
					{/each}
				{:else}
					<p>Popular instances:</p>
					{#each POPULAR_INSTANCES as instance}
						<button onclick={() => saveInstance(instance)}>
							{instance}
						</button>
					{/each}
				{/if}

				{#if isUsingCustomInstance}
					<button onclick={() => saveInstance(null)}>
						Reset to default
					</button>
				{/if}
			</div>
		{/if}

		{#if favouritesCount > 0}
			<p>{favouritesCount} favorites</p>
		{/if}

		{#if commentCount === 0}
			<p>No replies yet. Be the first to comment!</p>
		{:else}
			<p>{commentCount} {commentCount === 1 ? 'reply' : 'replies'}</p>
			<div class="comments-list">
				{#each comments as comment (comment.id)}
					{@render commentNode(comment)}
				{/each}
			</div>
		{/if}
	{/if}
</section>

Step 5: Add to Blog Posts

Finally, include the component in your blog post layout:

<!-- src/routes/blog/posts/[slug]/+page.svelte -->
<script lang="ts">
	import { page } from '$app/state';
	import MastodonComments from '$lib/components/MastodonComments.svelte';
	import type { PageData } from './$types';

	let { data }: { data: PageData } = $props();

	const slug = $derived(page.params.slug ?? '');
	const Component = $derived(data.component);
</script>

{#if data.metadata}
	<div class="post-header">
		<h1>{data.metadata.title}</h1>
		<p>{data.metadata.pubDate}</p>
		<p>{data.metadata.description}</p>
	</div>
{/if}

<article>
	<Component />
</article>

{#if slug}
	<MastodonComments {slug} />
{/if}

How the Instance Selector Works

The key insight is that The Forkiverse is a federated instance of the Fediverse. When a reader wants to comment from their own instance (say, mastodon.social), they can’t reply directly to your post on theforkiverse.com. Instead, we use the following workaround:

  1. Open the search page on their instance with your post URL as the query.
  2. Their instance fetches and caches your post.
  3. They can then interact with it as if it were native to their instance.
  4. Federation magic ensures the reply appears on the original post.
if (isUsingCustomInstance) {
	// Opens: https://mastodon.social/search?q=https://theforkiverse.com/@user/123
	const searchUrl = `https://${effectiveInstance}/search?q=${encodeURIComponent(mastodonPostUrl)}`;
	window.open(searchUrl, '_blank', 'noopener');
}

The instance selector uses the Fediverse Observer API to provide a searchable database of known instances, making it easy for readers to find their home.

Why This Approach?

Pros:

  • No database needed as the comments live on Mastodon.
  • No moderation burden because that’s your instance admins’ responsibility.
  • Built-in spam filtering via Mastodon’s existing systems.
  • Readers can use their existing Fediverse identity.
  • Works with any ActivityPub-compatible service.

Cons:

  • Requires manual post creation on Mastodon for each blog post.
  • 5-minute cache delay for new comments.
  • Dependent on Mastodon API availability.
  • Readers need a Fediverse account to comment.

Resources

Now go forth and federate your comment section.

> Loading comments...
> JESSE.ID
© 2026 All rights reserved by 👍👍 This Guy