Mastodon as Comments in Astrojs

Last Modified: July 02, 2025

6 min read

Since moving off Hugo into this Astro blog I haven’t had much time to write yet do much work to the site. One of the things on my list was adding comments but I didn’t want to use a third party service for them. The only social media I use anymore is Mastodon so it seemed the right choice to just aggregate comments from the Toots I share on Mastodon into my blog posts. This is what I came up with as my first iteration. It’s easy enough you should be able to quickly add it to your own Astro blog. Being a static site this will require builds to get the latest comments. I’ll add to this post or start a new one on my setup for Cloudflare.

Step 1 - mastodon.js

Add a /src/utils/mastodon.js file with the following javascript which uses the Mastodon to get the comment data from a Toot.

export async function getMastodonComments(statusId, instanceUrl) {
  try {
    const response = await fetch(`${instanceUrl}/api/v1/statuses/${statusId}/context`);
    const data = await response.json();
    
    // Filter out replies that aren't direct responses
    const comments = data.descendants.filter(comment => 
      comment.in_reply_to_id === statusId
    );
    
    return comments;
  } catch (error) {
    console.error('Failed to fetch Mastodon comments:', error);
    return [];
  }
}

Step 2 - Add to the schema

I use collections in Astro so I added mastodon to the schema for the frontmatter. Update the schema in /src/content.config.ts.

schema: z.object({
    title: z.string(),
    excerpt: z.string().optional(),
    publishDate: z.coerce.date(),
    updatedDate: z.coerce.date().optional(),
    isFeatured: z.boolean().default(false),
    tags: z.array(z.string()).default([]),
    seo: seoSchema.optional(),
    // Add Mastodon to the Schema
    mastodon: z.object({
      statusId: z.string().optional(),
      instanceUrl: z.string().optional(),
    }).optional(),
})

Step 3 - Add a component

Add a new component /src/components/MastodonComments.astro. This is the UI for the comments. This site uses Tailwind. Use it or change it to suit your needs.

---
import { getMastodonComments } from '../utils/mastodon.js';

export interface Props {
  statusId: string;
  instanceUrl: string;
}

const { statusId, instanceUrl } = Astro.props;
const comments = await getMastodonComments(statusId, instanceUrl);

function formatDate(dateString: string) {
  const date = new Date(dateString);
  return date.toLocaleDateString('en-US', { 
    year: 'numeric', 
    month: 'short', 
    day: 'numeric',
    hour: '2-digit',
    minute: '2-digit'
  });
}

function stripHtml(html: string) {
  // Basic HTML stripping for display name - you might want a more robust solution
  return html.replace(/<[^>]*>/g, '');
}
---

