I had a simple goal: embed my Bluesky posts directly into my blog. What could go wrong with such a straightforward request? Well, as it turns out, quite a lot, but in the most educational way possible.
The “simple” plan
My vision was clear: write a quick component, hit Bluesky’s API, render the post. Done. Bluesky even provides excellent oEmbed documentation, so this should be a 30-minute task, right?
The first version worked beautifully:
---const { url } = Astro.propsconst response = await fetch(`https://embed.bsky.app/oembed?url=${url}`)const data = await response.json()---<div set:html={data.html} />
Three lines of code. Beautiful. Elegant. Perfect.
Until I realized I wanted theme support.
The theme dilemma
Here’s where my perfectionist tendencies kicked in. I didn’t just want the embed to match my site’s theme, I wanted it to react to theme changes in real-time. No page refresh, no flicker, just seamless adaptation.
But here’s the first problem: Bluesky’s oEmbed endpoint doesn’t actually support a theme
parameter. Unlike some other providers, you can’t just pass &theme=dark
and get a dark-themed embed back.
This meant I needed to move from Astro’s server-side rendering to a client-side approach where I could manipulate the embed after receiving it. Simple enough, right? Just fetch the data on the client instead:
// This should work... right?const response = await fetch(`https://embed.bsky.app/oembed?url=${url}`)
The CORS reality check
Then reality hit. Hard.
Access to fetch at 'https://embed.bsky.app/oembed' from origin 'https://vinh.dev' has been blocked by CORS policy
Of course. Most oEmbed endpoints don’t allow cross-origin requests from browsers, for good security reasons. I could have accepted defeat and stuck with server-side rendering, but where’s the fun in that?
Building the bridge
I needed a proxy. Something that could:
- Fetch oEmbed data from any provider
- Handle CORS properly
- Support theme parameters
- Be fast and cached
Enter Cloudflare Workers, perfect for this lightweight proxy task.
The core insight was realizing this wasn’t just about Bluesky anymore. If I was building a proxy, why not make it work with any oEmbed provider? Twitter, YouTube, Instagram, the whole ecosystem.
Here’s the heart of the solution:
async function findProvider(url, providers) { for (const provider of providers) { for (const endpoint of provider.endpoints) { for (const scheme of endpoint.schemes || []) { const pattern = new RegExp('^' + scheme.replace(/\*/g, '.*') + '$'); if (pattern.test(url)) { return { name: provider.provider_name, endpoint: endpoint.url }; } } } } return null;}
The worker fetches the official oEmbed providers list, matches URLs against provider patterns, and proxies the requests. Clean, universal, and extensible.
The script injection challenge
Back on the client side, I hit another snag. Since Bluesky’s oEmbed doesn’t support theme parameters, I needed to manipulate the embed HTML to add theme support manually. This meant:
- Getting the raw HTML from the oEmbed response
- Adding
data-bluesky-embed-color-mode="dark"
attributes to the blockquotes - Re-injecting the HTML into the DOM
But here’s the catch: oEmbed responses often include JavaScript that needs to execute, like Bluesky’s embed enhancement scripts. When you inject HTML with innerHTML
, those scripts don’t run for security reasons.
The solution required a careful dance:
function injectContent(container, html, isDark) { const temp = document.createElement('div'); temp.innerHTML = html;
// Add theme support to Bluesky embeds (since oEmbed doesn't support it) const blockquotes = Array.from(temp.getElementsByTagName('blockquote')); blockquotes.forEach(blockquote => { if (blockquote.classList.contains('bluesky-embed') || blockquote.getAttribute('data-bluesky-uri')?.includes('bsky')) { blockquote.setAttribute('data-bluesky-embed-color-mode', isDark ? 'dark' : 'light'); } });
// Extract and handle scripts separately so they actually execute const scripts = Array.from(temp.getElementsByTagName('script')); scripts.forEach(oldScript => oldScript.remove());
container.innerHTML = temp.innerHTML;
// Re-create and append scripts to make them execute scripts.forEach(oldScript => { const newScript = document.createElement('script'); Array.from(oldScript.attributes).forEach(attr => { newScript.setAttribute(attr.name, attr.value); }); newScript.innerHTML = oldScript.innerHTML; container.appendChild(newScript); });}
The theme observer pattern
The final piece was making everything reactive to theme changes. Since I was manually injecting theme attributes, I needed to re-process embeds whenever the site theme changed. I used a MutationObserver to watch for theme attribute changes on the document:
const observer = new MutationObserver((mutations) => { mutations.forEach((mutation) => { if (mutation.attributeName === 'data-theme') { reloadAllEmbeds(); // Refresh with new theme } });});
observer.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme']});
The sweet result
Now, embedding any oEmbed-compatible content is as simple as:
<OEmbed url="https://bsky.app/profile/vinh.dev/post/3lalirzsyuc2i" />
And here’s what that produces:
The embed automatically matches my site’s theme, updates in real-time when users toggle between light and dark modes, and works with dozens of services beyond just Bluesky.
What I learned
This “simple” embedding task taught me several valuable lessons:
-
Start simple, then iterate. My first three-line solution was perfect for the initial use case. The complexity only became necessary when requirements evolved.
-
CORS exists for good reasons. While frustrating, understanding why these restrictions exist helped me build a more secure solution.
-
Universal solutions often aren’t much harder than specific ones. Once I was building a proxy anyway, supporting all oEmbed providers was only slightly more work than supporting just Bluesky.
-
The browser’s security model is your friend. The script injection challenges forced me to understand how browsers protect against XSS attacks.
Beyond the code
Looking back, what started as “I want to embed a Bluesky post” became a journey through web standards, browser security, serverless architecture, and reactive UI patterns. Sometimes the best learning happens when simple tasks refuse to stay simple.
The full implementation is available in my GitHub repo if you want to dive deeper. But remember, you don’t need all this complexity if your use case is simpler. Sometimes the three-line solution really is perfect.
Follow me on Bluesky where this whole adventure began, and feel free to share your own over-engineering stories. We’ve all been there!