How to add Bluesky comments to your Astro blog


The Bluesky logo

I used Jade Garafola’s instructions to add Bluesky comments to this blog. However, I do not use TunaCMS (this website was created yesterday using this blog starter kit), and I also found that displaying new Bluesky replies as comments required a rebuild. Since Astro is a static site generator, I had to tweak the scripts a little to make new comments appear after either a page reload or by clicking a refresh button.

1 Add Comments section

Paste the script below into a file called CommentSection.astro, which goes into /src/components/.

---
interface Props {
  uri: string;
}

const { uri } = Astro.props;

// Get the post ID for the Bluesky link
const postId = uri.split('/').pop();
const blueskyLink = `https://bsky.app/profile/${uri.split('/')[2]}/post/${postId}`;
---

<div class="comments-section" data-bluesky-uri={uri}>
  <h2>Comments</h2>
  <p class="comment-prompt">
    Reply on Bluesky <a href={blueskyLink} target="_blank" rel="noopener noreferrer">here</a> to join the conversation.
  </p>

  <div class="comments-loader">Loading comments...</div>
  <div class="comments-error" style="display: none;"></div>
  <div class="comments-list" style="display: none;"></div>

  <button class="refresh-comments">Refresh Comments</button>
</div>

<script>
  interface BlueskyPost {
  post: {
    author: {
      avatar: string;
      displayName: string;
      handle: string;
    };
    record: {
      text: string;
    };
    indexedAt: string;
    likeCount: number;
    repostCount: number;
    replyCount: number;
  };
  replies?: BlueskyPost[];
}

class CommentSection {
  private readonly uri: string;
  private readonly loader: HTMLElement;
  private readonly errorDiv: HTMLElement;
  private readonly commentsList: HTMLElement;
  private readonly refreshButton: HTMLElement;

  constructor(container: HTMLElement) {
    const uri = container.dataset.blueskyUri;
    if (!uri) {
      throw new Error('Bluesky URI is required');
    }
    this.uri = uri;

    const loader = container.querySelector('.comments-loader');
    const errorDiv = container.querySelector('.comments-error');
    const commentsList = container.querySelector('.comments-list');
    const refreshButton = container.querySelector('.refresh-comments');

    if (!loader || !errorDiv || !commentsList || !refreshButton) {
      throw new Error('Required DOM elements not found');
    }

    this.loader = loader as HTMLElement;
    this.errorDiv = errorDiv as HTMLElement;
    this.commentsList = commentsList as HTMLElement;
    this.refreshButton = refreshButton as HTMLElement;

    this.initialize();
  }

  private async initialize() {
    this.refreshButton.addEventListener('click', () => this.fetchComments());
    await this.fetchComments();

    // Auto-refresh comments every 5 minutes
    setInterval(() => this.fetchComments(), 5 * 60 * 1000);
  }

  private async fetchComments() {
    this.loader.style.display = 'block';
    this.errorDiv.style.display = 'none';
    this.commentsList.style.display = 'none';

    try {
      const endpoint = `https://api.bsky.app/xrpc/app.bsky.feed.getPostThread?uri=${encodeURIComponent(this.uri)}`;
      const response = await fetch(endpoint, {
        method: 'GET',
        headers: {
          'Accept': 'application/json'
        }
      });

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

      const data = await response.json();
      const comments = data.thread?.replies || [];

      this.renderComments(comments);
      this.loader.style.display = 'none';
      this.commentsList.style.display = 'block';
    } catch (error: unknown) {
      this.loader.style.display = 'none';
      this.errorDiv.style.display = 'block';

      const errorMessage = error instanceof Error
        ? error.message
        : 'An unknown error occurred';

      this.errorDiv.textContent = `Error loading comments: ${errorMessage}`;
    }
  }

    private renderComments(comments: BlueskyPost[]) {
      if (comments.length === 0) {
        this.commentsList.innerHTML = '<p class="no-comments">No comments yet. Be the first to comment!</p>';
        return;
      }

      this.commentsList.innerHTML = comments.map(comment => this.renderComment(comment)).join('');
    }

