Skip to main content
whitep4nth3r logo

How I show Bluesky likes on my blog posts

Learn how to use the Bluesky API to show likes and Bluesky user avatars on your blog posts when you share them on Bluesky.

I’m really enjoying spending time on Bluesky right now. One of the things I really enjoy about the whole experience is that the project is pretty much Open Source, people are building some really cool things with the platform, and there are some nice APIs to have fun with.

I’m familiar with the Webmentions standard and how it can be used to facilitate cross-site conversations by showing data such as likes and comments/replies to links on the internet. I worked with Webmentions a few years ago to display Webmention data from other social media platforms on my site. However, it often felt like a lot of hoops to jump through, when you can just get some data from an API.

In this post, we’re going to use the Bluesky API to fetch a collection of avatars of users who have liked a Bluesky post that you have associated with a public blog post, so you can display something that looks like this on your website.

🦋 203 likes on Bluesky. Underlined link: like this post on bluesky to see your face on this page. Followed by four rows of 15 circular avatars, overlapping each other. The final avatar is a blue circle which white text that says +144.

The workflow

Given this website is a static site built with Eleventy, it requires a few steps to associate a published blog post with a Bluesky post.

  1. Publish a blog post, which triggers a static site build

  2. Publish a Bluesky post which links to the published blog post

  3. Associate the ID of the Bluesky post with that published blog post (in a CMS, for example)

  4. Re-build the site

  5. Profit

Technical choices

When building this component, I made some very deliberate technical choices based on the desired user-experience, and some important performance considerations.

I used client-side JavaScript

This website is a static site that uses client-side JavaScript sparingly. The JavaScript code for this functionality runs on my blog page templates conditionally if a blog post has a Bluesky Post ID associated with it.

Alternatives to this approach would be to (in my case) use an Edge Function to modify the static HTML response at request time, but in the past I have had performance issues with calling third-party APIs in this way, such as a slower Time to First Byte (TTFB) than desired. Read How I Fixed my Brutal TTFB for more context.

Additionally, this feature on my website is a progressive enhancement, and the function of the page is not dependent on showing Bluesky likes. Therefore, if calls to the Bluesky API fail on the client, it doesn’t matter, and we can clean up the DOM appropriately. If we were running this same code on a server, it could block the rendering of the page (without proper error handling, at least), and the post wouldn’t get read. Big shame.

With my site being a static site, technically I could fetch the Bluesky data at build time and render the data statically on each blog post. However, I wanted this feature to bring joy by being a near real-time interactive experience. And plus, it wouldn’t be ideal to re-build my website every minute or so, to keep the data in sync.

Optimising for performance

Given we are loading n third-party images (user avatars), the size of the images is important. Fortunately, the Bluesky API provides at least two image sizes for each user, and we want to use the smallest one.

Additionally, given we are loading n images and we don’t know how long they will take to load or how much of an effect they will have on the page layout, some considerations have been made to avoid Cumulative Layout Shift (CLS) as much as possible. These will be outlined alongside the code examples below.

Prerequisites to show Bluesky likes on your blog posts

  1. A Bluesky account

  2. A website

  3. Some blog posts

  4. A way to store a Bluesky post ID with your blog post data (e.g. if you write your blogs in markdown, store the post ID in your front matter; if you’re using a CMS, add a field to your blog post content model, etc)

The code

Let’s take a look at the HTML, CSS and JavaScript that makes the magic happen.

The HTML

The HTML is contained within a section element. This component contains:

  • an h3 element, which will be populated with the total number of likes (your heading level element may vary),

  • a link to the Bluesky post to encourage people to like it, and

  • an empty ul element, ready to be filled with Bluesky avatars.

For the CSS classes I’m using BEM syntax, but you can use whatever CSS system you prefer. To target the DOM elements in JavaScript I’m using data-attributes prefixed with data-bsky; you can target DOM elements using CSS classes in JavaScript, but I prefer to use data-attributes to separate concerns. You could even use IDs on the elements and target those with JavaScript if you wish.

