How to Embed a Bluesky Feed on Your Website (2026)
Bluesky has no official feed embed. Its embed system supports exactly one HTTP URL pattern — https://bsky.app/profile/*/post/* — so it will render one post and refuse everything else, including your own profile. Hand the oEmbed endpoint a profile URL and it answers HTTP 400 with the sentence "only bsky.app post URLs currently supported." A running feed is therefore a third-party job, and there are three ways to do it: a web component, a hosted widget, or about twenty lines of your own JavaScript against Bluesky's public API — which needs no login, no app password and no API key.
Most of the page-one results for this query sell you the middle option. SociableKit and Common Ninja walk you through the same loop — build a widget, customise it, copy the code, paste it — with Common Ninja promising that "no coding knowledge is needed to set up or customize Bluesky Feed." Taggbox ranks for the feed query while its instructions actually teach Bluesky's single-post embed, one post at a time. What none of the three mentions is that the data behind the widget is a public, unauthenticated HTTP GET you can make yourself. So rather than write a fifth version of that, I measured the routes that have a measurable payload, on August 26, 2026: what each one weighs on the wire, which endpoints answer without credentials, and which one quietly 403s.
Can You Embed a Bluesky Feed the Official Way?
No, and the endpoint tells you so itself. Bluesky is a registered oEmbed provider, and the official documentation for the endpoint lists the supported URL patterns as a list of one:
The HTTP URL patterns which the oEmbed endpoint supports are:
https://bsky.app/profile/*/post/*: post embeds
That is the whole list. (The at:// URI for the same post also works — it's the same post by another name.) I checked what happens when you ignore it. Asking https://embed.bsky.app/oembed for a profile URL, and then for a feed URL, returns the same HTTP 400 and the same error string both times:
Expected 'url' to be bsky.app URL or AT-URI: only bsky.app post URLs currently supported
The word doing the work there is currently. Nothing in the protocol prevents a feed embed; Bluesky just hasn't built one, and the gap is where the widget market lives.
What the official embed does do, it does well. The snippet is a <blockquote class="bluesky-embed"> plus a script tag; the script replaces the blockquote with an iframe that re-fetches the post and renders media and quote-posts properly. It ignores maxheight entirely — "the rendered height of posts is not known until rendered", so height comes back null, the precedent Twitter set. And the public content policy is the logged-out one: adult content redacted, deleted posts and accounts redacted, and the "no unauthenticated viewers" profile preference checked and enforced. That last clause is worth remembering when someone's embed renders blank on your page and fine in your browser.
What Can You Embed on Bluesky Without Logging In?
This is the table I wanted when I started, and couldn't find anywhere. Bluesky's AppView serves a large slice of its read API publicly — the docs say "many Bluesky Lexicon endpoints are public, and do not require authentication… preferably via the https://public.api.bsky.app hostname, which includes additional caching." Many is doing some work in that sentence, so here is every embed-shaped endpoint, called against public.api.bsky.app with no credentials on August 26, 2026, with the status code it actually returned:
| What you want to show | Endpoint | No-auth status |
|---|---|---|
| One post | embed.bsky.app/oembed |
200 — the official route |
| One account's posts | app.bsky.feed.getAuthorFeed |
200 |
| A custom feed | app.bsky.feed.getFeed |
200 |
| A list's posts | app.bsky.feed.getListFeed |
200 |
| A hashtag or search | app.bsky.feed.searchPosts |
403 Forbidden |
| Your own home timeline | app.bsky.feed.getTimeline |
401 |
The bottom two rows are the ones that ruin embeds. Your home timeline is not embeddable at all — it's personalised, so it needs your session, and it answers {"error":"AuthMissing"} without one.
Search is the more interesting failure, and it depends which host you ask. Five calls to public.api.bsky.app returned 403 five times, as a CDN error page rather than JSON. The same five calls to the uncached AppView at api.bsky.app returned 200 — that host serves unauthenticated search, until it decides not to. This is not my discovery: the bsky-embed component below deprecated its search attribute over exactly this, and its README explains that unauthenticated searchPosts "answers 403 Request forbidden by administrative rules, so search embeds fail intermittently or stop working altogether." Two hosts, two answers, no guarantee on either — build a page around it at your own risk.
If you want a hashtag on your site, the working path is a custom feed built around that hashtag, embedded by its at:// URI through getFeed. Same content, an endpoint that answers. It's one of the reasons I keep a directory of Bluesky feeds — a feed URI is a more durable thing to build on than a search query.
What Are the Three Ways to Embed a Bluesky Feed?
1. A web component — one script tag, no account. bsky-embed by Vincent Will (MIT licensed, last pushed August 25, 2026) is a custom element written in Solid.js that fetches from Bluesky's AppView and renders the results:
<script type="module"
src="https://cdn.jsdelivr.net/npm/bsky-embed/dist/bsky-embed.es.js" async></script>
<bsky-embed username="your-handle.bsky.social" limit="5" mode="dark"></bsky-embed>
It takes username, feed (an at:// URI) or the deprecated search, plus options for dark mode, link targets, load-more, and your own CSS. Two things worth knowing before you call it live: it calls api.bsky.app rather than the cached public host, and there is no setInterval anywhere in the bundle — it fetches once when the page loads and again if a visitor presses load-more. "Live" here means live as of the last page view, which for most sites is the same thing and for a few isn't.
2. A hosted widget. SociableKit, Taggbox and Common Ninja all end in the same place — a script tag you paste, rendering posts their server fetched. You get a visual editor and no maintenance; you also get a third-party script on your page and a dependency on somebody's free tier. Read the tier before you build on it: as of today, SociableKit's own tutorial lists auto-sync among its premium features, so check what "real-time" means on the plan you're actually on.
3. Twenty lines of your own. Because getAuthorFeed is public, the whole widget category is optional:
<div id="bsky-feed"></div>
<script>
const HANDLE = 'your-handle.bsky.social', LIMIT = 5;
const api = 'https://public.api.bsky.app/xrpc/app.bsky.feed.getAuthorFeed'
+ `?actor=${HANDLE}&limit=${LIMIT * 3}&filter=posts_no_replies`;
fetch(api).then(r => r.json()).then(({ feed }) => {
const box = document.getElementById('bsky-feed');
feed.filter(item => !item.reason).slice(0, LIMIT).forEach(({ post }) => {
const el = document.createElement('article');
const p = document.createElement('p');
p.textContent = post.record.text; // textContent, never innerHTML
const a = document.createElement('a');
a.href = `https://bsky.app/profile/${post.author.handle}/post/${post.uri.split('/').pop()}`;
a.textContent = new Date(post.record.createdAt).toLocaleDateString();
el.append(p, a);
box.append(el);
});
});
</script>
Three details in there are the difference between working and nearly working. filter=posts_no_replies does not exclude reposts — I pulled ten items from @bsky.app with that filter set and four came back as other people's posts, tagged app.bsky.feed.defs#reasonRepost, which is why the code drops anything with a reason and over-fetches to compensate. The post URL has to be reassembled: the API gives you an at:// URI, and the last path segment is the record key that goes after /post/ in a bsky.app link. And post text is user input, so it goes in through textContent — if you reach for innerHTML here you have built a cross-site scripting hole and pointed it at the open internet.
How Much Does a Bluesky Embed Slow Down Your Site?
More than the snippet suggests, in the official case. Here is every asset each route pulls, raw and gzipped, fetched cold on August 26, 2026:
| Method | What you add | What the browser actually downloads |
|---|---|---|
| Official post embed | 1.3 KB gzip loader (2.9 KB raw) | + 183 KB gzip / 765 KB raw JS bundle and 4.7 KB CSS, once per page, executed once per iframe |
bsky-embed component |
13.9 KB gzip (43.8 KB raw) | + one API call; a separate 370 KB video bundle only if a video scrolls into view |
| Your own ~20 lines | what you wrote | ~9.5 KB gzip of JSON for five posts (64 KB raw) |
Method notes, since numbers without them are decoration: each asset was fetched cold from its own host — embed.bsky.app for the official route, jsDelivr for the component, public.api.bsky.app for the JSON — with a single post or a five-post feed as the payload. Gzip figures wobble by a few dozen bytes between runs. Hosted widgets aren't in the table on purpose: their payload is whatever the vendor shipped this week, and any number I printed would be stale before you read it.
The first row is the one that surprised me. Bluesky's official snippet looks featherweight — a 1.3 KB script — but that script's only job is to insert an iframe pointing at embed.bsky.app, and that page loads a 765 KB JavaScript bundle (183 KB over the wire) to render one post. The browser downloads it once and caches it, so five embedded posts don't cost five downloads; they do spin up five iframes, and each iframe is its own JavaScript realm that has to execute the bundle again. Embedding a few posts on a page is fine. Embedding a wall of them is a page-speed decision you should make deliberately.
Against that, the do-it-yourself route ships about nine and a half kilobytes of JSON and whatever markup you write. Nine and a half kilobytes against a hundred and eighty-three. It is also the only route where nothing on the page arrived from a server you don't control.
How Do You Embed a Bluesky Feed in WordPress?
Core WordPress will not do it, and the reason is upstream. WordPress ships a first-class Bluesky Embed block — insert it, paste a URL, press Embed — added in WordPress 6.6 and documented since April 15, 2026, and its documentation is careful to say "embed a Bluesky post". It has no feed mode and no profile mode, because WordPress registers exactly one Bluesky pattern in its oEmbed provider list, and that pattern is the post URL. Paste a profile URL into it and you are asking embed.bsky.app a question it answers with a 400.
So on WordPress the feed options collapse to two: a Custom HTML block holding the web component or a widget script from option 2, or a plugin that calls the Bluesky API server-side and renders posts as native markup. Same fork as everywhere else — someone else's script, or your own request.
One last thing, since I sell a growth tool and it would be strange to pretend otherwise: an embedded feed is a mirror, and it is unkind. It shows your last five posts and the numbers attached to them to every visitor who scrolls that far, which is a real argument for having an audience on Bluesky before you put a window to it on your homepage. That is what Agent Sky does — $9 a month, free to start — finding accounts in your niche worth following, pacing the follows well under Bluesky's rate limits, and confirming a follow-back really happened before it unfollows anyone. Re-running our own numbers this morning, the median Agent Sky account gained +326 followers over the trailing 30 days (median across the 20 accounts with a full 30-day history, August 26, 2026 — the full growth study has the method and the spread). That is the entire overlap between this post and the product, and I'd rather say so than stretch it; if you're wiring a feed into a company site, the business-account playbook is the other half of the job.
Bluesky renders one post beautifully and a feed not at all. Every option on this page exists to fill that gap — including the twenty lines you can write yourself, which is the only one that still works when somebody's free tier changes its mind.
Quick FAQ
How do I embed a Bluesky feed on my website?
Not with Bluesky's official embed — it accepts single post URLs only. Ask embed.bsky.app/oembed for a profile or feed URL and it answers HTTP 400: "only bsky.app post URLs currently supported." For a running feed you have three options: drop in the bsky-embed web component (one script tag, about 14 KB gzipped), pay a hosted widget service, or call Bluesky's public API yourself — app.bsky.feed.getAuthorFeed on public.api.bsky.app needs no login, no app password and no API key, and returns roughly 9.5 KB of gzipped JSON for five posts.
Can you embed Bluesky posts? Yes — a single post is the one thing Bluesky embeds officially, and it works well. Open the post's ⋯ menu in the app and choose Embed Post, or paste its URL into embed.bsky.app, and you get a blockquote plus a script tag to drop into your page. Bluesky is a registered oEmbed provider, so pasting a post URL into WordPress, Ghost or anything else that does oEmbed discovery produces the same embed automatically. The limit is that it is one post per snippet: there is no official embed for a profile, a feed or a list.
Can I embed a Bluesky feed in WordPress? Not with core WordPress. The Bluesky Embed block that ships with WordPress takes the URL of one post and renders that post — it has no feed or profile mode, because WordPress registers exactly one Bluesky pattern against Bluesky's post-only oEmbed endpoint. To show a running feed you need a Custom HTML block containing a web component or widget script, or a plugin that calls the Bluesky API for you.
Can you embed a Bluesky profile instead of a single post?
Not through the official embed, no. The oEmbed endpoint supports exactly one HTTP URL pattern — https://bsky.app/profile/*/post/* — and rejects a bare profile URL with a 400. But an account's public posts are readable without logging in through app.bsky.feed.getAuthorFeed, so any profile-feed widget you have seen is calling that endpoint and rendering the result itself.
Why is my Bluesky embed not working?
Four common causes, in rough order of likelihood: you gave the embed a profile, feed or list URL instead of a post URL (HTTP 400); the post or account was deleted, which Bluesky's embed policy redacts; the author has "no unauthenticated viewers" set on their profile, which the embed checks and enforces; or you built the embed on hashtag search, and unauthenticated app.bsky.feed.searchPosts is the one read endpoint that gets throttled. Author feeds, custom feeds and list feeds all still answer 200 without auth.