RSS and Atom Feeds for Static Blogs
Feeds are unfashionable and quietly effective. For a technical blog, a meaningful share of returning readers still arrive through feed readers — Feedly, NetNewsWire, Inoreader, Miniflux — and through services that consume feeds: newsletter tools, aggregators, Slack and Discord integrations, and Mastodon bots. On the Eleventy blog measured here, feed-referred sessions were 14% of all returning-visitor sessions. A broken feed does not show up in a browser, so it tends to stay broken: images that do not load in readers, links that resolve to the reader's own domain, duplicate items after every redesign because IDs changed.
This guide publishes feeds that validate and behave well in readers from Eleventy, Astro and Hugo, and adds checks that catch the common breakages in CI. It is part of Technical SEO for Static Sites.
Prerequisites
- A blog or changelog section with dated posts and a configured production origin.
- Decisions on scope (one site-wide feed, or one per section or tag) and on content (full text or summary).
- The W3C Feed Validation Service or
feedvalidatorfor manual checks, and Node in CI.
What a Good Feed Has
- Stable IDs. Each item's
<guid>(RSS) or<id>(Atom) must never change. Readers use it to decide whether an item is new. Using the post URL works until URLs change; a tag URI such astag:example.com,2024-03-18:/posts/css-gridsurvives redesigns. - Absolute URLs everywhere, including inside the item content.
- Correct dates in the format each spec requires: RFC 822 for RSS (
Wed, 25 Sep 2026 09:00:00 +0000), RFC 3339 for Atom (2026-09-25T09:00:00Z). - A sensible length. The latest 20–50 items; feeds with every post ever written grow to megabytes and are re-downloaded on every poll.
- Discovery. A
<link rel="alternate" type="application/atom+xml">in every page head so browsers and readers find it from any URL.
Eleventy: the Official RSS Plugin
@11ty/eleventy-plugin-rss v2 provides a virtual template that produces a complete feed with one config call, plus filters for absolute URLs if you prefer to write the template yourself:
// eleventy.config.js
import { feedPlugin } from '@11ty/eleventy-plugin-rss';
export default function (eleventyConfig) {
eleventyConfig.addPlugin(feedPlugin, {
type: 'atom',
outputPath: '/feed.xml',
collection: { name: 'posts', limit: 30 },
metadata: {
language: 'en',
title: 'Example Engineering Blog',
subtitle: 'Notes on building for the web',
base: 'https://blog.example.com/',
author: { name: 'Example Engineering' }
}
});
}
The plugin runs every item's content through its htmlBaseUrl transform, which rewrites relative href and src attributes to absolute URLs — the most commonly missed step when hand-writing a feed template.
Astro: @astrojs/rss
// src/pages/rss.xml.ts
import rss from '@astrojs/rss';
import { getCollection } from 'astro:content';
import { experimental_AstroContainer as AstroContainer } from 'astro/container';
export async function GET(context) {
const posts = (await getCollection('blog', (p) => !p.data.draft))
.sort((a, b) => b.data.published.valueOf() - a.data.published.valueOf())
.slice(0, 30);
const container = await AstroContainer.create();
return rss({
title: 'Example Engineering Blog',
description: 'Notes on building for the web',
site: context.site,
items: await Promise.all(posts.map(async (post) => {
const { Content } = await post.render();
return {
title: post.data.title,
link: `/posts/${post.slug}/`,
pubDate: post.data.published,
description: post.data.description,
content: absolutize(await container.renderToString(Content), context.site)
};
})),
customData: '<language>en</language>'
});
}
@astrojs/rss produces RSS 2.0 with content:encoded for full content. The absolutize helper rewrites href="/ and src="/ to the site origin; without it, every image in the feed was broken in NetNewsWire during testing.
Hugo: Override the Built-In Template
Hugo generates RSS for the home page, every section and every taxonomy term by default. Most sites want far fewer. Restrict outputs and override the template to include full content:
[outputs]
home = ["HTML", "RSS"]
section = ["HTML"]
taxonomy = ["HTML"]
term = ["HTML"]
[services.rss]
limit = 30
In layouts/_default/rss.xml, replace .Summary with .Content | transform.XMLEscape inside <description>, or add a content:encoded element. Hugo's .Permalink is absolute, but links inside .Content are whatever the Markdown contained; enable canonifyURLs only for the feed output, or use a render hook that emits absolute URLs when .Page.OutputFormats is RSS.
Serving and Discovering the Feed
Set the content type explicitly if the host does not infer it from the extension:
# _headers (Cloudflare or Netlify)
/feed.xml
Content-Type: application/atom+xml; charset=utf-8
Cache-Control: public, max-age=900
A 15-minute cache keeps the constant polling of feed readers off the origin; on this blog, feed requests were 31% of all requests by count before caching, almost all conditional revalidations. Add discovery links to the shared head layout:
<link rel="alternate" type="application/atom+xml" title="Example Engineering Blog" href="/feed.xml">
Checking Feeds in CI
A short script validates the built feed on every pull request: it parses as XML; it has between 1 and 50 items; every item has an ID, a date that parses, and an absolute link; no href="/ or src="/ survives in item content; and every item ID existed in the previous production feed or belongs to a new post. That last check catches redesigns that silently change IDs, which in readers shows as every old post reappearing as unread.
node scripts/check-feed.mjs dist/feed.xml --previous https://blog.example.com/feed.xml
Feeds Beyond the Blog
Feeds are useful anywhere readers want to follow changes. A changelog feed lets customers subscribe to releases without an email list; a documentation "what's new" feed built from pages whose dateModified changed materially keeps power users informed; a per-tag feed serves readers who only care about one topic. Each should be generated from the same collection logic as its HTML page, so the feed and the page never disagree about what is new. Keep the number of feeds small and deliberate — every one is a URL you commit to keeping stable, since subscribers cannot be redirected as easily as browsers can.
For documentation teams, the changelog feed has an unexpected second audience: support and sales colleagues who subscribe in Slack via the RSS app, and so learn about product changes the same day they ship.
Making Feeds Look Good in Readers
Feed readers render item HTML with their own stylesheets, so the content should be clean, semantic HTML that needs no CSS to make sense. A few habits make a large difference to how posts look in NetNewsWire, Feedly and friends.
Give every image explicit width and height attributes and a meaningful alt; readers use the dimensions to lay out the article before the image loads. Replace <picture> elements with a plain <img> pointing at a widely supported format — some readers ignore <source> elements and show nothing if the fallback src is missing. Convert syntax-highlighted code blocks to plain <pre><code>; the highlighting classes mean nothing without the site's CSS, and some readers display inline style attributes badly. Replace custom elements, tabs and callout components with their plain-HTML equivalents: a callout becomes a <blockquote> with a bold label, tabs become consecutive sections with headings.
Most generators let you render content differently for the feed. In Eleventy, a transform can run only on the feed's output path; in Astro, the container API renders components with a feed-specific prop; in Hugo, render hooks can check the output format. Test with at least two readers before trusting the result — NetNewsWire on macOS and a web reader such as Feedly or Inoreader cover the common rendering engines.
Measured Impact
| Measure | Hand-written template | Official plugin + CI check |
|---|---|---|
| Validator errors (W3C Feed Validation Service) | 61 | 0 |
| Feed size | 3.4 MB (all posts) | 290 KB (30 full posts) |
| Broken images in NetNewsWire, latest 10 posts | 23 | 0 |
| Feed requests reaching origin per day | about 9,800 | about 410 |
| Feed-referred share of returning sessions | 11% | 14% |
Pitfalls & Rollback
- Changing the feed URL. Many readers do not follow redirects reliably; keep the old URL serving the feed forever, or at least redirect with a 301.
- Changing IDs in a redesign. Every item reappears as unread for every subscriber.
- Drafts in the feed. Filter by the same draft flag the HTML uses.
- Scripts and interactive embeds in content. Readers strip them; replace with a link.
- Rollback: feeds are static files; reverting the template restores the old output. If IDs changed, reverting quickly limits the duplicate-item damage to readers that polled in between.
Conclusion
A good feed is a small, stable contract: absolute URLs, permanent IDs, correct dates and a bounded length, discoverable from every page and cached at the edge. Generate it with the generator's official tooling, check it in CI against the previous production feed, and it will keep serving the readers who follow the site most closely.
FAQ
Should I publish RSS, Atom or both?
One is enough, and every modern feed reader supports both. Atom has a stricter, better-specified format with required IDs and update times, which makes it slightly easier to get right. RSS 2.0 is more widely recognised by name. If you already publish one, keep its URL stable rather than switching.
Should the feed contain full posts or summaries?
Full content serves readers best and is what most developer audiences expect. Summaries are reasonable when posts are very long or rely on interactive elements that do not survive in feed readers, but include enough text for the reader to decide whether to click through.
Why do relative links break in feed readers?
Feed readers display content outside your site, so a link to /posts/grid/ or an image at /img/a.png has no base URL to resolve against. Convert every link and image source in feed content to an absolute URL at build time.
What content type should a feed be served with?
application/rss+xml for RSS and application/atom+xml for Atom, or application/xml as a safe fallback. Serving it as text/html or octet-stream makes some readers and browsers mishandle it.
Related
- Parent: Technical SEO for Static Sites — feeds alongside sitemaps and structured data.
- Generating Sitemaps for Large Static Sites — the other machine-readable index of the site.
- Generating Sitemaps and RSS in Next.js Static Export — feeds in Next.js.
- Eleventy vs Jekyll for Markdown-Heavy Blogs — blog generators compared.
- Release Notes and Changelogs from Git — the changelog a feed can publish.