
Embed a Substack feed on any website for free.
See how we embedded multiple Substack feeds into a client website for free using RSS and JavaScript — no payments, no iframes, just clean HTML with full styling control, and zero recurring costs.

Spell check entire websites
See how we embedded multiple Substack feeds into a client website for free using RSS and JavaScript — no payments, no iframes, just clean HTML with full styling control, and zero recurring costs.
So here's the thing - a client came to us wanting to embed Substack newsletters on their website. Sounds simple enough, right? Well, turns out Substack doesn't actually give you a public API to grab newsletter content. Whoops! This threw us for a bit of a loop, but hey, that's where the fun begins - finding creative ways around these kinds of roadblocks.
This left us with a few options, but honestly, none of them were great:
First, we looked at Substack's official embed system. It works great for showing a single post, but we needed a feed of recent articles. This approach would mean manually updating the embed code every time a new newsletter came out - definitely not what we were looking for.
<!-- Substack's official post embed -->
<div class="substack-post-embed">
<a href="https://substackdomain.com/p/post-slug" data-post-link>
View this post
</a>
</div>
<script src="https://substackapi.com/embeds/feed.js" async>
</script>
Next, we stumbled across something that looks like a Substack API, but it's actually a saas service that is unrelated. It might work, but we weren't excited about adding another monthly subscription and external dependency to our client's setup.
Substack newsletters have RSS feeds! You know, those old-school XML feeds that most people forgot about? Well, they're still there, and they're perfect for what we needed. We just needed a way to grab that RSS data without running into CORS issues.
The key insight is that every Substack newsletter has an RSS feed at newsletter.substack.com/feed. This feed is:
We built a JavaScript solution that runs entirely in the browser. It grabs the RSS feed data (thanks to RSS2JSON handling the XML to JSON conversion) and builds the HTML right there in the browser.
RSS Feeds Are Everywhere: Every Substack newsletter has an RSS feed at newsletter.substack.com/feed
. No API keys, no authentication - just grab the data and go!
RSS2JSON to the Rescue: This handy service takes RSS feeds and turns them into JSON we can actually use. No monthly fees, no complex setup - just point it at a Substack RSS feed and get back clean, usable data.
Build It In The Browser: Everything happens client-side. We fetch the data, build the HTML, and drop it right into your page. No server needed, no backend headaches.
No Iframes, All Style: Unlike iframe embeds, our content renders directly into your page's HTML. That means you can style it however you want with your existing CSS - it's just regular HTML elements.
Here's what happens when the page loads: our script finds all the newsletter embed containers, reads their configuration from HTML data attributes, builds the right RSS URLs, grabs the data from RSS2JSON, and then builds all the HTML right there in the browser. It's surprisingly simple once you see how it all fits together.
Data Retrieval: The script dynamically constructs RSS feed URLs and retrieves content through the RSS2JSON proxy service, ensuring CORS compliance and reliable data access.
Content Transformation: RSS XML data undergoes conversion to structured JSON, then transforms into custom HTML markup utilizing developer-defined CSS classes and styling frameworks.
DOM Integration: Generated HTML seamlessly integrates into the host page's DOM structure, enabling native CSS styling, responsive design implementation, and framework compatibility.
Now for the fun part - actually building it! We created a script that handles everything we need while keeping it simple and reliable. Here's how it works:
/**
* RSS-based Substack embed (client-side)
* - Finds elements matching `.substack-feed-embed` or `#substack-feed-embed`
* - Pulls configuration from data-* attributes with sensible defaults
* - Fetches the Substack RSS (via rss2json) and renders simple cards
*
* Optional global defaults:
* window.SubstackFeedWidget = { substackUrl: '', posts: 3, showImages: true, showDates: true };
*/
(function () {
const globalDefaults = (window.SubstackFeedWidget || {});
const defaults = {
substackUrl: '',
posts: 3,
showImages: true,
showDates: true,
...globalDefaults
};
const containers = document.querySelectorAll('.substack-feed-embed, #substack-feed-embed');
if (!containers.length) return;
const fmtDate = (iso) => {
try {
// Use reader’s locale; tweak if you want a fixed one
return new Intl.DateTimeFormat(undefined, { year: 'numeric', month: 'short', day: '2-digit' }).format(new Date(iso));
} catch {
return '';
}
};
const normalizeUrl = (u) => {
if (!u) return '';
return /^https?:\/\//i.test(u) ? u : `https://${u}`;
};
const extractFirstImg = (html) => {
if (!html) return null;
const m = html.match(/<img[^>]+src="([^"]+)"/i);
return m ? m[1] : null;
};
const truncate = (str, max = 150) => (str && str.length > max ? `${str.slice(0, max)}…` : (str || ''));
const buildCard = (post, opts) => {
const a = document.createElement('a');
a.className = 'substack-card';
a.target = '_blank';
a.rel = 'noopener';
a.href = post.link;
const wrap = document.createElement('div');
wrap.className = 'substack-post';
if (opts.showImages) {
const imgUrl = extractFirstImg(post.content);
if (imgUrl) {
const img = document.createElement('img');
img.className = 'substack-thumbnail';
img.loading = 'lazy';
img.sizes = '568px';
img.alt = post.title || 'Substack post image';
img.src = imgUrl;
wrap.appendChild(img);
}
}
const body = document.createElement('div');
const h3 = document.createElement('h3');
h3.className = 'substack-title';
h3.textContent = post.title || 'Untitled';
body.appendChild(h3);
if (opts.showDates && post.pubDate) {
const pDate = document.createElement('p');
pDate.className = 'substack-date';
pDate.textContent = fmtDate(post.pubDate);
body.appendChild(pDate);
}
const pDesc = document.createElement('p');
pDesc.className = 'substack-desc';
// Use description (plaintext-ish from rss2json) and truncate
pDesc.textContent = truncate(post.description, 160);
body.appendChild(pDesc);
wrap.appendChild(body);
a.appendChild(wrap);
return a;
};
const fetchWithTimeout = (url, ms = 10000) => {
const ctl = new AbortController();
const t = setTimeout(() => ctl.abort(), ms);
return fetch(url, { signal: ctl.signal }).finally(() => clearTimeout(t));
};
containers.forEach(async (container) => {
const cfg = {
substackUrl: container.getAttribute('data-substack-url') || defaults.substackUrl,
posts: parseInt(container.getAttribute('data-posts'), 10) || defaults.posts,
showImages: (container.getAttribute('data-show-images') || `${defaults.showImages}`) !== 'false',
showDates: (container.getAttribute('data-show-dates') || `${defaults.showDates}`) !== 'false'
};
if (!cfg.substackUrl) {
container.innerHTML = '<p class="substack-error">Error: No Substack URL specified.</p>';
return;
}
const feedBase = normalizeUrl(cfg.substackUrl);
const rssUrl = `https://api.rss2json.com/v1/api.json?rss_url=${encodeURIComponent(`${feedBase}/feed`)}`;
// Minimal skeleton while loading (optional)
container.innerHTML = '<div class="substack-skeleton">Loading…</div>';
try {
const res = await fetchWithTimeout(rssUrl, 12000);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
if (data.status !== 'ok' || !Array.isArray(data.items)) {
throw new Error(data.message || 'Failed to parse feed');
}
const posts = data.items.slice(0, Math.max(1, cfg.posts));
if (!posts.length) {
container.innerHTML = '<p class="substack-empty">No posts available.</p>';
return;
}
// Clear and render
container.innerHTML = '';
posts.forEach((post) => {
container.appendChild(buildCard(post, cfg));
});
} catch (err) {
console.error('Substack feed error:', err);
container.innerHTML = '<p class="substack-error">Could not load posts. Please try again later.</p>';
}
});
})();
Single newsletter:
<!-- Basic usage -->
<div id="newsletter-embed"></div>
<script> window.SubstackFeedWidget =
{ substackUrl:
"newsletter.safe.ai", posts: 3, showImages: true, showDates: true
};
</script>
<script src="embed-substack-rss.js"></script>
Multiple newsletters with custom settings:
<!-- Multiple newsletters -->
<div class="substack-feed-embed" data-substack-url="newsletter.safe.ai" data-posts="3" data-show-images="true">
</div>
<div class="substack-feed-embed" data-substack-url="platformer.substack.com" data-posts="2" data-show-images="false">
</div>
<script src="embed-substack-rss.js"></script>
<style>
.substack-feed-embed {
display: grid;
gap: 20px;
}
.substack-post {
display: flex;
gap: 15px;
align-items: flex-start;
margin-bottom: 20px;
padding: 15px;
border: 1px solid #eee;
border-radius: 8px;
}
.substack-thumbnail {
width: 120px;
height: 120px;
object-fit: cover;
border-radius: 8px;
flex-shrink: 0;
}
.substack-post div {
flex: 1;
}
</style>
Here's how it looks in action on: safe.ai/newsletter
This architecture provides a solid foundation for advanced feature implementation:
Here's what you need to do to get this up and running:
This whole adventure started with a simple request and turned into a pretty cool solution. By thinking outside the box and leveraging RSS feeds, we built something that's both powerful and surprisingly simple. No APIs, no monthly fees, no iframes - just clean, stylable content that works exactly how you want it to.
Questions? Hit us up! We love talking shop about this stuff.