Integrating Bluesky Comments into my Blog
I took inspiration from @natalie.sh
loading page…
I took inspiration from @natalie.sh
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:
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.
Bluesky runs on the AT Protocol, and I only needed three things:
did:plc: that identifies a Bluesky account. I grabbed mine using ilo.so/bluesky-did.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:
tsconst 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.
Taking directly from Natalie’s source code, the whole comment system resides in a single React component with a clear separation of concerns:
useEffect, complete with loading and error states.Reply component that recursively renders individual posts and nested replies.Embed and MultiImageLayout components to handle external link cards and an image lightbox.Stat sub‑components for displaying like, repost, and reply counts.This keeps everything compact and testable, even though it’s all in one file.
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:
tsxconst 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.
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:
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).
To enable comments for a post, I added a bluesky field to the frontmatter template:
mdbluesky: 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™.
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.
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.
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.
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.
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!