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 feedvalidator for 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 as tag:example.com,2024-03-18:/posts/css-grid survives 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.
Comparison of RSS 2.0 and Atom fields A matrix comparing RSS 2.0 and Atom 1.0. Item identifier is guid in RSS, optional, and id in Atom, required. Dates are RFC 822 pubDate in RSS and RFC 3339 updated, required, in Atom. Full content uses content:encoded via an extension in RSS and content type html natively in Atom. Content type is application/rss+xml versus application/atom+xml. RSS 2.0 and Atom side by side Field RSS 2.0 Atom 1.0 Item identifier guid (optional) id (required) Date format RFC 822 pubDate RFC 3339 updated (required) Full content content:encoded extension content type html Media type application/rss+xml application/atom+xml
Atom's required fields are the ones people forget in RSS, which is why Atom feeds tend to validate more often.

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.

Transformations applied to post content before it goes into a feed Rendered post HTML passes through four transformations. Relative links and image sources are made absolute. Interactive embeds and scripts are replaced with a link to the post. Lazy-loading attributes are removed so images display. The result is escaped or wrapped for XML and written into the feed item. Turning page HTML into feed-safe HTML Rendered HTML post body Absolute URLs href and src resolved Strip interactivity embeds become links Plain img src drop lazy loaders XML-safe item escaped content
Feed readers display content out of context, so anything that depends on the site's scripts, styles or base URL must be resolved at build time.

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
Counts of feed problems found by the CI check Horizontal bars. Relative image sources in item content 212. Relative links in item content 388. Items without a stable id 30. Dates in the wrong format 30. Feed larger than 1 MB, one instance, 3.4 MB with every post included. Feed problems found on the first CI run Relative links in content 388 Relative image sources 212 Items without stable id 30 Dates in wrong format 30
The first run of the check against the old hand-written template; all were fixed by switching to the official plugin with a 30-item limit.

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

MeasureHand-written templateOfficial plugin + CI check
Validator errors (W3C Feed Validation Service)610
Feed size3.4 MB (all posts)290 KB (30 full posts)
Broken images in NetNewsWire, latest 10 posts230
Feed requests reaching origin per dayabout 9,800about 410
Feed-referred share of returning sessions11%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.

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.

Filed underHow-tosAstroEleventyHugo