Adding Bluesky Activity to an Eleventy Blog
I’ve been noticing so many folks adding Bluesky likes and comments to their personal blogs, after tacking on webmentions a while back I thought it’d be a great addition to mine too. So, I finally made some time to get it added.
I’ll share some implementation notes first, then the 11ty-centric code that pulls the feed and displays it. My solution was deeply inspired by the notes and code graciously shared by a handful of folks, I’ve linked to their content at the bottom of this post.
Let’s dive in!
Implementation Notes#anchor
I’ll admit this was simpler than I had anticipated, the public API has everything you’ll need to grab likes, reshares, and responses. That said, I wanted to make note of a few specifics that you may want to change depending on how you want this set up.
Eleventy-specific#anchor
This is specific to an 11ty (now Build Awesome) build, but the JavaScript is largely portable. All you’d need to change is how you cache the responses, or include eleventy-fetch into your project.
Static HTML Rendering#anchor
I’m going against the grain a bit and am only grabbing Bluesky data when the site is built.
- Pros – No need for client-side JavaScript, more performant
- Cons – Not real-time, so my site will lag behind a bit
I felt the pros outweighed the cons for my own case. I’m humble enough to admit I don’t get the most social media activity, so I’m okay if a like on one of my posts takes a couple hours to show up on my blog.
Bluesky Post URL#anchor
This (any?) Bluesky reactions implementation necessitates that you have a Bluesky post URL somehow attributed to your blog posts. For static files, that’d be as simple as adding bluesky to your frontmatter and updating the Javascript to grab that instead of post.custom_fields.bluesky.
If you’re pulling blog data from elsewhere then it’ll need to come with that payload somehow. In my case, all my blog data is coming from a headless WordPress implementation so I’m saving the Bluesky post URL as a WP native custom field.
Code#anchor
Finally, some code!
bluesky.js#anchor
Here’s my full bluesky.js data file, it’s grabbing and caching all Bluesky likes, reshares, and responses for any post with a bluesky post url attributed.
import { AssetCache } from "@11ty/eleventy-fetch";
import getBlogposts from "./blogposts.js";
const BSKY_API = "https://public.api.bsky.app/xrpc";
/**
* Parse a bsky.app URL into handle and post rkey
* e.g. https://bsky.app/profile/stevenwoodson.com/post/3jzu2ove5g22i
*/
function parseBskyUrl(url) {
const match = url.match(/bsky\.app\/profile\/([^/]+)\/post\/([a-zA-Z0-9]+)/);
if (!match) return null;
return { handle: match[1], rkey: match[2] };
}
/**
* Resolve a Bluesky handle to a DID
*/
async function resolveHandle(handle) {
try {
const params = new URLSearchParams({ handle });
const response = await fetch(
`${BSKY_API}/com.atproto.identity.resolveHandle?${params}`,
);
const data = await response.json();
if (!data?.did) {
throw new Error(`Could not resolve handle: ${handle}`);
}
return data.did;
} catch (err) {
throw new Error(`Failed to resolve handle ${handle}: ${err.message}`);
}
}
/**
* Fetch the post thread (replies)
*/
async function getPostThread(uri) {
try {
const params = new URLSearchParams({ uri, depth: 10 });
const response = await fetch(
`${BSKY_API}/app.bsky.feed.getPostThread?${params}`,
);
const data = await response.json();
if (!data?.thread) {
throw new Error(`No thread data returned for URI: ${uri}`);
}
return data.thread;
} catch (err) {
throw new Error(`Failed to fetch thread ${uri}: ${err.message}`);
}
}
/**
* Fetch likes for a post
*/
async function getLikes(uri) {
try {
const likes = [];
let cursor;
do {
const params = { uri, limit: 100 };
if (cursor) params.cursor = cursor;
const response = await fetch(
`${BSKY_API}/app.bsky.feed.getLikes?${new URLSearchParams(params)}`,
);
const data = await response.json();
if (data?.likes) {
likes.push(...data.likes);
}
cursor = data?.cursor;
} while (cursor);
return likes;
} catch (err) {
throw new Error(`Failed to fetch likes for ${uri}: ${err.message}`);
}
}
/**
* Fetch reposters for a post
*/
async function getReposters(uri) {
try {
const reposters = [];
let cursor;
do {
const params = { uri, limit: 100 };
if (cursor) params.cursor = cursor;
const response = await fetch(
`${BSKY_API}/app.bsky.feed.getRepostedBy?${new URLSearchParams(params)}`,
);
const data = await response.json();
if (data?.repostedBy) {
reposters.push(...data.repostedBy);
}
cursor = data?.cursor;
} while (cursor);
return reposters;
} catch (err) {
throw new Error(`Failed to fetch reposters for ${uri}: ${err.message}`);
}
}
/**
* Flatten replies from a thread into a simple list
*/
function extractReplies(thread) {
const replies = [];
if (!thread?.replies) return replies;
for (const reply of thread.replies) {
if (
reply.post &&
reply.post.author &&
reply.post.author.did &&
reply.post.uri
) {
replies.push({
author: {
name:
reply.post.author.displayName ||
reply.post.author.handle ||
"Unknown",
handle: reply.post.author.handle || "unknown",
avatar: reply.post.author.avatar || null,
did: reply.post.author.did,
},
text: reply.post.record?.text || "",
createdAt: reply.post.record?.createdAt || null,
likeCount: reply.post.likeCount || 0,
replyCount: reply.post.replyCount || 0,
url: `https://bsky.app/profile/${reply.post.author.handle || reply.post.author.did || ""}/post/${reply.post.uri.split("/").pop()}`,
replies: extractReplies(reply),
});
}
}
// Sort by likes descending
replies.sort((a, b) => b.likeCount - a.likeCount);
return replies;
}
/**
* Fetch all Bluesky reactions for a single post URL
*/
async function fetchBlueskyData(bskyUrl) {
const parsed = parseBskyUrl(bskyUrl);
if (!parsed) {
throw new Error(`Invalid Bluesky URL: ${bskyUrl}`);
}
const did = await resolveHandle(parsed.handle);
if (!did) {
throw new Error(`Could not resolve DID for handle: ${parsed.handle}`);
}
const atUri = `at://${did}/app.bsky.feed.post/${parsed.rkey}`;
const [thread, likes, reposters] = await Promise.all([
getPostThread(atUri),
getLikes(atUri),
getReposters(atUri),
]);
if (!thread || !thread.post) {
throw new Error(`Invalid thread data for ${atUri}`);
}
const postUrl = `https://bsky.app/profile/${did}/post/${parsed.rkey}`;
return {
postUrl,
likes: likes.map((like) => ({
name: like.actor?.displayName || like.actor?.handle || "Unknown",
handle: like.actor?.handle || "unknown",
avatar: like.actor?.avatar || null,
url: `https://bsky.app/profile/${like.actor?.handle || like.actor?.did || ""}`,
})),
reposters: reposters.map((reposter) => ({
name: reposter.displayName || reposter.handle || "Unknown",
handle: reposter.handle || "unknown",
avatar: reposter.avatar || null,
url: `https://bsky.app/profile/${reposter.handle || reposter.did || ""}`,
})),
replies: extractReplies(thread),
likeCount: likes.length,
repostCount: reposters.length,
replyCount: thread.replies ? thread.replies.length : 0,
};
}
export default async () => {
const cache = new AssetCache("bluesky");
if (cache.isCacheValid("2h")) {
console.log("Using cached bluesky");
return cache.getCachedValue();
}
console.log("Fetching Bluesky comments...");
const blogposts = await getBlogposts();
const postsWithBluesky = blogposts.filter(
(post) => post.custom_fields?.bluesky,
);
if (postsWithBluesky.length === 0) {
console.log("No blog posts with Bluesky URLs found");
const empty = {};
await cache.save(empty, "json");
return empty;
}
console.log(`Found ${postsWithBluesky.length} blog posts with Bluesky URLs`);
const results = {};
for (const post of postsWithBluesky) {
try {
const data = await fetchBlueskyData(post.custom_fields.bluesky);
if (data) {
results[post.slug] = data;
}
} catch (err) {
console.warn(
`Failed to fetch Bluesky data for "${post.slug}":`,
err.message,
);
}
}
await cache.save(results, "json");
return results;
};
Filters#anchor
I then set up these two filters for use in my template.
blueskyBySlug: (bluesky, slug) => {
return bluesky && bluesky[slug] ? bluesky[slug] : null;
},
blueskyIsOwn: (handle) => {
return handle === "stevenwoodson.com";
},
The first one blueskyBySlug grabs the Bluesky data for that particular post, based on its slug. The second one blueskyIsOwn helps me add a little extra styling for responses that are from me.
Template Partial#anchor
I then pull from the data gathered using the filter when rendering each post, here’s the full partial file that I’m using just below the post content.
{% set blueskyData = bluesky | blueskyBySlug(blogpost.slug) %}
<div class="l-bluesky flow">
{% if blueskyData %}
{% if (blueskyData.likeCount > 0)
or(blueskyData.repostCount > 0)or(blueskyData.replyCount > 0) %}
<header data-header-type="inline">
<h2 class="headline3" id="bluesky-reactions">
<span aria-hidden="true" class="fe fe-Bluesky"></span> Bluesky Reactions</h2>
<a href="{{ blueskyData.postUrl }}" rel="noopener noreferrer">View on Bluesky</a>
</header>
{% endif %}
{% if blueskyData.likeCount > 0 %}
<h3 class="headline5">
<span aria-hidden="true" class="fe fe-thumbs-up"></span>
{{ blueskyData.likeCount }}
{% if blueskyData.likeCount == 1 %}Like{% else %}Likes{% endif %}
</h3>
<div class="avatars">
{% for like in blueskyData.likes %}
<a class="bluesky-avatar" href="{{ like.url }}" rel="noopener noreferrer">
{% if like.avatar %}
<img src="{{ like.avatar }}" alt="{{ like.name }}" class="avatar" loading="lazy">
{% else %}
<img src="{{ '/assets/images/default-avatar.gif' | url }}" alt="{{ like.name }}" class="avatar" loading="lazy">
{% endif %}
</a>
{% endfor %}
</div>
{% endif %}
{% if blueskyData.repostCount > 0 %}
<h3 class="headline5">
<span aria-hidden="true" class="fe fe-repeat"></span>
{{ blueskyData.repostCount }}
{% if blueskyData.repostCount == 1 %}Repost{% else %}Reposts{% endif %}
</h3>
<div class="avatars">
{% for reposter in blueskyData.reposters %}
<a class="bluesky-avatar" href="{{ reposter.url }}" rel="noopener noreferrer">
{% if reposter.avatar %}
<img src="{{ reposter.avatar }}" alt="{{ reposter.name }}" class="avatar" loading="lazy">
{% else %}
<img src="{{ '/assets/images/default-avatar.gif' | url }}" alt="{{ reposter.name }}" class="avatar" loading="lazy">
{% endif %}
</a>
{% endfor %}
</div>
{% endif %}
{% if blueskyData.replyCount > 0 %}
<h3 class="headline5">
<span aria-hidden="true" class="fe fe-message-square"></span>
{{ blueskyData.replyCount }}
{% if blueskyData.replyCount == 1 %}Reply{% else %}Replies{% endif %}
</h3>
{% macro blueskyReply(reply) %}
{% if reply.text %}
{% set blueskyHandle = reply.author.handle | default('') | lower | replace('@', '') %}
<li class="reply bluesky-reply{% if blueskyHandle|blueskyIsOwn %} reply--own{% endif %}">
<div class="reply__meta">
{% if reply.author.avatar %}
<img src="{{ reply.author.avatar }}" alt="{{ reply.author.name }}" class="avatar" loading="lazy">
{% else %}
<img src="{{ '/assets/images/default-avatar.gif' | url }}" alt="" class="avatar" loading="lazy">
{% endif %}
<p>
<strong>
<a href="{{ reply.url }}" rel="noopener noreferrer">{{ reply.author.name }}</a>
{% if reply.createdAt %}
on <time datetime="{{ reply.createdAt }}">{{ reply.createdAt | readableDateFromISO }}</time>
{% endif %}
</strong>
</p>
</div>
<div class="reply__content">
{{ reply.text }}
</div>
<small class="bluesky-reply-meta">
{{ reply.likeCount }}
{% if reply.likeCount == 1 %}like{% else %}likes{% endif %} •
{{ reply.replyCount }}
{% if reply.replyCount == 1 %}reply{% else %}replies{% endif %}
</small>
{% if reply.replies and reply.replies.length > 0 %}
<ul class="replies bluesky-nested-replies">
{% for childReply in reply.replies %}
{{ blueskyReply(childReply) }}
{% endfor %}
</ul>
{% endif %}
</li>
{% endif %}
{% endmacro %}
<ul class="replies">
{% for reply in blueskyData.replies %}
{{ blueskyReply(reply) }}
{% endfor %}
</ul>
{% endif %}
{% endif %}
</div>
Shout outs#anchor
Shout out to these folks who added Bluesky content to their blogs as well, and also chose to write about their process:
- How I show Bluesky likes on my blog posts by Salma Alam-Naylor
- How I added Bluesky likes to my Astro blog by Luciano Mammino
- Bluesky Likes Web Components by Lea Verou
- Adding Bluesky Comments to 11ty Blog by Joseph Jude
- I finally added Bluesky comments and likes to my blog (and you can too!) by Brittany Ellich
- Adding Bluesky Comments to Your Astro Blog by Jade Garafola
Have some thoughts or feedback?
Join the conversation on Mastodon, Bluesky, or Send me an email.
Need help with an upcoming project?
I'd love to hear more about what you have coming up and how I can help bring it to life! My earliest availability is currently Q2 2026.
Get in Touch