<section class="mt-12 border-t border-gray-200 pt-8">
  <div class="flex items-center gap-2 mb-6">
    <svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
      <path d="M21.327 8.566c0-4.339-3.501-7.84-7.84-7.84-4.339 0-7.84 3.501-7.84 7.84 0 4.339 3.501 7.84 7.84 7.84.433 0 .856-.035 1.269-.102v-6.344c-1.169 0-2.115-.946-2.115-2.115s.946-2.115 2.115-2.115 2.115.946 2.115 2.115c0 .434-.131.837-.355 1.174l2.141 2.141c.768-1.125 1.217-2.484 1.217-3.944z"/>
    </svg>
    <h3 class="text-xl font-semibold">
      Comments from Mastodon
    </h3>
  </div>

  {comments.length === 0 ? (
    <div class="p-6 text-center">
      <p class="mb-4">
        No comments yet. Start the conversation!
      </p>
      <a 
        href={`${instanceUrl}/web/statuses/${statusId}`} 
        target="_blank" 
        rel="noopener noreferrer"
        class="inline-flex items-center gap-2 bg-gray-300 hover:bg-gray-400 font-medium px-4 py-2 rounded-lg transition-colors"
      >
        <svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
          <path d="M21.327 8.566c0-4.339-3.501-7.84-7.84-7.84-4.339 0-7.84 3.501-7.84 7.84 0 4.339 3.501 7.84 7.84 7.84.433 0 .856-.035 1.269-.102v-6.344c-1.169 0-2.115-.946-2.115-2.115s.946-2.115 2.115-2.115 2.115.946 2.115 2.115c0 .434-.131.837-.355 1.174l2.141 2.141c.768-1.125 1.217-2.484 1.217-3.944z"/>
        </svg>
        Comment on Mastodon
      </a>
    </div>
  ) : (
    <div class="space-y-6">
      <p class="text-sm mb-4">
        {comments.length} {comments.length === 1 ? 'comment' : 'comments'} • 
        <a 
          href={`${instanceUrl}/web/statuses/${statusId}`} 
          target="_blank" 
          rel="noopener noreferrer"
          class="font-medium"
        >
          Join the discussion
        </a>
      </p>
      
      {comments.map(comment => (
        <article class="border border-gray-500 rounded-lg p-4 hover:shadow-md transition-shadow">
          <header class="flex items-start gap-3 mb-3">
            <a 
              href={comment.account.url} 
              target="_blank" 
              rel="noopener noreferrer"
              class="flex-shrink-0"
            >
              <img 
                src={comment.account.avatar} 
                alt={`${stripHtml(comment.account.display_name)}'s avatar`}
                class="w-10 h-10 rounded-full ring-2 ring-gray-100 hover:ring-indigo-200 transition-colors"
                loading="lazy"
              />
            </a>
            
            <div class="flex-1 min-w-0">
              <div class="flex items-center gap-2 flex-wrap">
                <a 
                  href={comment.account.url} 
                  target="_blank" 
                  rel="noopener noreferrer"
                  class="font-semibold transition-colors"
                  set:html={comment.account.display_name}
                />
                <span class="text-sm">
                  @{comment.account.acct}
                </span>
              </div>
              
              <div class="flex items-center gap-2 mt-1">
                <time 
                  datetime={comment.created_at}
                  class="text-sm"
                >
                  {formatDate(comment.created_at)}
                </time>
                <span>•</span>
                <a 
                  href={comment.url} 
                  target="_blank" 
                  rel="noopener noreferrer"
                  class="text-sm font-medium transition-colors"
                >
                  View on Mastodon
                </a>
              </div>
            </div>
          </header>
          
          <div 
            class="ml-11 prose-dante prose-lg max-w-none leading-relaxed"
            set:html={comment.content}
          />
          
          {comment.media_attachments && comment.media_attachments.length > 0 && (
            <div class="mt-3 space-y-2">
              {comment.media_attachments.map(media => (
                media.type === 'image' ? (
                  <img 
                    src={media.preview_url} 
                    alt={media.description || 'Attached image'}
                    class="max-w-sm rounded-lg border border-gray-200"
                    loading="lazy"
                  />
                ) : media.type === 'video' ? (
                  <video 
                    src={media.url} 
                    poster={media.preview_url}
                    controls
                    class="max-w-sm rounded-lg border border-gray-200"
                  >
                    {media.description && <track kind="descriptions" label={media.description} />}
                  </video>
                ) : null
              ))}
            </div>
          )}
          
          {(comment.reblogs_count > 0 || comment.favourites_count > 0) && (
            <div class="flex items-center gap-4 mt-3 pt-3 border-t border-gray-100">
              {comment.favourites_count > 0 && (
                <div class="flex items-center gap-1 text-sm">
                  <svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
                    <path d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z"/>
                  </svg>
                  {comment.favourites_count}
                </div>
              )}
              {comment.reblogs_count > 0 && (
                <div class="flex items-center gap-1 text-sm">
                  <svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
                    <path d="M19 7v4H5.83l3.58-3.59L8 6l-6 6 6 6 1.41-1.41L5.83 13H21V7z"/>
                  </svg>
                  {comment.reblogs_count}
                </div>
              )}
            </div>
          )}
        </article>
      ))}
      
      <div class="text-center pt-4">
        <a 
          href={`${instanceUrl}/web/statuses/${statusId}`} 
          target="_blank" 
          rel="noopener noreferrer"
          class="inline-flex items-center gap-2 font-medium transition-colors"
        >
          View full conversation on Mastodon
          <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
            <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"/>
          </svg>
        </a>
      </div>
    </div>
  )}
</section>

Step 4 - Update the blog post template

My site has the template for the blog post detail in /src/pages/blog/[id].astro

At the top of the file import the new component from Step 3 and after post from props method add the variables for the frontmatter you will add in Step 5.

import MastodonComments from '../../components/MastodonComments.astro';

const { post, prevPost, nextPost } = Astro.props;

// Add variables to extract Mastodon info from frontmatter
const mastodonStatusId = post.data.mastodon?.statusId;
const mastodonInstance = post.data.mastodon?.instanceUrl;

Add the component to the detail template where you want the comments to display. If no mastodonStatusId and/or mastodonInstance is provided in the frontmatter then this componenet will not be added to the render.

{mastodonStatusId && mastodonInstance && (
  <MastodonComments 
    statusId={mastodonStatusId} 
    instanceUrl={mastodonInstance} 
  />
)}

Step 5 - Frontmatter

After you toot your new blog post link, get the statusId from the Mastodon post and add it to the frontmatter of your blog post markdown file. Make sure you set the instanceUrl to whatever instance your account is on. For this example purposes I added mastodon.social with a dummy id.

title: 'This is my blog post title'
excerpt: 'Description of this post.'
publishDate: 'July 30 2025'
isFeatured: true
tags:
  - astrojs
mastodon:
  statusId: '1111111111111111'
  instanceUrl: 'https://mastodon.social'

It’s not ideal to have to publish a blog post, toot then update the frontmatter but it really does not take much time in the end. And if I don’t care about comments on a particular post (or older posts) I just don’t have to update the frontmatter to include Mastodon comments.

At some point if this code stays stable or matures, I might package it into an Astro plugin.

I hope this helps someone else using Astro. If you add it and like it shout at me. If you run into any issues let me know and I’ll update the post accordingly.

Comments from Mastodon

1 comment • Join the discussion