How We Built an Automated RSS Feed for Our Framer Website Without a Plugin

Written by
Written by
Sachin Kamath
Sachin Kamath
|
AVP - Marketing & Design
AVP - Marketing & Design
Published on
Published on
Design Blog
Framer

TL;DR
Framer does not generate RSS feeds. We wanted one for our blog so that readers, aggregators, and newsletter tools could subscribe to new content automatically. We first tried Framer's official Server API but it timed out every time because it requires an active Framer session. We switched to a simpler approach: fetch the sitemap, scrape metadata from each blog page, and generate an RSS 2.0 XML file. A Node.js script does all of this in about two minutes for 166 posts. A GitLab CI pipeline runs it every morning at 6:30 AM IST and commits the updated feed. Netlify serves it as a static file at feed.zeliot.in/rss.xml. No plugins, no paid services, zero recurring cost.
Introduction
At Zeliot, we write about real-time data streaming, Kafka, and connected mobility. Our blog has grown to 166 posts and we publish regularly. But for a long time, there was no way for someone to subscribe to new content without bookmarking the page and checking back manually.
No RSS meant no Feedly, no newsletter auto-digests, no community aggregators picking up our posts. For a company writing technical content for engineers, that felt like a real gap. Engineers and developers still use RSS heavily. Community newsletters and aggregators that cover Kafka, data engineering, and streaming infrastructure all consume RSS feeds to discover new content. We were missing that entire distribution channel.
The fix was straightforward on paper: add an RSS feed. In practice, it took a few hours because Framer, which we use to build and host zeliot.in, does not support RSS natively. There is no setting to turn on, no export button, nothing. You either use a plugin or build it yourself.
We chose to build it ourselves for a few reasons. We have 166 posts already and more coming. We did not want to depend on a third-party plugin staying alive, staying free, or staying compatible with Framer's API. We also wanted the feed at our own domain, structured exactly the way we wanted, with full control over what gets included. And building it from scratch meant we could write this post and share the approach with anyone else running into the same problem.
This post covers everything: why we needed it, what we tried, what broke, what we fixed, and the final setup that now runs automatically every day without anyone touching it.
What is RSS and why does it matter
RSS stands for Really Simple Syndication. It is an XML file that sits at a URL on your website and lists your content in a structured format: title, link, description, and publish date. RSS readers like Feedly, Inoreader, and dozens of others check that URL periodically, see what is new, and surface it to subscribers automatically.
Here is what a single item in a feed looks like:
<item> <title>Why Kafka Migration Projects Fail</title> <link>https://www.zeliot.in/blog/kafka-migration-challenges</link> <description>Schema drift, consumer offsets, ACL gaps...</description> <pubDate>Mon, 16 Jun 2026 00:00:00 GMT</pubDate> </item>
<item> <title>Why Kafka Migration Projects Fail</title> <link>https://www.zeliot.in/blog/kafka-migration-challenges</link> <description>Schema drift, consumer offsets, ACL gaps...</description> <pubDate>Mon, 16 Jun 2026 00:00:00 GMT</pubDate> </item>
That is the entire format. The full feed is just a list of these items wrapped in some channel metadata. Nothing complex.
For us, the value of having a feed was three things. First, readers who use RSS readers can subscribe once and get every new post automatically. Second, newsletter tools like Mailchimp and Beehiiv can pull from an RSS feed to auto-generate content digests. Third, and most importantly for a technical audience, community aggregators and curators who cover Kafka, data streaming, and distributed systems specifically look for RSS feeds when deciding what content to surface. Without a feed, we were invisible to that entire layer of distribution.
Why we did not just use a plugin
The most obvious answer is Feedify, a Framer marketplace plugin that connects to your CMS collection and generates an RSS feed. We looked at it. It works.
However, we decided against it for a few reasons. With 166+ posts and a steady publishing pace, we did not want our content distribution to depend on a third-party plugin staying maintained, staying free, or staying compatible as Framer's API evolves. We also wanted the feed to live at our own domain rather than a service URL, and we wanted to control exactly what fields go into it.
Building it ourselves also meant that when we need to add a second feed for our developer blog (coming soon), we just extend the same script rather than configuring another tool.
Approach 1: Framer's official Server API
Our first instinct was to pull data directly from Framer's CMS. Framer launched a Server API in February 2026, an npm package called framer-api that lets you connect to your Framer project from any server and read CMS collections programmatically.
On paper this was the cleanest possible approach. Connect to the project, fetch the Blogs collection, map the fields to RSS items. Official, supported, and structured.
We set it up:
import { connect } from "framer-api" const framer = await connect(PROJECT_URL, API_KEY) const collections = await framer.getCollections() // Output: ['Blogs', "Dev Blogs '26", 'Newsroom', 'Authors', ...] const blogsCollection = collections.find(c => c.name === "Blogs") // Found: "Blogs" with 166 items const items = await blogsCollection.getItems()
import { connect } from "framer-api" const framer = await connect(PROJECT_URL, API_KEY) const collections = await framer.getCollections() // Output: ['Blogs', "Dev Blogs '26", 'Newsroom', 'Authors', ...] const blogsCollection = collections.find(c => c.name === "Blogs") // Found: "Blogs" with 166 items const items = await blogsCollection.getItems()
It connected. It found our collections. It could see all 166 blog posts. But then, it timed out.
Error: Connection timeout after 90000ms
After some digging, we found out why. The Framer Server API uses a stateful WebSocket connection. That WebSocket requires an active Framer browser or desktop app session to maintain it. It is designed for plugins running inside Framer, not for standalone server scripts running in a terminal or CI environment. The 90-second timeout is not a bug you can fix. It is a fundamental constraint of the architecture. That's why, we abandoned this approach and moved on.
Approach 2: Sitemap scraping
Here is the thing about a public website. All the data we needed was already publicly accessible. Framer automatically updates the sitemap at zeliot.in/sitemap.xml every time you publish. That sitemap lists every URL on the site, including the 166 blog posts. And every blog page already outputs Open Graph meta tags and JSON-LD structured data in the HTML, which includes title, description, cover image, and publish date (variables available to be used i.e. the ones that you had setup while creating the CMS collection).
So the approach became:
1. Fetch the sitemap
2. Extract all /blog/ URLs
3. Fetch each page and pull metadata from the HTML
4. Build the RSS XML
5. Write it to a file
No API keys. No authentication. No WebSockets. Just plain HTTP requests.
Building the generator
The generator is a single file, scripts/generate.mjs. Here is the core of it.
Fetching the sitemap
We used Node's built-in https module rather than the fetch API. On Node v25, fetch had SSL issues connecting to zeliot.in. Switching to the https module fixed it immediately.
function fetchText(url) { return new Promise((resolve, reject) => { const req = https.get(url, { headers: { "User-Agent": "zeliot-rss-generator/1.0" }, timeout: 15000, }, (res) => { const chunks = [] res.on("data", chunk => chunks.push(chunk)) res.on("end", () => resolve(Buffer.concat(chunks).toString("utf-8"))) res.on("error", reject) }) req.on("timeout", () => { req.destroy(); reject(new Error("Timeout")) }) }) }
function fetchText(url) { return new Promise((resolve, reject) => { const req = https.get(url, { headers: { "User-Agent": "zeliot-rss-generator/1.0" }, timeout: 15000, }, (res) => { const chunks = [] res.on("data", chunk => chunks.push(chunk)) res.on("end", () => resolve(Buffer.concat(chunks).toString("utf-8"))) res.on("error", reject) }) req.on("timeout", () => { req.destroy(); reject(new Error("Timeout")) }) }) }
Extracting blog URLs
const sitemap = await fetchText("https://www.zeliot.in/sitemap.xml") const blogUrls = [...sitemap.matchAll(/<loc>([^<]+)<\/loc>/g)] .map(m => m[1].trim()) .filter(url => url.startsWith("https://www.zeliot.in/blog/") && url !== "https://www.zeliot.in/blog/")
const sitemap = await fetchText("https://www.zeliot.in/sitemap.xml") const blogUrls = [...sitemap.matchAll(/<loc>([^<]+)<\/loc>/g)] .map(m => m[1].trim()) .filter(url => url.startsWith("https://www.zeliot.in/blog/") && url !== "https://www.zeliot.in/blog/")
One mistake we made early: we used zeliot.in instead of www.zeliot.in in the filter. The sitemap uses the www version for all URLs. The filter returned zero results until we caught that.
Fetching metadata in batches
We fetch pages in parallel groups of 5 to avoid hammering the server:
async function fetchInBatches(urls, batchSize = 5) { const results = [] for (let i = 0; i < urls.length; i += batchSize) { const batch = urls.slice(i, i + batchSize) const settled = await Promise.allSettled( batch.map(async url => { const html = await fetchText(url) return extractMeta(html, url) }) ) results.push(...settled .filter(r => r.status === "fulfilled") .map(r => r.value)) } return results }
async function fetchInBatches(urls, batchSize = 5) { const results = [] for (let i = 0; i < urls.length; i += batchSize) { const batch = urls.slice(i, i + batchSize) const settled = await Promise.allSettled( batch.map(async url => { const html = await fetchText(url) return extractMeta(html, url) }) ) results.push(...settled .filter(r => r.status === "fulfilled") .map(r => r.value)) } return results }
Extracting metadata from each page
For each page, we extract four things: title, description, cover image, and publish date.
function extractMeta(html, url) { const get = pattern => { const m = html.match(pattern) return m ? m[1].trim() : "" } const title = get(/<meta\s+property="og:title"\s+content="([^"]+)"/i) const description = get(/<meta\s+property="og:description"\s+content="([^"]+)"/i) const image = get(/<meta\s+property="og:image"\s+content="([^"]+)"/i) const pubDate = get(/"datePublished"\s*:\s*"([^"]+)"/i) return { url, title, description, image, pubDate } }
function extractMeta(html, url) { const get = pattern => { const m = html.match(pattern) return m ? m[1].trim() : "" } const title = get(/<meta\s+property="og:title"\s+content="([^"]+)"/i) const description = get(/<meta\s+property="og:description"\s+content="([^"]+)"/i) const image = get(/<meta\s+property="og:image"\s+content="([^"]+)"/i) const pubDate = get(/"datePublished"\s*:\s*"([^"]+)"/i) return { url, title, description, image, pubDate } }
The publish date required a small investigation. Framer does not output the article:published_time meta tag that RSS generators typically look for. What it does output is JSON-LD structured data in the head tag.
Building the XML
function buildXml(posts) { const itemsXml = posts.map(p => ` <item> <title>${escapeXml(p.title)}</title> <link>${p.url}</link> <guid isPermaLink="true">${p.url}</guid> <description>${escapeXml(p.description)}</description> <pubDate>${toRFC822(p.pubDate)}</pubDate> </item>`).join("\n") return `<?xml version="1.0" encoding="UTF-8"?> <rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"> <channel> <title>Zeliot Blog</title> <link>https://www.zeliot.in</link> <description>Real-time data streaming insights from the Zeliot team.</description> <language>en-US</language> <lastBuildDate>${new Date().toUTCString()}</lastBuildDate> <atom:link href="https://feed.zeliot.in/rss.xml" rel="self" type="application/rss+xml" /> ${itemsXml} </channel> </rss>` }
function buildXml(posts) { const itemsXml = posts.map(p => ` <item> <title>${escapeXml(p.title)}</title> <link>${p.url}</link> <guid isPermaLink="true">${p.url}</guid> <description>${escapeXml(p.description)}</description> <pubDate>${toRFC822(p.pubDate)}</pubDate> </item>`).join("\n") return `<?xml version="1.0" encoding="UTF-8"?> <rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"> <channel> <title>Zeliot Blog</title> <link>https://www.zeliot.in</link> <description>Real-time data streaming insights from the Zeliot team.</description> <language>en-US</language> <lastBuildDate>${new Date().toUTCString()}</lastBuildDate> <atom:link href="https://feed.zeliot.in/rss.xml" rel="self" type="application/rss+xml" /> ${itemsXml} </channel> </rss>` }
What running it looks like
๐ก Fetching sitemap... Found 166 blog URLs in sitemap ๐ Fetching metadata for 166 posts... Fetching 1-5 of 166... โ Fetching 6-10 of 166... โ ... โ Processed 166 posts Sample post: Title: 7 Kafka Migration Challenges and How to Prevent Them Date: 2026-06-16T00:00:00.000Z Image: โ โจ RSS feed written to: public/rss.xml Posts included: 166
๐ก Fetching sitemap... Found 166 blog URLs in sitemap ๐ Fetching metadata for 166 posts... Fetching 1-5 of 166... โ Fetching 6-10 of 166... โ ... โ Processed 166 posts Sample post: Title: 7 Kafka Migration Challenges and How to Prevent Them Date: 2026-06-16T00:00:00.000Z Image: โ โจ RSS feed written to: public/rss.xml Posts included: 166
166 posts, accurate dates, cover images. About two minutes end to end.
Hosting
We initially planned to use Vercel. We ran into a wall immediately: importing from a private GitLab group requires a Vercel Pro plan. Our repo lives inside a private group, so that was a non-starter on the free tier. We switched to Netlify. Free tier, supports private GitLab groups without any additional configuration. The setup was three fields:
Branch: main
Build command: (empty, no build needed)
Publish directory: public
Deploy.
The feed was live at zeliot-website-rss.netlify.app/rss.xml within two minutes.
Getting it to our domain (zeliot.in)
We wanted the feed at feed.zeliot.in/rss.xml rather than the Netlify URL. Since zeliot.in is hosted on Framer, we cannot add custom file paths directly. The solution was a subdomain pointing to Netlify. In GoDaddy DNS, we added a CNAME record:
Type | Name | Value |
|---|---|---|
CNAME | feed | zeliot-website-rss.netlify.app |
Then added feed.zeliot.in as a custom domain inside Netlify. Netlify provisions an SSL certificate automatically via Let's Encrypt once DNS propagates.
The feed is now live at:
Automating daily updates
The sitemap updates automatically whenever we publish in Framer. But the RSS file is static. It only updates when we run the generator and commit the result. We needed that to happen without anyone thinking about it. We set up a GitLab CI pipeline scheduled to run every day at 6:30 AM IST.
The .gitlab-ci.yml file:
stages: - generate generate-rss: stage: generate image: node:20 rules: - if: '$CI_PIPELINE_SOURCE == "schedule"' - if: '$CI_PIPELINE_SOURCE == "web"' script: - node scripts/generate.mjs - git config user.email "gitlab-ci@zeliot.in" - git config user.name "GitLab CI" - git add public/rss.xml - "git diff --cached --quiet && echo 'No changes' || git commit -m 'chore: regenerate RSS feed [skip ci]'" - "git push https://oauth2:${GITLAB_TOKEN}@gitlab.com/${CI_PROJECT_PATH} HEAD:main || true"
stages: - generate generate-rss: stage: generate image: node:20 rules: - if: '$CI_PIPELINE_SOURCE == "schedule"' - if: '$CI_PIPELINE_SOURCE == "web"' script: - node scripts/generate.mjs - git config user.email "gitlab-ci@zeliot.in" - git config user.name "GitLab CI" - git add public/rss.xml - "git diff --cached --quiet && echo 'No changes' || git commit -m 'chore: regenerate RSS feed [skip ci]'" - "git push https://oauth2:${GITLAB_TOKEN}@gitlab.com/${CI_PROJECT_PATH} HEAD:main || true"
Three things in this config are worth calling out.
The [skip ci] flag in the commit message tells GitLab not to trigger another pipeline run from that commit. Without it, the pipeline would commit, that commit would trigger a new pipeline, which would commit again, and so on forever.
The || true at the end of the push command prevents the pipeline from failing when there is nothing new to push. If no posts were published since the last run, the feed has not changed, and the push is a no-op.
Shell operators like && and || in GitLab YAML must be wrapped in double quotes or the YAML parser rejects the file. This caused several failed pipelines before we caught it.
When the pipeline runs:
1. It regenerates the feed by fetching the sitemap and all blog pages
2. If rss.xml changed, it commits and pushes to main
3. Netlify detects the push and redeploys
4. The updated feed is live within 3 to 4 minutes of the pipeline completing
Setting up the pipeline in GitLab
The .gitlab-ci.yml file defines what the pipeline does. But before it can run, you need to do three things in GitLab: create a personal access token, add it as a CI variable, and set up the schedule.
Step 1: Create a personal access token
The pipeline needs permission to push commits back to the repository. GitLab's built-in CI_JOB_TOKEN is read-only, so you need a separate token with write access.
Go to GitLab โ your avatar (top right) โ Edit profile โ Access Tokens โ Add new token.
Give it a name (e.g. rss-feed-ci), set an expiry date, and select the write_repository scope. That is the only scope it needs.
Copy the token value immediately, GitLab only shows it once.
Step 2: Add the token as a CI/CD variable
Go to your GitLab project โ Settings โ CI/CD โ Variables โ Add variable.
Set the following:
Field | Value |
|---|---|
Key | GITLAB_TOKEN |
Value | the token you just copied |
Type | Variable |
Protect variable | checked (runs on protected branches only) |
Mask variable | checked (hides the value in pipeline logs) |
Save. The pipeline can now reference ${GITLAB_TOKEN} without the value ever appearing in your code or logs.
Step 3: Set up the scheduled pipeline
Go to your GitLab project โ Build โ Pipeline schedules โ Create a new pipeline schedule.
Fill in:
Field | Value |
|---|---|
Description | Daily RSS feed regeneration |
Interval Pattern | 30 1 * * * |
Timezone | Asia/Kolkata |
Target branch | main |
The cron expression 30 1 * * * means 1:30 AM UTC, which is 6:30 AM IST. Save the schedule.
Step 4: Trigger a manual run to verify
Before waiting for the scheduled run, test it manually. Go to Build โ Pipeline schedules, find the schedule you just created, and click the play button on the right. This triggers an immediate run.
Go to Build โ Pipelines to watch it execute. A successful run looks like this:
$ node scripts/generate.mjs ๐ก Fetching sitemap... Found 166 blog URLs in sitemap โจ RSS feed written to: public/rss.xml $ git add public/rss.xml $ git diff --cached --quiet && echo 'No changes' || git commit -m 'chore: regenerate RSS feed [skip ci]' [main a1b2c3d] chore: regenerate RSS feed [skip ci] $ git push https://oauth2:${GITLAB_TOKEN}@${CI_PROJECT_PATH} HEAD:main
$ node scripts/generate.mjs ๐ก Fetching sitemap... Found 166 blog URLs in sitemap โจ RSS feed written to: public/rss.xml $ git add public/rss.xml $ git diff --cached --quiet && echo 'No changes' || git commit -m 'chore: regenerate RSS feed [skip ci]' [main a1b2c3d] chore: regenerate RSS feed [skip ci] $ git push https://oauth2:${GITLAB_TOKEN}@${CI_PROJECT_PATH} HEAD:main
If the run passes, the pipeline is configured correctly and will run automatically every morning from here.
Surfacing the feed in Framer
Once the feed is live, there are two things worth doing in Framer to make it discoverable.
1. Add an RSS autodiscovery tag to the head
This is a hidden <link> tag in your site's HTML that RSS readers and browsers use to auto-detect the feed. When someone visits your blog, their RSS reader sees this tag and knows a feed exists without the user having to find the URL manually.
In Framer, go to Settings(gear icon, top right) then General, scroll down to Custom Code, and paste this into the Start of head tag field:
<link rel="alternate" type="application/rss+xml" title="Zeliot Blog" href="https://feed.zeliot.in/rss.xml"
<link rel="alternate" type="application/rss+xml" title="Zeliot Blog" href="https://feed.zeliot.in/rss.xml"
Save and publish. The tag is invisible on the page but browsers, RSS readers, and feed aggregators will find it when they visit any page on your site.
Verifying it works
After publishing, open your blog in Chrome and right-click โ View Page Source. Search for application/rss+xml. You should see the tag in the <head> section. If it is there, autodiscovery is working.
You can also paste your blog URL into the W3C Feed Validation Service it follows autodiscovery links and validates the feed it finds. A clean result means your feed is well-formed and will work with any standard RSS reader.
2. Add a visible RSS button on the blog page
For readers who know what RSS is and want to subscribe manually, add a visible button on the blog page. In Framer, open the blog page in the editor. Add a Button or Link element wherever makes sense in your layout, typically near the blog title or in a subscribe section. Set the link to:
https://feed.zeliot.in/rss.xml
Set it to open in a new tab. Label it "RSS" or "Subscribe via RSS" and optionally add an RSS icon next to the text. The classic RSS icon is a small orange square with the wifi-style signal arcs, recognisable to anyone who uses a feed reader. That is all that is needed. The autodiscovery tag handles machine detection, and the button handles human subscribers.
Summary
Action | Description |
|---|---|
Feed URL | |
Posts | 166 currently, more blogs are uploaded every week |
Generator | Node.js, no external dependencies |
Hosting | Netlify free tier |
Automation | GitLab CI, 6:30 AM IST daily |
DNS | GoDaddy CNAME pointing to Netlify |
Build time | ~2 minutes for 166 posts |
The whole thing lives in one GitLab repo: the generator script, the CI config, and the generated feed file. No external services, no recurring subscriptions, no plugins. If Framer ever ships native RSS support, we can retire this in five minutes. Until then, it works exactly as it should.
Disclaimer: Claude helped me in the entire process of writing the code, guiding on how to implement this, deploying the links, etc.
Frequently Asked Questions
Introduction
At Zeliot, we write about real-time data streaming, Kafka, and connected mobility. Our blog has grown to 166 posts and we publish regularly. But for a long time, there was no way for someone to subscribe to new content without bookmarking the page and checking back manually.
No RSS meant no Feedly, no newsletter auto-digests, no community aggregators picking up our posts. For a company writing technical content for engineers, that felt like a real gap. Engineers and developers still use RSS heavily. Community newsletters and aggregators that cover Kafka, data engineering, and streaming infrastructure all consume RSS feeds to discover new content. We were missing that entire distribution channel.
The fix was straightforward on paper: add an RSS feed. In practice, it took a few hours because Framer, which we use to build and host zeliot.in, does not support RSS natively. There is no setting to turn on, no export button, nothing. You either use a plugin or build it yourself.
We chose to build it ourselves for a few reasons. We have 166 posts already and more coming. We did not want to depend on a third-party plugin staying alive, staying free, or staying compatible with Framer's API. We also wanted the feed at our own domain, structured exactly the way we wanted, with full control over what gets included. And building it from scratch meant we could write this post and share the approach with anyone else running into the same problem.
This post covers everything: why we needed it, what we tried, what broke, what we fixed, and the final setup that now runs automatically every day without anyone touching it.
What is RSS and why does it matter
RSS stands for Really Simple Syndication. It is an XML file that sits at a URL on your website and lists your content in a structured format: title, link, description, and publish date. RSS readers like Feedly, Inoreader, and dozens of others check that URL periodically, see what is new, and surface it to subscribers automatically.
Here is what a single item in a feed looks like:
<item> <title>Why Kafka Migration Projects Fail</title> <link>https://www.zeliot.in/blog/kafka-migration-challenges</link> <description>Schema drift, consumer offsets, ACL gaps...</description> <pubDate>Mon, 16 Jun 2026 00:00:00 GMT</pubDate> </item>
That is the entire format. The full feed is just a list of these items wrapped in some channel metadata. Nothing complex.
For us, the value of having a feed was three things. First, readers who use RSS readers can subscribe once and get every new post automatically. Second, newsletter tools like Mailchimp and Beehiiv can pull from an RSS feed to auto-generate content digests. Third, and most importantly for a technical audience, community aggregators and curators who cover Kafka, data streaming, and distributed systems specifically look for RSS feeds when deciding what content to surface. Without a feed, we were invisible to that entire layer of distribution.
Why we did not just use a plugin
The most obvious answer is Feedify, a Framer marketplace plugin that connects to your CMS collection and generates an RSS feed. We looked at it. It works.
However, we decided against it for a few reasons. With 166+ posts and a steady publishing pace, we did not want our content distribution to depend on a third-party plugin staying maintained, staying free, or staying compatible as Framer's API evolves. We also wanted the feed to live at our own domain rather than a service URL, and we wanted to control exactly what fields go into it.
Building it ourselves also meant that when we need to add a second feed for our developer blog (coming soon), we just extend the same script rather than configuring another tool.
Approach 1: Framer's official Server API
Our first instinct was to pull data directly from Framer's CMS. Framer launched a Server API in February 2026, an npm package called framer-api that lets you connect to your Framer project from any server and read CMS collections programmatically.
On paper this was the cleanest possible approach. Connect to the project, fetch the Blogs collection, map the fields to RSS items. Official, supported, and structured.
We set it up:
import { connect } from "framer-api" const framer = await connect(PROJECT_URL, API_KEY) const collections = await framer.getCollections() // Output: ['Blogs', "Dev Blogs '26", 'Newsroom', 'Authors', ...] const blogsCollection = collections.find(c => c.name === "Blogs") // Found: "Blogs" with 166 items const items = await blogsCollection.getItems()
It connected. It found our collections. It could see all 166 blog posts. But then, it timed out.
Error: Connection timeout after 90000ms
After some digging, we found out why. The Framer Server API uses a stateful WebSocket connection. That WebSocket requires an active Framer browser or desktop app session to maintain it. It is designed for plugins running inside Framer, not for standalone server scripts running in a terminal or CI environment. The 90-second timeout is not a bug you can fix. It is a fundamental constraint of the architecture. That's why, we abandoned this approach and moved on.
Approach 2: Sitemap scraping
Here is the thing about a public website. All the data we needed was already publicly accessible. Framer automatically updates the sitemap at zeliot.in/sitemap.xml every time you publish. That sitemap lists every URL on the site, including the 166 blog posts. And every blog page already outputs Open Graph meta tags and JSON-LD structured data in the HTML, which includes title, description, cover image, and publish date (variables available to be used i.e. the ones that you had setup while creating the CMS collection).
So the approach became:
1. Fetch the sitemap
2. Extract all /blog/ URLs
3. Fetch each page and pull metadata from the HTML
4. Build the RSS XML
5. Write it to a file
No API keys. No authentication. No WebSockets. Just plain HTTP requests.
Building the generator
The generator is a single file, scripts/generate.mjs. Here is the core of it.
Fetching the sitemap
We used Node's built-in https module rather than the fetch API. On Node v25, fetch had SSL issues connecting to zeliot.in. Switching to the https module fixed it immediately.
function fetchText(url) { return new Promise((resolve, reject) => { const req = https.get(url, { headers: { "User-Agent": "zeliot-rss-generator/1.0" }, timeout: 15000, }, (res) => { const chunks = [] res.on("data", chunk => chunks.push(chunk)) res.on("end", () => resolve(Buffer.concat(chunks).toString("utf-8"))) res.on("error", reject) }) req.on("timeout", () => { req.destroy(); reject(new Error("Timeout")) }) }) }
Extracting blog URLs
const sitemap = await fetchText("https://www.zeliot.in/sitemap.xml") const blogUrls = [...sitemap.matchAll(/<loc>([^<]+)<\/loc>/g)] .map(m => m[1].trim()) .filter(url => url.startsWith("https://www.zeliot.in/blog/") && url !== "https://www.zeliot.in/blog/")
One mistake we made early: we used zeliot.in instead of www.zeliot.in in the filter. The sitemap uses the www version for all URLs. The filter returned zero results until we caught that.
Fetching metadata in batches
We fetch pages in parallel groups of 5 to avoid hammering the server:
async function fetchInBatches(urls, batchSize = 5) { const results = [] for (let i = 0; i < urls.length; i += batchSize) { const batch = urls.slice(i, i + batchSize) const settled = await Promise.allSettled( batch.map(async url => { const html = await fetchText(url) return extractMeta(html, url) }) ) results.push(...settled .filter(r => r.status === "fulfilled") .map(r => r.value)) } return results }
Extracting metadata from each page
For each page, we extract four things: title, description, cover image, and publish date.
function extractMeta(html, url) { const get = pattern => { const m = html.match(pattern) return m ? m[1].trim() : "" } const title = get(/<meta\s+property="og:title"\s+content="([^"]+)"/i) const description = get(/<meta\s+property="og:description"\s+content="([^"]+)"/i) const image = get(/<meta\s+property="og:image"\s+content="([^"]+)"/i) const pubDate = get(/"datePublished"\s*:\s*"([^"]+)"/i) return { url, title, description, image, pubDate } }
The publish date required a small investigation. Framer does not output the article:published_time meta tag that RSS generators typically look for. What it does output is JSON-LD structured data in the head tag.
Building the XML
function buildXml(posts) { const itemsXml = posts.map(p => ` <item> <title>${escapeXml(p.title)}</title> <link>${p.url}</link> <guid isPermaLink="true">${p.url}</guid> <description>${escapeXml(p.description)}</description> <pubDate>${toRFC822(p.pubDate)}</pubDate> </item>`).join("\n") return `<?xml version="1.0" encoding="UTF-8"?> <rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"> <channel> <title>Zeliot Blog</title> <link>https://www.zeliot.in</link> <description>Real-time data streaming insights from the Zeliot team.</description> <language>en-US</language> <lastBuildDate>${new Date().toUTCString()}</lastBuildDate> <atom:link href="https://feed.zeliot.in/rss.xml" rel="self" type="application/rss+xml" /> ${itemsXml} </channel> </rss>` }
What running it looks like
๐ก Fetching sitemap... Found 166 blog URLs in sitemap ๐ Fetching metadata for 166 posts... Fetching 1-5 of 166... โ Fetching 6-10 of 166... โ ... โ Processed 166 posts Sample post: Title: 7 Kafka Migration Challenges and How to Prevent Them Date: 2026-06-16T00:00:00.000Z Image: โ โจ RSS feed written to: public/rss.xml Posts included: 166
166 posts, accurate dates, cover images. About two minutes end to end.
Hosting
We initially planned to use Vercel. We ran into a wall immediately: importing from a private GitLab group requires a Vercel Pro plan. Our repo lives inside a private group, so that was a non-starter on the free tier. We switched to Netlify. Free tier, supports private GitLab groups without any additional configuration. The setup was three fields:
Branch: main
Build command: (empty, no build needed)
Publish directory: public
Deploy.
The feed was live at zeliot-website-rss.netlify.app/rss.xml within two minutes.
Getting it to our domain (zeliot.in)
We wanted the feed at feed.zeliot.in/rss.xml rather than the Netlify URL. Since zeliot.in is hosted on Framer, we cannot add custom file paths directly. The solution was a subdomain pointing to Netlify. In GoDaddy DNS, we added a CNAME record:
Type | Name | Value |
|---|---|---|
CNAME | feed | zeliot-website-rss.netlify.app |
Then added feed.zeliot.in as a custom domain inside Netlify. Netlify provisions an SSL certificate automatically via Let's Encrypt once DNS propagates.
The feed is now live at:
Automating daily updates
The sitemap updates automatically whenever we publish in Framer. But the RSS file is static. It only updates when we run the generator and commit the result. We needed that to happen without anyone thinking about it. We set up a GitLab CI pipeline scheduled to run every day at 6:30 AM IST.
The .gitlab-ci.yml file:
stages: - generate generate-rss: stage: generate image: node:20 rules: - if: '$CI_PIPELINE_SOURCE == "schedule"' - if: '$CI_PIPELINE_SOURCE == "web"' script: - node scripts/generate.mjs - git config user.email "gitlab-ci@zeliot.in" - git config user.name "GitLab CI" - git add public/rss.xml - "git diff --cached --quiet && echo 'No changes' || git commit -m 'chore: regenerate RSS feed [skip ci]'" - "git push https://oauth2:${GITLAB_TOKEN}@gitlab.com/${CI_PROJECT_PATH} HEAD:main || true"
Three things in this config are worth calling out.
The [skip ci] flag in the commit message tells GitLab not to trigger another pipeline run from that commit. Without it, the pipeline would commit, that commit would trigger a new pipeline, which would commit again, and so on forever.
The || true at the end of the push command prevents the pipeline from failing when there is nothing new to push. If no posts were published since the last run, the feed has not changed, and the push is a no-op.
Shell operators like && and || in GitLab YAML must be wrapped in double quotes or the YAML parser rejects the file. This caused several failed pipelines before we caught it.
When the pipeline runs:
1. It regenerates the feed by fetching the sitemap and all blog pages
2. If rss.xml changed, it commits and pushes to main
3. Netlify detects the push and redeploys
4. The updated feed is live within 3 to 4 minutes of the pipeline completing
Setting up the pipeline in GitLab
The .gitlab-ci.yml file defines what the pipeline does. But before it can run, you need to do three things in GitLab: create a personal access token, add it as a CI variable, and set up the schedule.
Step 1: Create a personal access token
The pipeline needs permission to push commits back to the repository. GitLab's built-in CI_JOB_TOKEN is read-only, so you need a separate token with write access.
Go to GitLab โ your avatar (top right) โ Edit profile โ Access Tokens โ Add new token.
Give it a name (e.g. rss-feed-ci), set an expiry date, and select the write_repository scope. That is the only scope it needs.
Copy the token value immediately, GitLab only shows it once.
Step 2: Add the token as a CI/CD variable
Go to your GitLab project โ Settings โ CI/CD โ Variables โ Add variable.
Set the following:
Field | Value |
|---|---|
Key | GITLAB_TOKEN |
Value | the token you just copied |
Type | Variable |
Protect variable | checked (runs on protected branches only) |
Mask variable | checked (hides the value in pipeline logs) |
Save. The pipeline can now reference ${GITLAB_TOKEN} without the value ever appearing in your code or logs.
Step 3: Set up the scheduled pipeline
Go to your GitLab project โ Build โ Pipeline schedules โ Create a new pipeline schedule.
Fill in:
Field | Value |
|---|---|
Description | Daily RSS feed regeneration |
Interval Pattern | 30 1 * * * |
Timezone | Asia/Kolkata |
Target branch | main |
The cron expression 30 1 * * * means 1:30 AM UTC, which is 6:30 AM IST. Save the schedule.
Step 4: Trigger a manual run to verify
Before waiting for the scheduled run, test it manually. Go to Build โ Pipeline schedules, find the schedule you just created, and click the play button on the right. This triggers an immediate run.
Go to Build โ Pipelines to watch it execute. A successful run looks like this:
$ node scripts/generate.mjs ๐ก Fetching sitemap... Found 166 blog URLs in sitemap โจ RSS feed written to: public/rss.xml $ git add public/rss.xml $ git diff --cached --quiet && echo 'No changes' || git commit -m 'chore: regenerate RSS feed [skip ci]' [main a1b2c3d] chore: regenerate RSS feed [skip ci] $ git push https://oauth2:${GITLAB_TOKEN}@${CI_PROJECT_PATH} HEAD:main
If the run passes, the pipeline is configured correctly and will run automatically every morning from here.
Surfacing the feed in Framer
Once the feed is live, there are two things worth doing in Framer to make it discoverable.
1. Add an RSS autodiscovery tag to the head
This is a hidden <link> tag in your site's HTML that RSS readers and browsers use to auto-detect the feed. When someone visits your blog, their RSS reader sees this tag and knows a feed exists without the user having to find the URL manually.
In Framer, go to Settings(gear icon, top right) then General, scroll down to Custom Code, and paste this into the Start of head tag field:
<link rel="alternate" type="application/rss+xml" title="Zeliot Blog" href="https://feed.zeliot.in/rss.xml"
Save and publish. The tag is invisible on the page but browsers, RSS readers, and feed aggregators will find it when they visit any page on your site.
Verifying it works
After publishing, open your blog in Chrome and right-click โ View Page Source. Search for application/rss+xml. You should see the tag in the <head> section. If it is there, autodiscovery is working.
You can also paste your blog URL into the W3C Feed Validation Service it follows autodiscovery links and validates the feed it finds. A clean result means your feed is well-formed and will work with any standard RSS reader.
2. Add a visible RSS button on the blog page
For readers who know what RSS is and want to subscribe manually, add a visible button on the blog page. In Framer, open the blog page in the editor. Add a Button or Link element wherever makes sense in your layout, typically near the blog title or in a subscribe section. Set the link to:
https://feed.zeliot.in/rss.xml
Set it to open in a new tab. Label it "RSS" or "Subscribe via RSS" and optionally add an RSS icon next to the text. The classic RSS icon is a small orange square with the wifi-style signal arcs, recognisable to anyone who uses a feed reader. That is all that is needed. The autodiscovery tag handles machine detection, and the button handles human subscribers.
Summary
Action | Description |
|---|---|
Feed URL | |
Posts | 166 currently, more blogs are uploaded every week |
Generator | Node.js, no external dependencies |
Hosting | Netlify free tier |
Automation | GitLab CI, 6:30 AM IST daily |
DNS | GoDaddy CNAME pointing to Netlify |
Build time | ~2 minutes for 166 posts |
The whole thing lives in one GitLab repo: the generator script, the CI config, and the generated feed file. No external services, no recurring subscriptions, no plugins. If Framer ever ships native RSS support, we can retire this in five minutes. Until then, it works exactly as it should.
Disclaimer: Claude helped me in the entire process of writing the code, guiding on how to implement this, deploying the links, etc.
Frequently Asked Questions
Yes, as long as your Framer site has a sitemap and your blog posts output Open Graph meta tags, which Framer does by default. The only adjustment you need to make is changing the SITE_URL and BLOG_PREFIX values in the script to match your domain and blog path.
We tried. The Framer Server API uses a stateful WebSocket connection that requires an active Framer browser or app session. Running it from a terminal or CI environment results in a 90-second timeout. It is designed for plugins inside Framer, not for standalone scripts. If Framer fixes this in a future release, the Server API would be a cleaner data source since it reads directly from the CMS.
Framer hosts zeliot.in and does not allow custom static file paths. Getting /blog/feed.xml on the same domain would require moving our DNS to Cloudflare and using a Cloudflare Worker to proxy the request. We chose the simpler path of a subdomain via a GoDaddy CNAME record pointing to Netlify.
The GitLab CI pipeline runs every morning at 6:30 AM IST. It fetches the sitemap, which Framer updates automatically on every publish, and regenerates the feed. If new posts are found, it commits the updated rss.xml and pushes it. Netlify detects the push and redeploys within a couple of minutes.
Vercel requires a Pro plan to import repos from private GitLab groups. Netlify supports private GitLab groups on its free tier. That was the deciding factor.
The sitemap scraping approach works for any website with a sitemap and Open Graph tags. You would just change the URLs and filter logic in generate.mjs. The GitLab CI and Netlify setup is framework-agnostic.
The script uses Promise.allSettled instead of Promise.all, so a failed fetch on one page does not stop the entire run. That page gets skipped and a warning is logged. The rest of the feed generates normally.
Dive Deeper with AI
On this page
Get exclusive blogs, articles and videos on data streaming, use cases and more delivered right in your inbox!
Ready to Switch to Condense and Simplify Real-Time Data Streaming? Get Started Now!
Switch to Condense for a fully managed, Kafka-native platform with built-in connectors, observability, and BYOC support. Simplify real-time streaming, cut costs, and deploy applications faster.


