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:
- Searches your Mastodon account’s posts to find the one containing your blog post URL.
- Fetches the replies to that post using Mastodon’s public API.
- Renders them as a threaded comment section.
- 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, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"');
}
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:
- Open the search page on their instance with your post URL as the query.
- Their instance fetches and caches your post.
- They can then interact with it as if it were native to their instance.
- 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
- Mastodon API Documentation
- Fediverse Observer API
- ActivityPub Specification
- The original inspiration
Now go forth and federate your comment section.