    private renderComment(comment: BlueskyPost): string {
  const date = new Date(comment.post.indexedAt).toLocaleDateString();

  const renderReplies = (replies: BlueskyPost[] = []): string => {
    if (replies.length === 0) return '';
    return `
      <div class="replies">
        ${replies.map(reply => {
          const replyDate = new Date(reply.post.indexedAt).toLocaleDateString();
          return `
            <div class="comment reply">
              <div class="comment-header">
                <img src="${reply.post.author.avatar}"
                     alt="${reply.post.author.displayName}'s avatar"
                     class="avatar"
                     style="width: 32px; height: 32px; border-radius: 50%; object-fit: cover;" />
                <div class="author-info">
                  <span class="author-name">${reply.post.author.displayName}</span>
                  <span class="author-handle">@${reply.post.author.handle}</span>
                </div>
              </div>
              <div class="comment-content">${reply.post.record.text}</div>
              <div class="comment-footer">
                <div class="interaction-counts">
                  <span>${reply.post.replyCount || 0} 💬</span>
                  <span>${reply.post.repostCount || 0} 🔁</span>
                  <span>${reply.post.likeCount || 0} ❤️</span>
                </div>
                <time datetime="${reply.post.indexedAt}">${replyDate}</time>
              </div>
              ${renderReplies(reply.replies)}
            </div>
          `;
        }).join('')}
      </div>
    `;
  };

  return `
    <div class="comment">
      <div class="comment-header">
        <img src="${comment.post.author.avatar}"
             alt="${comment.post.author.displayName}'s avatar"
             class="avatar"
             style="width: 32px; height: 32px; border-radius: 50%; object-fit: cover;" />
        <div class="author-info">
          <span class="author-name">${comment.post.author.displayName}</span>
          <span class="author-handle">@${comment.post.author.handle}</span>
        </div>
      </div>
      <div class="comment-content">${comment.post.record.text}</div>
      <div class="comment-footer">
        <div class="interaction-counts">
          <span>${comment.post.replyCount || 0} 💬</span>
          <span>${comment.post.repostCount || 0} 🔁</span>
          <span>${comment.post.likeCount || 0} ❤️</span>
        </div>
        <time datetime="${comment.post.indexedAt}">${date}</time>
      </div>
      ${renderReplies(comment.replies)}
    </div>
  `;
}

  }

  // Initialize all comment sections on the page
  document.querySelectorAll('.comments-section').forEach(container => {
    new CommentSection(container as HTMLElement);
  });
</script>

<style is:global>
  .comments-section {
    margin-top: 2rem;
    padding-top: 1rem;
  }

  .comments-section .comment {
    margin: 1rem 0;
    padding: 1rem;
    border: 1px solid #eee;
    border-radius: 0.5rem;
  }

  .comments-section .comment-header {
    display: flex;
    align-items: center;
    gap: 0.5rem;
  }

  .comments-section .author-info {
    display: flex;
    flex-direction: column;
  }

  .comments-section .author-handle {
    color: #666;
    font-size: 0.9em;
  }

  .comments-section .comment-footer {
    display: flex;
    justify-content: space-between;
    margin-top: 0.5rem;
    font-size: 0.9em;
    color: #666;
  }

  .comments-section .interaction-counts {
    display: flex;
    gap: 1rem;
  }

  .comments-section .replies {
    margin-left: 1.5rem;
    padding-left: 1rem;
    border-left: 2px solid #eee;
  }

  .comments-section .refresh-comments {
    margin-top: 1rem;
    padding: 0.5rem 1rem;
    background-color: #f0f0f0;
    border: none;
    border-radius: 0.25rem;
    cursor: pointer;
  }

  .comments-section .refresh-comments:hover {
    background-color: #e0e0e0;
  }

  .comments-section .comments-loader,
  .comments-section .comments-error {
    padding: 1rem;
    text-align: center;
  }

  .comments-section .comments-error {
    color: #e74c3c;
  }
</style>

2. Insert it into your blogpost template

Now, you should import the comments section by editing your layout file, which in my case is located in /src/layouts/BlogPost.astro.

Import the CommentSection at the top, and also make sure you are getting the blueskyUri prop from the frontmatter:

import CommentSection from "../components/CommentSection.astro";

type Props = CollectionEntry<'blog'>['data'];
const { title, description, pubDate, updatedDate, heroImage, alt, blueskyUri } = Astro.props;

Then, somewhere in the body of the layout, add the following:

	  {blueskyUri && <CommentSection uri={blueskyUri} /> }

3. Configure the frontmatter

The idea is to point to a Bluesky post that links to the blogpost inside the frontmatter of an article. First, we have to change config.ts to add the blueskyUri field:

 blueskyUri: z.string().optional(),

Then, you can add the blueskyUri to the frontmatter of your blogpost, for example:

blueskyUri: 'at://did:plc:bl4vbc4br3oynfgqf7igp2c4/app.bsky.feed.post/3lc6lzv23m22k'

Now, you need to change two things. The did:plc:bl4vbc4br3oynfgqf7igp2c4 is my unique Bluesky profile ID, yours will of course be different. If you visit your handle settings in Bluesky and choose ‘I have my own domain’ (even if you don’t use that setup), you will find this persistent identifier.

a screenshot showing the id

The second part is at the end: in this case 3lc6lzv23m22k, which is the post ID.

a screenshot showing the id of a Bluesky post

Now, after you publish a blog post, you can create a Bluesky post that links to it. Then, you can change the frontmatter of the blogpost that can now include the ID of the Bluesky post, and republish your blog. After that, you should see the comments section at the bottom of your blog post.

Comments

Reply on Bluesky here to join the conversation.

Loading comments...