Guides

How to embed a Substack feed

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.

  • Website Development
  • Marketing
Taylor Osborn
Updated:
Oct 5, 2025
Created:
Oct 5, 2025

Challenge: There is no official Substack API

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.

The problem:

  • No official API to fetch newsletter posts
  • CORS restrictions block direct browser requests
  • Substack's embed options are limited to individual posts
  • No way to show a feed of recent newsletter articles

This left us with a few options, but honestly, none of them were great:

  • Manual content updates: Yeah, we could copy-paste content every once in a while, but who wants to do that?
  • Substack's official embeds: Only works for individual posts, not feeds of recent articles.
  • Paid third-party services: Adds monthly costs and another dependency to manage.
  • Build everything from scratch: Sure, but that's a lot of work for something that might be simpler.

We tried the paid SaaS solutions

Option 1: Individual Post Embeds

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>

Pros:

  • Official Substack solution
  • No CORS issues
  • Automatic styling
  • Always up-to-date

Cons:

  • Only one post at a time
  • Requires specific post URL
  • No feed functionality
  • Limited customization

Option 2: Paid third-party services

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.

Issues with paid services

  • CORS: The services monetization is enforced with CORS restrictions.
  • Reliability: Services often change pricing, or just go down as freemium models become less generours.
  • Cost: Monthly subscriptions for basic functionality
  • Control: Limited customization options
  • Dependencies: Relying on third-party uptime and additional requests

Winning solution: RSS to the rescue

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:

  • Free and public
  • Always up-to-date
  • CORS-friendly when accessed through the right proxy
  • Contains all recent posts

How we made it work

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.

How it all comes together

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.

Example script you can copy and paste

Core implementation

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>';
    }
  });
})();

Usage examples

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>

Example CSS

<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>

Live demo

Here's how it looks in action on: safe.ai/newsletter

Why this solution works

Key advantages

  • Always Free: No paid services or API keys required
  • No iframes: Content renders directly in your page DOM
  • Fully Stylable: Complete control over HTML and CSS
  • Reliable: Uses official RSS feeds from Substack
  • Lightweight: Single JavaScript file, no dependencies
  • Multiple Newsletters: Support for different newsletters on one page
  • Retry Logic: Built-in error handling and retry mechanism that is easy to expand on.

Extensibility considerations

This architecture provides a solid foundation for advanced feature implementation:

  • Custom theming systems with CSS variable integration
  • Advanced content filtering and search capabilities
  • Analytics integration and usage tracking
  • Responsive design optimization for mobile devices
  • Dark mode implementation with CSS custom properties
  • Social media sharing and engagement features

Ready to get started?

Here's what you need to do to get this up and running:

  1. Grab the embed script, talk to an LLM about it to understand or improve it, and add it to your project.
  2. Set up your CSS (or use your existing classes).
  3. Drop in your HTML containers with the right data attributes.
  4. Include the script on your page.
  5. Organize it up however you want - it's just regular HTML.

Wrapping Up

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.

Summary:

  1. Substack has no API, and no way to embed a feed.
  2. Every Substack newsletter automatically has a public RSS feed.
  3. RSS feed deliver XML, which we can parse into JSON, which can be used to create the website content.
  4. A pure client-side JavaScript solution lets you render a customizable post feed directly into your page DOM — no iframes, no server-side code.
  5. You can configure per-embed behavior (number of posts, whether to show images or dates) using data- attributes or global defaults.

Design brings delight

ODW helps clients succeed by building performant websites that create value through business-case integrations, analytics, and scalability.