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:

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) + 
             '&nbsp;' + 
             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}&nbsp;
          🔄 ${reply.post.repostCount || 0}&nbsp;
          ♥️ ${reply.post.likeCount || 0}&nbsp;
          <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.