A custom RSS feed on Webflow Cloud lets your newsletter send only the blog posts you choose, with a summary written for email.
Webflow can already publish an RSS feed for any Collection, and newsletter tools like Mailchimp or Kit can turn that feed into emails. The native feed includes every published post, though, and an edited post that gets republished can come back as new.
When editors should choose which posts reach subscribers, or the email needs its own summary, a custom feed gives you that control.
In this tutorial, we’ll build a Next.js Route Handler on Webflow Cloud. It reads your blog Collection through the CMS API and returns valid RSS 2.0, which your newsletter tool polls on its own schedule.
What do you need to send blog posts to a newsletter via RSS in Webflow?
Start by checking whether the native feed is enough. You turn it on in the Collection page's settings under RSS Feed Settings, where you choose the item title, description, image and publication date.
For the custom feed, you need:
- A site with the CMS: Premium or higher, the first tier with the CMS, which also covers mounting the app on a custom domain.
- A blog Collection: Published posts with a title, a slug, a summary and a Date field you set once, such as "Newsletter date", plus an optional switch field for posts that should go to subscribers.
- Next.js 15 with npm: Webflow's bring-your-own-app requirements state that it "supports only the npm package manager," so skip
pnpmandyarn. - A site token: With the
cms:readscope, held by the site owner, plus the blog Collection's ID. - An RSS-capable newsletter tool: An RSS-to-email or RSS campaign feature that accepts a public feed URL and lets you set a send schedule.
Pick the mount path, such as /feeds, before you start, because the newsletter tool needs the full feed URL under it.
5 steps to send blog posts to a newsletter via RSS in Webflow Cloud
Setting up a custom RSS feed for your Webflow site involves five main steps: scaffolding the Next.js application, configuring your CMS credentials, building the feed route, deploying to Webflow Cloud, and linking your RSS-to-email tool.
1. Scaffold a Next.js app for Webflow Cloud
Create a new TypeScript Next.js project with the App Router and npm. The feed lives in a Route Handler so that you won't need client components or extra libraries.
Plain string building is enough to produce valid RSS, and I prefer it to a feed library here because every dependency has to run on the Cloudflare Workers runtime. Pin the scaffold to Next.js 15, because @latest now installs Next.js 16.
Run this from the folder where you keep your projects:
npx create-next-app@15 blog-feed --ts --app --use-npm
cd blog-feed
The --use-npm flag writes a package-lock.json, the lockfile Webflow Cloud builds from. When the command finishes, run npm run dev and open http://localhost:3000. You should see the default Next.js starter page, and the project folder should contain an app directory for the feed route.
2. Store the CMS credentials as environment variables
Put the token and the feed settings in environment variables so the route holds no secrets and the same code works in every environment.
Webflow's environment variables page says both secret and plain variables are available to the build and to the deployed app at runtime, and secret values are redacted from build logs. Mark the token as secret.
Create these variables in a local .env.local file, then in the environment variables settings of your Webflow Cloud environment:
| Variable | What it holds | Secret? |
|---|---|---|
WEBFLOW_API_TOKEN |
The site token with cms:read |
Yes |
WEBFLOW_COLLECTION_ID |
The blog Collection's ID | No |
BLOG_BASE_URL |
The public URL of your blog listing with no trailing slash, for example https://www.example.com/blog |
No |
FEED_URL |
The feed's own public URL, for example https://www.example.com/feeds/feed.xml |
No |
FEED_TITLE |
The channel name subscribers see in some newsletter tools, for example, Example Co. blog |
No |
| Variable → What it holds → Secret? |
|---|
WEBFLOW_API_TOKEN |
The site token with cms:read |
| Yes |
WEBFLOW_COLLECTION_ID |
| The blog Collection's ID |
| No |
BLOG_BASE_URL |
The public URL of your blog listing with no trailing slash, for example https://www.example.com/blog |
| No |
FEED_URL |
The feed's own public URL, for example https://www.example.com/feeds/feed.xml |
| No |
FEED_TITLE |
The channel name subscribers see in some newsletter tools, for example, Example Co. blog |
| No |
Restart npm run dev after editing .env.local so Next.js picks up the new values. The route builds absolute post links from BLOG_BASE_URL and each slug, because an email client has no page to resolve a relative link against.
3. Build the RSS Route Handler
Add a Route Handler in a folder named feed.xml so the feed answers at a URL ending in /feed.xml. The handler reads published items from Webflow's Content Delivery API, keeps the posts marked for the newsletter, and returns XML with an RSS content type.
The delivery host serves cached responses that don't count against your Data API rate limit, which matters because every newsletter poll runs this route. Change the field slugs in FIELDS to match your Collection; you can read them in each field's settings in the CMS.
Route Handler Implementation
Save the following as app/feed.xml/route.ts:
// app/feed.xml/route.ts
// Do not add an edge runtime export: Webflow Cloud already runs on
// Cloudflare Workers, and the OpenNext adapter does not support that directive.
export const dynamic = 'force-dynamic';
type Post = { title: string; slug: string; summary: string; published: string };
type Item = { fieldData?: Record<string, unknown> };
// Replace these with your blog Collection's field slugs.
const FIELDS = {
title: 'name',
slug: 'slug',
summary: 'newsletter-summary',
published: 'newsletter-date',
include: 'send-to-newsletter', // set to '' to include every post
};
function escapeXml(value: string): string {
return value
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}
function toPost(item: Item): Post | null {
const fields = item.fieldData ?? {};
if (FIELDS.include && fields[FIELDS.include] !== true) return null;
const title = fields[FIELDS.title];
const slug = fields[FIELDS.slug];
const summary = fields[FIELDS.summary];
const published = fields[FIELDS.published];
if (typeof title !== 'string' || typeof slug !== 'string' || typeof published !== 'string') {
return null;
}
if (Number.isNaN(Date.parse(published))) return null;
return { title, slug, summary: typeof summary === 'string' ? summary : '', published };
}
export async function GET(): Promise<Response> {
const collectionId = process.env.WEBFLOW_COLLECTION_ID;
const token = process.env.WEBFLOW_API_TOKEN;
const blogBaseUrl = process.env.BLOG_BASE_URL;
const feedUrl = process.env.FEED_URL;
const feedTitle = process.env.FEED_TITLE ?? 'Blog';
if (!collectionId || !token || !blogBaseUrl || !feedUrl) {
return new Response('Feed is not configured', { status: 500 });
}
const query = new URLSearchParams({ limit: '20' });
query.set(`sort[${FIELDS.published}]`, 'desc');
if (FIELDS.include) query.set(`filter[${FIELDS.include}][eq]`, 'true');
const res = await fetch(
`https://api-cdn.webflow.com/v2/collections/${collectionId}/items/live?${query}`,
{ headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' } },
);
if (!res.ok) {
return new Response(`CMS request failed with status ${res.status}`, { status: 502 });
}
const data = (await res.json()) as { items?: Item[] };
const posts = (data.items ?? [])
.map(toPost)
.filter((post): post is Post => post !== null);
const itemsXml = posts
.map((post) => {
const link = `${blogBaseUrl}/${post.slug}`;
return `
<item>
<title>${escapeXml(post.title)}</title>
<link>${escapeXml(link)}</link>
<guid isPermaLink="true">${escapeXml(link)}</guid>
<pubDate>${new Date(post.published).toUTCString()}</pubDate>
<description>${escapeXml(post.summary)}</description>
</item>`;
})
.join('');
const xml = `<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
<channel>
<title>${escapeXml(feedTitle)}</title>
<link>${escapeXml(blogBaseUrl)}</link>
<description>${escapeXml(`Latest posts from ${feedTitle}`)}</description>
<atom:link href="${escapeXml(feedUrl)}" rel="self" type="application/rss+xml"/>
<lastBuildDate>${new Date().toUTCString()}</lastBuildDate>${itemsXml}
</channel>
</rss>`;
return new Response(xml, {
headers: { 'Content-Type': 'application/rss+xml; charset=utf-8' },
});
}
The feed is public, and the token stays on the server because it has no NEXT_PUBLIC_ prefix. Escaping every value keeps titles with an ampersand or a quote from breaking the XML, and toUTCString() produces the RFC 822 date format that the RSS 2.0 specification requires.
Managing Item GUIDs and Filtering
Using the post URL as the guid and a date editors set once keeps each item's identity stable, so republishing a post never makes it look new. The switch check reads true explicitly, because the API leaves a field out of fieldData when it has no value.
The request lets the API do the selecting: filter[...] keeps only posts with the switch on, and sort[...] orders them by the newsletter date, both documented on the List Live Items reference, so one call returns the 20 newest posts for the feed. The route still checks each item, so a post with no date never produces an invalid entry.
Open http://localhost:3000/feed.xml, and you should see an XML document listing those posts. If it comes back empty, check that the slugs in FIELDS match your Collection and that at least one published post has the switch on.
4. Deploy and confirm the feed URL includes the mount path
Connect your repository to Webflow Cloud, set the environment's mount path to /feeds, and run a deployment.
When the deployment finishes, request the feed at its production URL, which is your domain plus the mount path plus /feed.xml. A route that answers at /feed.xml locally answers at /feeds/feed.xml once the app is mounted at /feeds.
Webflow's configuration docs describe the mount path as applied when the app builds, so server routes need no change.
Check the deployed route and its headers from a terminal:
curl -i https://www.example.com/feeds/feed.xml
A healthy response returns a 200 status with content-type: application/rss+xml; charset=utf-8, followed by the XML body. Paste the URL into an RSS validator as a second check to catch malformed dates or unescaped characters before a subscriber sees them.
When both pass, you have a public feed URL ready for the newsletter tool, served from your own domain.
5. Point your newsletter's RSS campaign at the feed
Create an RSS-driven campaign in your newsletter platform and give it the /feeds/feed.xml URL you confirmed with curl. Menu names differ by provider, so follow your platform's RSS campaign documentation for the exact controls.
Mailchimp's RSS campaigns send on a recurring schedule you pick, from daily to monthly, and Kit can send a broadcast per new post or a weekly digest. In each case, you choose the audience and a template that pulls each item's title and summary along with its link.
I'd pick a weekly digest over send-per-post for most blogs, because a burst of publishing then lands as one email instead of three. Turn on manual approval for the first send if your platform offers it.
A preview, where the platform offers one, should show your recent post titles and summaries in the template, and each item should link to the live post on your Webflow site.
What causes a Webflow RSS newsletter feed to fail?
Test the feed in production and locally, because the mount path and deployed environment can hide failures that don't show up on your machine.
The deploy fails with an edge runtime error from OpenNext
Webflow's bring-your-own-app page still says to add export const runtime = 'edge' to API routes, so following the docs faithfully produces this failure. The confusion comes from "edge" meaning two things.
Webflow Cloud runs on Cloudflare Workers, which is an edge platform. The Next.js edge runtime target is a separate setting, and the OpenNext Cloudflare adapter that Webflow Cloud uses to deploy Next.js does not support it.
Delete the runtime export from app/feed.xml/route.ts and redeploy. If a teammate or an AI assistant keeps adding the line back, leave a comment at the top of the file explaining why the directive is absent.
The feed returns 404 in production but loads at /feed.xml locally
A newsletter tool pointed at https://www.example.com/feed.xml is requesting a path your Webflow site doesn't serve, because the app lives under its mount path.
Use the mounted production URL from step 4 in the newsletter campaign and the validator. If you later add a client-side page that fetches the feed, prefix the request with a NEXT_PUBLIC_BASE_PATH variable you add yourself, because Webflow Cloud does not rewrite client-side fetch calls to include the mount path.
The deployed route throws an error about node:fs
The route, or a helper it imports, uses node:fs at request time, for example, to cache feed.xml on disk. Workers only provide node:fs by default from compatibility date 2025-09-01, which is later than the date in Webflow Cloud's example configuration, and the same code runs under local Node.js, so the error appears only after deployment.
A file written by a postbuild script wouldn't help either, because it would stay stale until the next deploy.
Build the XML as a string on each request, exactly as the Route Handler does, and remove any fs or writeFile imports and any postbuild script that generates the file.
The deployed feed returns "Feed is not configured"
The route returns that message when a required environment variable from step 2 is missing. .env.local never leaves your machine, so values that work under npm run dev don't exist in production until you add them to the Webflow Cloud environment.
Add any missing variables to the environment the app is deployed from, with the token marked secret, then trigger a fresh deployment. If the response changes to "CMS request failed with status 401," check that the token has the cms:read scope.
What you can build next with RSS and Webflow
Once the newsletter runs off your blog automatically, the same route can feed other channels with no extra publishing step. I'd add a second Route Handler that filters by category for topic-specific digests, or a full-text feed for readers who prefer one.
For the marketing side of feeds, Webflow's guide to RSS feeds covers how teams use them beyond email.
For the platform this route runs on, Webflow's developer docs cover Webflow Cloud apps and how they are deployed.
Frequently asked questions
Can the feed include the full post instead of a summary?
Yes. Add your rich text field to FIELDS and output it inside a content:encoded element, with xmlns:content="http://purl.org/rss/1.0/modules/content/" declared on the rss tag and the HTML wrapped in CDATA. A literal ]]> inside the post would end the CDATA early, so replace it before wrapping. Expect the email to render less predictably than your site, since email clients often strip styles.
How do I send only some posts to the newsletter?
That is what the include slug in FIELDS does. Add a switch field to your blog Collection, something like "Send to newsletter," and editors toggle it per post. Posts with the switch off never reach the feed so that you can keep the campaign on automatic sending without extra review.
Do newsletter polls count against my Webflow Cloud limits?
Yes. Webflow Cloud's usage page says "Only requests that execute your app's code are counted," and each poll runs the route. The CMS call the route makes is a subrequest, which doesn't count toward that total. Webflow Cloud also sets private, no-cache on every response, so a poller can't reuse an earlier copy, and for heavy traffic the 429 rate limit guide covers caching on the server.
Why did my newsletter resend posts it already sent?
Newsletter tools treat an item as new when its GUID or its pubDate changes. Changing a post's slug changes its link, and so its GUID, and moving the newsletter date changes the pubDate. Avoid renaming slugs on published posts, and keep the newsletter date fixed once a post has gone out.





