Integrating Bluesky Comments into my Blog

I took inspiration from @natalie.sh

Apr 14, 2026
Apr 14, 2026
5 min read
#bluesky#blogging
Integrating Bluesky Comments into my Blog

Needing Interactions

I’ve been slowly building this blogsite in my free time, and it’s been a blast. One thing kept nagging at me, though: there was no way for readers to interact with me or my output. I enjoy reading comments on media because they create organic human interaction and conversations you can’t get anywhere else. At least, I think so.

The problem was, how do I implement comments as a broke, cheap, and unemployed web development student?

I did some research until I stumbled upon Natalie’s post about using… Bluesky as a comment system? I hadn’t touched my Bluesky account in 10 months. Honestly, I didn’t even know you could do that. My personal takeaways from the article was this:

  • Bluesky owns the content, not me. I can just fetch threads.
  • I can fetch images, embeds, and reply chains.
  • Compared to X, Bluesky seems to have more actual humans.

So the process boils down to: I publish a blog post, announce it on Bluesky, and then aggregate all the replies under that post. Those replies become the comments on my blog.

Creating the Component

About the AT Protocol

Bluesky runs on the AT Protocol, and I only needed three things:

  • A DID (Decentralized Identifier), a unique string starting with did:plc: that identifies a Bluesky account. I grabbed mine using ilo.so/bluesky-did.
  • A CID (Content Identifier), the hash and unique ID of a Bluesky post that you can find as the last segment of its URL.
  • And the AT URI, the full address combining the two: at://did:plc:.../app.bsky.feed.post/postCid.

With the public endpoint and the right URI, I call the getPostThread XRPC method. The component uses a small fetch wrapper with an AbortController for clean-up:

ts
const cid = postCid.split("/").pop()!;
const uri = `at://${did}/app.bsky.feed.post/${cid}`;

const params = new URLSearchParams({ uri, depth: "5" });
const res = await fetch(
  `https://public.api.bsky.app/xrpc/app.bsky.feed.getPostThread?${params}`,
  { signal: abortController.signal }
);

The depth is capped at 5, matching the maximum nesting we handle visually.

The Component Architecture

Taking directly from Natalie’s source code, the whole comment system resides in a single React component with a clear separation of concerns:

  • The data fetcher inside a useEffect, complete with loading and error states.
  • The Reply component that recursively renders individual posts and nested replies.
  • The Embed and MultiImageLayout components to handle external link cards and an image lightbox.
  • Tiny Stat sub‑components for displaying like, repost, and reply counts.

This keeps everything compact and testable, even though it’s all in one file.

Recursive Replies and Nesting

Bluesky threads can be looong. To display them cleanly, each Reply component renders its own author, text, and embeds, then loops over its replies array and renders more Reply components. Visual nesting is achieved with a left margin that grows linearly with depth. Instead of a fixed pixel value, the indent is based on the avatar size and gap:

tsx
const AVATAR_SIZE = 48;
const AVATAR_GAP = 14;
const indent = depth > 0 ? depth * (AVATAR_SIZE + AVATAR_GAP) : 0;

I also added a subtle vertical connector line on the left for threads with depth > 0, so it’s easy to follow reply chains.

When the recursion reaches MAX_DEPTH (5), the component stops rendering Reply children and instead shows a “Continue thread” link that opens the BlueSky post in a new tab. This avoids infinitely deep nesting while keeping the layout clean.

Handing Rich Content

The API returns embedded content in the embed field. The Embed component switches on embed.$type:

  • app.bsky.embed.external renders a link card with an optional thumbnail and description.
  • app.bsky.embed.images triggers a MultiImageLayout component that:
    • Displays a responsive grid (up to 4 images) using CDN‑optimised URLs.
    • Opens a full‑screen portal lightbox with keyboard navigation (← → Escape), previous/next buttons, and automatic body scroll locking.
    • Shows alt‑text when available, otherwise a simple position counter.

Unsupported embed types are gracefully ignored for now and don’t break anything. Because the AT Protocol is extensible, I can add new embed handlers later without much refactoring (and when I’m bothered enough to do).

Frontmatter Integration

To enable comments for a post, I added a bluesky field to the frontmatter template:

md
bluesky:
  did: did:plc:xxx
  postCid:

The blog page reads these values and passes them to the BlueskyComments component:

astro
<BlueskyComments did={post.bluesky.did} postCid={post.bluesky.postCid} skipFirst />

This works identically for posts stored locally in content/ and for those fetched from my remote Obsidian vault via GitHub. The frontmatter is parsed uniformly, so the comment system Just Works™.

Notes

TypeScript Makes It Tolerable

Natalie wasn’t kidding. The @atcute/bluesky package already comes with TypeScript definitions for all API responses. Autocomplete and compile‑time checks eliminate guesswork when accessing nested fields like post.record.text or (embed.images[0].image as any).ref.$link. TypeScript also helped me catch missing null checks early, for example when a post doesn’t have an embed at all.

It Fails Gracefully

The comment section is an optional client‑side feature. If the API request fails or the user has JavaScript disabled, the blog post remains fully functional; the comments simply do not render. The AbortController also prevents memory leaks when the component unmounts mid‑request.

Already Performant

As mentioned, I don’t handle any infrastructure myself. Bluesky’s CDN serves embedded images, and the public API is cached. No database queries or server‑side rendering costs are incurred. The current implementation fetches directly in the browser with a built‑in 5‑minute request caching via the remote post logic (not shown here), and the React component respects that.

Conclusions

Performance Considerations

For a personal blog with low (zero) traffic, relying on the browser’s natural caching and fetching only once per page visit is sufficient. If traffic increases, adding a lightweight server‑side cache (e.g., Vercel Data Cache or a fetch with next: { revalidate: 3600 }) would be straightforward. The component already separates data fetching from rendering, so swapping out the fetch layer would be minimal.

This Approach Feels Right For Me

I just wanted my blog to feel a little less lonely. What I ended up with is a small service that costs me nothing, asks nothing of my readers (there are probably none), and quietly does its job. The lightbox is a bit over‑engineered for a comment section, but it was fun to build and adds a touch of polish. I might add support for quote‑post embeds or improve the keyboard handling, but honestly, it already does exactly what I need. Sometimes the best feature is the one you stop thinking about.

If you’re reading this and thinking about adding comments to your own site, please look into natalie.sh’s article. She deserves full credit for the original idea and implementation that I adapted.

I used Kamiina Botan from Kamiina Botan, Yoeru Sugata wa Yuri no Hana as a test subject for the new comment system on this blog! She’s precious and whimsical, so please reply with more photos of her under the original post!

comments
Leave a reply on Bluesky
Loading comments…