The bskyPostId associated with a blog post is added into a data-attribute on a meta tag next to this component. This is purely unique to my set-up, given that I’m building a static site, and need access to a build-time variable on the client-side in a separate JavaScript file. You may have access to your bskyPostId in your app state, for example, if you’re using a different framework. Edit as you see fit.

<meta data-bsky-post-id="${post.bskyPostId}" />

<section class="post__likes" data-bsky-container>
<h3 class="post__likesTitle">
🦋 <span data-bsky-likes-count></span> likes on Bluesky
</h3>
<a class="post__likesCta" href="https://bsky.app/profile/{handle}/post/${postId}" target="_blank">
Like this post on Bluesky to see your face on this page
</a>
<ul data-bsky-likes class="post__likesList"></ul>
</section>

The CSS

The CSS you see here has been slightly modified from my implementation to avoid you having to use my custom properties and personal spacing preferences. Please add what you need to make your implementation right for you.

I’d like to call out the magic number min-height: 400px on the parent container class, .post__likes; this is to maintain a fixed height of at least 400px for the element on page load, so that the avatars don't shift the page content around as they gradually load in (the container will expand vertically on mobile). This is to prevent a bad CLS score. In the JavaScript code below, you’ll notice that I’ve specified a limit on the number of avatars fetched, based on how many avatars will fit comfortably inside this fixed-height container.

.post__likes {
min-height: 400px; /* to avoid CLS as much as possible! */
}

.post__likesTitle {
font-size: 2rem;
color: #000;
}

.post__likesCta {
color: #000;
font-size: 1.25rem;
font-style: italic;
display: block;
}

.post__likesList {
list-style: none;
padding: 0;
display: flex;
flex-direction: row;
flex-wrap: wrap;
}

.post__like {
width: 4rem;
aspect-ratio: 1/1;
margin-right: -1rem;
border-radius: 100%;
filter: drop-shadow(0px 0.125rem 0.125rem rgba(0, 0, 0, 0.25));
}

.post__like__avatar {
border-radius: 100%;
}

.post__like--howManyMore {
width: 4rem;
aspect-ratio: 1/1;
display: flex;
justify-content: center;
align-items: center;
font-size: 1rem;
font-weight: bold;
font-style: italic;
background-color: #208bfe; /* Bluesky blue */
color: #fff;
}

The JavaScript

Disclaimer: this code is provided in plain JavaScript; you may adapt this code to your own website framework should you wish, but the beauty of writing this in plain JavaScript is that you can use it on any front end as it is.

First, you’ll need to define a few variables. The LIMIT specifies the maximum number of avatars you want to display on your page depending on how you want to display them. My limit is set to 59 because that’s how many avatars fit nicely on four rows (with extra space to display how many more likes there are). The maximum number of avatars you can fetch with this API endpoint is 100.

The bskyPostId is grabbed from the meta tag as described in the HTML section above (you may need to do this differently depending on your framework and existing code).

In order to modify the DOM after fetching data, we need to access the container, likesContainer and likesCount elements using document.querySelector().

Replace the value of myDid with your own Bluesky DID. And everything else is good to go.

const LIMIT = 59;

const bskyPostId = document.querySelector("[data-bsky-post-id]").dataset.bskyPostId;

const container = document.querySelector("[data-bsky-container]");
const likesContainer = document.querySelector("[data-bsky-likes]");
const likesCount = document.querySelector("[data-bsky-likes-count]");

const myDid = "add_your_did";
const bskyAPI = "https://public.api.bsky.app/xrpc/";
const getLikesURL = `${bskyAPI}app.bsky.feed.getLikes?limit=${LIMIT}&uri=`;
const getPostURL = `${bskyAPI}app.bsky.feed.getPosts?uris=`;

Next, we’re going to define two functions that modify the DOM using the data from the Bluesky APIs.

The drawHowManyMore function only runs if there are more likes on the post than what has been fetched by the getLikes API. Again, I’m using BEM syntax for my CSS; if you’re using something different you will need to update which classes are added to the likesMore element.

