Jens Rømer

Solid-rendered Bluesky comments

Published on: Wed Apr 09 2025

I’ve been having a great time on Bluesky lately, and recently came across Emily Lui’s blog post on integrating Bluesky post comments with your blog. I think it’s a really cute and fun idea, so I decided to put my own little spin on it.

Emily’s implementation is React/Next based, but since I’m using Astro for my site I have some flexibility in terms of the type of framework to use. So I decided to try something different which I’ll outline in the sections below.

Bluesky

We need some comment data from Bluesky, that’s our first step. And it’s surprisingly easy. Bluesky has a fairly comprehensive API, and many of the endpoints don’t require authentication, let alone setting up a developer account. Here’s the endpoint for getting the post thread corresponding to this post for example - and try calling it:

https://public.api.bsky.app/xrpc/app.bsky.feed.getPostThread?uri=at://did:plc:laqygfbyvnkyuhsuaxmp6ez3/app.bsky.feed.post/3ljpikbdvts2o

The fact that this works without any prior setup, is one of the reasons why Bluesky is said to be open (their marketing page mentions the word open 9 times).

Let’s just break down the endpoint, the app.bsky.feed.getPostThread endpoint, real quick.

First there’s the API path:

https://public.api.bsky.app

Then there’s the specific path for the endpoint to get the thread data:

app.bsky.feed.getPostThread

And at the end you have a single URL param called uri, which unsurprisingly holds a URI value.

at://did:plc:laqygfbyvnkyuhsuaxmp6ez3/app.bsky.feed.post/3ljpikbdvts2o

The URI params consists of a few different pieces. The scheme at, which describes the protocol, in this case Bluesky’s AT-Protocol. The next part, the authority, is an identifier for the user, which can either be a DID (decentralized identifier) or a user handle. While the latter is more readable, it may also change, which the DID won’t. After this we have the path, consisting of the record type: app.bsky.feed.post, and an identifier for the post in question, in this case 3ljpikbdvts2o.

Astro

As mentioned this site is built with Astro, and all blog posts are written as simple markdown files (there’s various ways to integrate with a CMS, but that’s overkill for me right now). The first thing to figure out is how to make the connection between a blog post and a Bluesky post, which is a manual step. In my case, I’ll create a new blog post, share it on Bluesky and then copy the Bluesky post ID into my blog post Frontmatter (metadata). That ID can then be read in my layout component used for blog posts, and can be passed to a component that will handle rendering of the comments.

There’s a few manual steps involved, and you might be able to set up an automated pipeline for this (publish blog post, pipeline programmatically posts to Bluesky and pushes the id to the blog post metadata). But I’m ok with this, since I don’t really want to post on Bluesky programmatically, but want to use the editor experience in the app.

Now with this out of the way, we still need to actually fetch the data and render it. So far, I haven’t ever needed more than what Astro provides out of the box, which is static site generation based on a JSX-like template expression syntax. There’s obvious benefits to this, it’s fast and it feels very familiar to anyone with React experience. But the drawbacks become clear when you need more dynamic and interactivity.

In this case we don’t want the comments to be rendered statically. If we do that, then the comments section will only update when the site is re-build and deployed. Astro supports Client-Side scripts, so we could fetch data when the page loads, but then we hit a secondary issue: reactive rendering. Astro components may look like JSX and React, but Astro components aren’t actually reactive. So we could (re)fetch our data in a client-side script, but we would have to imperatively (re)render the markup. Luckily Astro is (officially) compatible with some of the most popular frontend frameworks, and this is how we’re going to solve the data-fetching and rendering challenge.

One last thing regarding Astro: By default, UI framework components are not hydrated (i.e. being made dynamic), when rendered from an Astro component. We want our component to be client-side rendered, and fetch data when the page loads, which can be solved by using client directives. In our case we make sure that our framework component is rendered client-side like this:

<Comments client:only="solid-js ..." />

Solid JS

For the last piece, the framework component, I ended up reaching for Solid JS. There’s a few reasons for this. Firstly, I like the framework and its creator Ryan Carniato, who’s an amazing resource for the web community. Also, I covered Solid’s approach to signals almost two years ago, in my post about fine grained reactivity.

Secondly, Solid is both smaller and faster than React. I would probably still pick React in a work environment, but for a small personal project going with Solid is both low risk and presents a good learning opportunity.

Thirdly, Solid actually comes with a built-in primitive for data fetching, createResource, which React (infamously) doesn’t. I could of course also add the ever-awesome TanStack Query, but having to maintain a smaller list of dependencies is definitely a plus for me.

With the motivation out of the way, let’s look at a few parts of the Solid implementation.

I’ve included a small code-example further down, and as you might see Solid initially looks very similar to React, since it also the idea of components as functions that return JSX. But it is quite different, most fundamentally in how the render function in Solid only runs once (whereas React components continuously rerender when their props/state change, or when their parent re-renders). This has quite a few implications for how you can structure your components.

What you might also notice is how createResource looks and feels similar to TanStack Query, which according to the changelog is on purpose. It’s more basic than TanStack Query, and doesn’t do caching on its own, but it’s still a very nice tool to have out of the box.

Lastly, conditional and list rendering is done with specific Solid components. In the example below a switch component that will render one of three sections based on the resource state. And a for-component that will render a list of PostComments. There’s different ways to render the post comments, but I used a recursive component for this (<PostComment> component renders its own list of <PostComment>), which worked well for this type of data structure.

const PostComments = (props: PostCommentsProps) => {
  // Looks and feels quite similar to TanStack Query
  const [commentsResource] = createResource(
    // You cannot destructure props since they are wrapped in Object getters
    () => props.postId,
    (postId) => fetchData(postId)
  );

  // This only runs once!

  return (
    <Switch>
      <Match when={commentsResource.loading}>
        {/* Loading state */}
      </Match>
      <Match when={commentsResource.error}>
        {/* Error state */}
      </Match>
      {/* The getter is an actual function */}
      <Match when={commentsResource()}>
        {/* No need to remember keys when mapping..
          .. though you can forget to use the <For> component.. */}
        <For each={commentsResource()?.replies}>
          {(comment) => {
            return <PostComment post={comment} />;
          }}
        </For>
      </Match>
    </Switch>
  );
};

Now, if you want to see your name and comment displayed below, just click the comment stats to go to the post, and reply away. See you there!