Use a Bluesky Post as the Comment Thread for a Hugo Page
I wanted comments on a Hugo site without hosting accounts or a comment database. One option is to use the Bluesky post that shares an article as its discussion thread, then fetch the replies with Bluesky’s public API.
This keeps the site static, but it also makes comments dependent on client-side JavaScript, Bluesky search, and a third-party API. That tradeoff is worth understanding before adding the code.
Add a place for the thread
In single.html, or the equivalent article template, add counters, a link to the Bluesky conversation, and an empty container:
<div class="bluesky-stats">
<small>
💬 <span id="reply-count">0</span>
🔄 <span id="repost-count">0</span>
♥️ <span id="like-count">0</span>
</small>
</div>
<h2>Comments</h2>
<p class="comment-prompt">
Reply on Bluesky <a href="#" target="_blank" rel="noopener noreferrer">here</a> to join the conversation.
</p>
<div id="bluesky-comments"></div>
<script src="/js/bluesky-comments.js"></script>
Find the shared URL and fetch its replies
The JavaScript does three things:
- searches for Bluesky posts containing the current page URL;
- fetches each matching post’s thread;
- sorts the replies by time and renders them below the article.
Place this in /assets/js/bluesky-comments.js or adjust the template’s script path to match where your Hugo site publishes JavaScript:
function unorphanize(element, count = 1) {
// Get HTML content
let html = element.innerHTML;
// Store HTML tags
const tags = html.match(/<([A-Z][A-Z0-9]*)\b[^>]*>/gi) || [];
const placeholders = tags.map((_, i) => `__${i}__`);
// Replace tags with placeholders
tags.forEach((tag, i) => {
html = html.replace(tag, placeholders[i]);
});
// Add non-breaking spaces
for (let i = 0; i < count; i++) {
const lastSpaceIndex = html.lastIndexOf(' ');
if (lastSpaceIndex > 0) {
html = html.substring(0, lastSpaceIndex) +
' ' +
html.substring(lastSpaceIndex + 1);
}
}
// Restore tags
tags.forEach((tag, i) => {
html = html.replace(placeholders[i], tag);
});
element.innerHTML = html;
}
// First, add a script variable to store the original post URL
let blueskyPostUrl = '';
async function loadBlueskyComments() {
const currentUrl = window.location.href;
const commentsDiv = document.getElementById('bluesky-comments');
// Clear existing content
commentsDiv.innerHTML = '';
const commentsList = document.createElement('ul');
commentsDiv.appendChild(commentsList);
try {
const searchParams = new URLSearchParams({ q: currentUrl });
const searchResponse = await fetch(
`https://public.api.bsky.app/xrpc/app.bsky.feed.searchPosts?${searchParams}`,
{ headers: { Accept: "application/json" } }
);
if (!searchResponse.ok) throw new Error("Failed to search posts");
const searchData = await searchResponse.json();
// Update stats and post URL for the first matching post
if (searchData.posts && searchData.posts[0]) {
const post = searchData.posts[0];
// Update stats
document.getElementById('reply-count').textContent = post.replyCount || 0;
document.getElementById('repost-count').textContent = post.repostCount || 0;
document.getElementById('like-count').textContent = post.likeCount || 0;
// Update comment link
blueskyPostUrl = `https://bsky.app/profile/${post.author.did}/post/${post.uri.split('/').pop()}`;
const commentPromptLink = document.querySelector('.comment-prompt a');
if (commentPromptLink) {
commentPromptLink.href = blueskyPostUrl;
}
}
// For each post found, fetch its thread
const allComments = [];
for (const post of searchData.posts) {
const threadParams = new URLSearchParams({ uri: post.uri });
const threadResponse = await fetch(
`https://public.api.bsky.app/xrpc/app.bsky.feed.getPostThread?${threadParams}`,
{ headers: { Accept: "application/json" } }
);
if (threadResponse.ok) {
const threadData = await threadResponse.json();
if (threadData.thread?.replies) {
allComments.push(...threadData.thread.replies);
}
}
}
// Sort all comments by time
const sortedComments = allComments.sort((a, b) =>
new Date(a.post.indexedAt) - new Date(b.post.indexedAt)
);
sortedComments.forEach(reply => {
if (!reply?.post?.record?.text) return;
const author = reply.post.author;
const li = document.createElement('li');
li.innerHTML = `
<small>
<a href="https://bsky.app/profile/${author.did}" target="_blank">
${author.displayName || author.handle}
</a>
<span class="author-handle">@${author.handle}</span>
</small>
<p>${reply.post.record.text}</p>
<small>
💬 ${reply.post.replyCount || 0}
🔄 ${reply.post.repostCount || 0}
♥️ ${reply.post.likeCount || 0}
<a href="https://bsky.app/profile/${reply.post.author.did}/post/${reply.post.uri.split('/').pop()}" target="_blank">
Link
</a>
</small>
`;
// Apply unorphanize to the list item
unorphanize(li);
commentsList.appendChild(li);
});
} catch (error) {
commentsDiv.innerHTML = `<p>Error loading comments: ${error.message}</p>`;
}
}
document.addEventListener('DOMContentLoaded', loadBlueskyComments);
Add only the styles you need
The markup works without special styling. A little spacing is enough to start:
.bluesky-stats {
margin: 10px 0;
}
.comment-prompt {
margin-bottom: 20px;
}
A few limitations I would fix before production
The example searches for the current URL, so canonical URL differences, tracking parameters, or multiple people sharing the same article can produce unexpected matches. A more reliable implementation stores the intended Bluesky post URI in Hugo front matter.
The example also inserts profile data and reply text with innerHTML. That is compact for a demonstration, but I would render untrusted text with textContent in production to avoid HTML injection.
Finally, test the empty, loading, API-error, and deleted-post states. A comment section should not leave a broken heading or misleading zero counters when Bluesky is unavailable.