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.
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.
Publish a blog post, which triggers a static site build
Publish a Bluesky post which links to the published blog post
Associate the ID of the Bluesky post with that published blog post (in a CMS, for example)
Re-build the site
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
A Bluesky account
A website
Some blog posts
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 {
/* to avoid CLS as much as possible! */
min-height: 400px;
/* to prevent negative margin from allowing avatars to spill out of container */
padding-right: 1rem;
}
.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--placeholder,
.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 55
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 = 55;
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
, adding a placeholder fallback if there is no avatar provided. 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 or the aria-label
on the placeholder span
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.innerText = `+${postLikesCount - likesActorLength}`;
likesContainer.appendChild(likesMore);
}
}
function drawLikes(likesActors, postLikesCount) {
for (const like of likesActors) {
const likeEl = document.createElement("li");
likeEl.classList.add("post__like");
if (like.actor.avatar !== undefined) {
likeEl.innerHTML = `
<img class="post__like__avatar"
src="${like.actor.avatar.replace("avatar", "avatar_thumbnail")}"
alt="${like.actor.displayName}"
/>`;
} else {
likeEl.classList.add("post__like--placeholder");
likeEl.innerHTML = `
<span aria-label="${like.actor.displayName}">@</span>
`;
}
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
It only takes a few seconds from a Bluesky user liking a post to their avatar showing up on a blog post.
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).The Bluesky
getPosts
API updates quicker than thegetLikes
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.
🦋 likes on Bluesky
Like this post on Bluesky to see your face on this pageSalma 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.