Published on

Adding a Bluesky feed to your website


With the late influx of people to Twitter alternatives like Bluesky, I ended up wondering how I might be able to use the AT protocol as a backend for my blog.

So far, I’ve only spent some time daydreaming about setting up a PDS to keep final control over all of my data, but I haven’t actually gotten around to doing anything about it.

I’m a firm believer in starting somewhere when you’re a bit lost, so I started by pulling my Bluesky posts as a new type of shortform posts on this blog. This post provides a simple guide to how you might do the same.

Setup

Here’s what you might need to follow along easily, you can pretty easily replace Astro with whichever framework or library you might prefer.

This is a pretty simple stack, though it does make a lot of use of Astro to do all of the heavy lifting.

The ingredients

There’s just a couple of components to our Bluesky feed that we need to set things up.

  • A function for fetching the posts
  • A component for displaying a single post

Fetching a feed

We’re fetching the Bluesky feed using a Bluesky’s public API, more specifically it’s getAuthorFeed endpoint. Atproto being open, this is publically available and we can pull our feed with a simple get request.

Here’s the script in all it’s simplicity, the parameters are pretty pretty self-explanatory and we’re just passing through the parameters to the API.

type FeedFilter =
  | "posts_no_replies"
  | "posts_with_replies"
  | "posts_no_replies"
  | "posts_with_media"
  | "posts_and_author_threads";

export const getFeed = async (
  author: string = "dvergmal.dev",
  limit: number = 10,
  filter: FeedFilter = "posts_no_replies",
) => {
  const response = await fetch(
    `https://public.api.bsky.app/xrpc/app.bsky.feed.getAuthorFeed?actor=${author}&filter=${filter}&limit=${limit}`,
  );

  return await response.json();
};

I also wanted to simplify the posts a bit, since there’s a lot of data in the feed posts that are not strictly necessary for us.

When you’re working with atproto structures, pay attention to the shape of the data as there’s a lot of nested data that you might get tangled up in.

export interface FeedPost {
  text: string;
  createdAt: string;
  uri: string;
  isRepost: boolean;

  author: {
    did: string;
    name: string;
    avatar: string;
    handle: string;
  };

  interaction: {
    likes: number;
    comments: number;
    reposts: number;
    quotes: number;
  };
}

const mapPosts = (author: string, posts: any[]): FeedPost[] => {
  return posts.map(({ post }) => {
    return {
      text: post.record.text,
      createdAt: post.record.createdAt,
      isRepost: post.author.handle !== author,
      uri: post.uri,
      author: {
        did: post.author.did,
        name: post.author.displayName,
        avatar: post.author.avatar,
        handle: post.author.handle,
      },
      interaction: {
        likes: post.likeCount,
        comments: post.replyCount,
        reposts: post.repostCount,
        quotes: post.quoteCount,
      },
    };
  });
};

Hook these two functions up and you’re ready to fetch your feed.

export const getFeed = async (
  author: string = "dvergmal.dev",
  limit: number = 10,
  filter: FeedFilter = "posts_no_replies",
) => {
  const response = await fetch(
    `https://public.api.bsky.app/xrpc/app.bsky.feed.getAuthorFeed?actor=${author}&filter=${filter}&limit=${limit}`,
  );

  const data = await response.json();
  return mapPosts(author, data.feed);
};

We’ve got our feed and using is now pretty simple. Let’s say we have an imaginary Bluesky account with the handle @johndoe.dvergmal.dev, we would use our function like this: getFeed('johndoe.dvergmal.dev').

Setting up our feed component

Our feed might be something simple, perhaps just a <ol> element wrapping a each of our recently created feed-posts. Maybe something like this?

  1. John Doe

    I wanted to create a relatively simple and clean Post component.

    5 replies on bsky
  2. Repostito
    Repostito
    @SomeHandle

    I've reposted this post from another user.

    Reposted

There’s two posts here, the first one is a post of our own and the second one a post we’ve reposted on Bluesky.

The simple version

If you decide to filter our reposts, the following snipped for the <li> element should be all you need to create posts similar to the first one we displayed.

<li class="list-none flex flex-col mx-0 rounded-2xl p-5 bg-white">
  <div class="flex items-center gap-4">
    <img
      src={post.author.avatar}
      alt={post.author.name}
      class="w-8 h-8 rounded-full"
    />
    <div class="flex flex-col flex-1">
      <div
        class="flex flex-row items-center justify-between *:m-0 *:p-0 *:leading-none w-full"
      >
        <h5 class="text-lg">{post.author.name}</h5>
        <a
          href={`https://bsky.app/profile/${post.author.handle}`}
          class="text-base text-rose-400 hover:underline"
          >@{post.author.handle}</a
        >
      </div>
      <FormattedDate date={new Date(post.createdAt)} />
    </div>
  </div>
  <p class="my-2 flex-1 whitespace-pre-line">
    {post.text}
  </p>
  <div class="flex flex-row gap-2">
    <span class="text-sm text-gray-500">
      {post.interaction.comments} replies
    </span>
    <span class="text-sm text-gray-500">
      {post.interaction.reposts} reposts
    </span>
    <span class="text-sm text-gray-500">
      {post.interaction.likes} likes
    </span>
  </div>
</li>

Differentiating reposts

Earlier, if you followed the the example, you might have noticed we’ve added a isRepost key to the FeedPost data model. We can now use this boolean to differentiate between reposts and posts of your own.

This is probably something you’d like to do since if you repost a fair bit, these will quickly clutter your feed if they’re not recognisable from posts of your own.

Putting all things together, here’s the final version of our <li> element.

The complex version

We’re using a ternary to add some differentiating styles to the reposts and we’re replacing the interaction data with a simple “Reposted” message.

<li
  class={`list-none flex flex-col mx-0 rounded-2xl p-5 ${post.isRepost ? "bg-gray-50" : "bg-white"}`}
>
  <div class="flex items-center gap-4">
    <img
      src={post.author.avatar}
      alt={post.author.name}
      class="w-8 h-8 rounded-full"
    />
    <div class="flex flex-col flex-1">
      <div
        class="flex flex-row items-center justify-between *:m-0 *:p-0 *:leading-none w-full"
      >
        <h5 class="text-lg">{post.author.name}</h5>
        <a
          href={`https://bsky.app/profile/${post.author.handle}`}
          class="text-base text-rose-400 hover:underline"
          >@{post.author.handle}</a
        >
      </div>
      <FormattedDate date={new Date(post.createdAt)} />
    </div>
  </div>
  <p class="my-2 flex-1 whitespace-pre-line">
    {post.text}
  </p>
  {
    post.isRepost ? (
      <div class="text-gray-500 text-sm font-bold">Reposted</div>
    ) : (
      <div class="flex flex-row gap-2">
        <span class="text-sm text-gray-500">
          {post.interaction.comments} replies
        </span>
        <span class="text-sm text-gray-500">
          {post.interaction.reposts} reposts
        </span>
        <span class="text-sm text-gray-500">
          {post.interaction.likes} likes
        </span>
      </div>
    )
  }
</li>

In conclusion

Working with the Bluesky API is quite straightforward and due to it’s open nature, you can very easily get access to your posts.

This provides a very easy way for anyone to get started with microblogging by simply using Bluesky as your CMS and pulling your content from the feed.

Next steps to claim improved ownership over that those posts of yours would then be to set up a PDS to store your posts.

You can find a full-sized working version of this code on my Shorts page. If you found this useful, please consider sharing it.