The drawLikes function loops through the likes data from the getLikes API and creates an img element for each actor. Note that we replace avatar with avatar_thumbnail in the like.actor.avatar string. This is to display an image that is 128x128px, instead of the default 1000x1000px. Don’t forget the alt text attribute on the img element.

function drawHowManyMore(postLikesCount, likesActorLength) {
if (postLikesCount > LIMIT) {
const likesMore = document.createElement("li");
likesMore.classList.add("post__like");
likesMore.classList.add("post__like--howManyMore");
likesMore.innerHTML = `+${postLikesCount - likesActorLength}`;
likesContainer.appendChild(likesMore);
}
}

function drawLikes(likesActors, postLikesCount) {
for (const like of likesActors) {
const likeEl = document.createElement("li");
likeEl.classList.add("post__like");
likeEl.innerHTML = `
<img
class="post__like__avatar"
src="
${like.actor.avatar.replace("avatar", "avatar_thumbnail")}"
alt="
${like.actor.displayName}" />`
;

likesContainer.appendChild(likeEl);
}

drawHowManyMore(postLikesCount, likesActors.length);
}

And here’s where we get the data from the Bluesky APIs, which is fetched from two API endpoints: app.bsky.feed.getPosts and app.bsky.feed.getLikes. After checking if a bskyPostId is available and not null, construct a postUri (at-uri) using the myDid and bskyPostId variables.

Next, fetch the bskyPost data to get the total number of likes on that post: postData.posts[0].likeCount. Then, fetch the bskyPostLikes data, which will return a likes array of user objects (or actors) that contain the avatar URLs we want to display.

If the bskyPostLikes response contains any likes, we update the likesCount text content in the HTML, and run the drawLikes function. If there are any errors in these API calls, we simply remove the whole container from the page. And that’s it!

if (bskyPostId !== "null") {
const postUri = `at://${myDid}/app.bsky.feed.post/${bskyPostId}`;

try {
const bskyPost = await fetch(getPostURL + postUri);
const bskyPostLikes = await fetch(getLikesURL + postUri);
const postData = await bskyPost.json();
const likesData = await bskyPostLikes.json();

const totalLikesCount = postData.posts[0].likeCount;

if (likesData.likes.length > 0) {
likesCount.textContent = totalLikesCount;
drawLikes(likesData.likes, totalLikesCount);
}
} catch (error) {
container.remove();
}
}

View the full JavaScript file on GitHub.

Some cool observations

  1. It only takes a few seconds from a Bluesky user liking a post to their avatar showing up on a blog post.

  2. The likes actors are sorted by timestamp-of-like-descending, so when someone likes your post on Bluesky, they appear at the top left of the avatar list. This, I hope, creates even more joy than intended (for left-to-right reading geographies, at least).

  3. The Bluesky getPosts API updates quicker than the getLikes API. This means that on a page refresh, the likes number is generally up-to-date, and the avatars may take another second or two to appear on another refresh.

Share your results with me on Bluesky

I hope it goes without saying that I’d love to see your implementations and how you made this code work for your on your website. When you’re ready to post about it on Bluesky, tag the handle @whitep4nth3r.com in the replies, and I’ll like it to put my face on your blog post.

Like weird newsletters?

Join 330+ subscribers in the Weird Wide Web Hole to find no answers to questions you didn't know you had.

Subscribe

Salma is looking at you, with a rather large smile. She's pointing across herself up to her left, with a very tatooed arm. She's wearing a black shirt and black rimmed glasses.

Salma Alam-Naylor

I'm a live streamer, software engineer, and developer educator. I help developers build cool stuff with blog posts, videos, live coding and open source projects.

Related posts

19 Jun 2023

The best light/dark mode theme toggle in JavaScript

Learn how to build The Ultimate Theme Toggle™️ for your website using JavaScript, CSS custom properties, local storage and system settings.

CSS 4 min read →

4 Apr 2022

HTML is all you need to make a website

HTML-only websites are a controversial and divisive topic. But why?

Web Dev 3 min read →