Skip to content

Feeds

cms.feed.rss() and cms.feed.atom() build a syndication feed from two arguments: the feed’s own details, and a list of items. Everything about which objects go in and how each one looks stays in the template, where |filter, |sortBy and |map already live.

Create a builder page with a route ending in /rss (or .rss), and a template like this:

{{ cms.feed.rss(
{
title: 'Total CMS Releases',
link: cms.builder.canonicalUrl('changelog'),
self: cms.builder.canonicalUrl('changelog-rss'),
description: 'New capabilities, refinements, and fixes.',
},
cms.collection.objects('changelog')|sortBy('date')|reverse|map(e => {
title: e.version ~ ' — ' ~ e.title,
link: cms.collection.canonicalObjectUrl('changelog', e),
id: e.id,
date: e.date,
content: e.changelog|markdown,
})
) }}

That is the whole template — no layout, no XML boilerplate. Escaping, CDATA, RFC-2822 dates and the atom:link self reference are handled for you.

The route’s extension sets the Content-Type, so /changelog/rss and /changelog/feed.rss both serve application/rss+xml. See Site Builder for how that works.

KeyRequiredNotes
titleyes
linkyesThe page a reader should visit — not the feed’s own URL
descriptionyes
selfAtom onlyThe feed’s own URL. Readers use it to re-fetch
languagenoe.g. en-us
updatednoDefaults to the newest item’s date
imagenoThe channel image: a URL, or {url, title, link}. Title and link default to the feed’s own
generatorno
copyrightno

self is required for Atom because readers use it to re-subscribe, and there is no safe value to guess — pointing it at link would hand every subscriber a URL for the HTML page instead of the feed.

KeyNotes
title
linkMade absolute against your domain if relative
idThe item’s identity. Defaults to link
dateISO string, timestamp, or DateTime
contentHTML. Run Markdown fields through |markdown first
summaryShort form. RSS uses it in place of content if given
authorA name, or {name, email, uri}
mediaAn enclosure — see below

Items are emitted in the order you supply them, so sort before you map.

A feed reader treats the id as the item’s identity and re-announces anything whose id changes. Prefer something stable and independent of the URL — an object id is ideal:

id: e.id,

Atom is stricter: an entry id must be an IRI, so a short key like v3-5-0 is not legal there. In an Atom feed the item link is used instead, which is what feed generators generally do.

media attaches an enclosure — a podcast episode, a video, an image. The short form is a URL:

media: e.audio.link,

The type is guessed from the extension and the length reported as zero. For a podcast, give real values instead — image and file fields already carry mime and size:

media: {
url: e.audio.link,
type: e.audio.mime,
length: e.audio.size,
},

Podcast clients use length for progress and buffering, so it is worth supplying. One enclosure per item — RSS 2.0 permits no more.

Add a podcast block to the feed details and to each item and the feed gains the iTunes and Podcast Index tags Apple Podcasts, Spotify and the open directories read. Leave it out and the feed is exactly what it was.

If your show uses the bundled Podcast extension’s podcast and podcast-episode schemas, you do not need any of this: the extension serves the feed at its own address, and podcast_feed() fills the block in from those collections, serves uploaded audio through the counted streaming route or links to audio hosted elsewhere, and takes the feed URL from the show record. See Podcasts. What follows is for feeds built from your own schema.

{{ cms.feed.rss({
title: show.title, link: '/', self: '/podcast.xml', description: show.description,
podcast: {
author: show.author,
owner: {name: show.author, email: show.email},
image: 'https://example.com/media/cover.jpg',
category: ['Technology', 'Business > Entrepreneurship'],
explicit: false,
}
}, episodes|sortBy('-date')|map(e => {
id: e.id, title: e.title, link: cms.builder.url('episode', {id: e.id}),
date: e.date, content: e.notes|markdown,
media: {url: cms.media.stream(e, {property: 'audio'}), type: e.audio.mime, length: e.audio.size},
podcast: {duration: e.duration, episode: e.number, season: e.season},
})) }}

When podcast is present these are required, and the feed refuses to render without them: author, owner ({name, email}), image, category, explicit, and the feed’s self URL.

  • image must be an absolute URL ending in .jpg or .png, with no query string, 1400–3000 px square. An ImageWorks URL with parameters is rejected, and so is .jpeg; point at the stored file, or serve a resized copy at a plain path.
  • category is a string or a list, from Apple’s category list. Sub-categories are written Parent > Child. A category that is not on the list fails with the closest matches named.
  • explicit is true or false.
  • self is what apps re-fetch with, and the Podcast Index guid is derived from it. Keep it stable for the life of the show.
KeyTagNotes
author, owner, image, category, explicitrequiredsee above
typeitunes:typeepisodic (default) or serial
subtitle, summarysummary defaults to description
newFeedUrlitunes:new-feed-urlwhen the feed moves
complete, blockbooleans
guidpodcast:guidderived from self when omitted
lockedpodcast:locked{owner: email, value: true}
fundingpodcast:funding{url, title} or a list of them
KeyTagNotes
durationitunes:durationseconds, or HH:MM:SS (written as seconds)
episode, seasonwhole numbers
episodeTypefull (default), trailer, bonus
imageper-episode art, same rules as the feed image
explicitoverrides the feed value
title, subtitle, summarysummary defaults to the item summary, then the content with tags stripped
blockboolean
transcriptpodcast:transcript{url, type} — e.g. application/srt
chapterspodcast:chapters{url, type: 'application/json+chapters'}
peoplepodcast:personlist of {name, role?, group?, img?, href?}
soundbitespodcast:soundbitelist of {startTime, duration, title?}

The enclosure is media, exactly as above. Podcast apps cache the enclosure URL for a long time, so it must be a public, stable URL; a signed or expiring link is a broken episode later.

Total CMS does not count downloads. Podcasters use a prefix analytics service instead, which is one change to the enclosure URL:

media: {url: 'https://op3.dev/e/' ~ (audioUrl|replace({'https://': ''})), type: e.audio.mime, length: e.audio.size},

Atom feeds ignore the podcast block: podcast apps read RSS.

Same arguments, different renderer:

{{ cms.feed.atom(meta, items) }}

Serve it from a route ending in .xml, and remember meta.self.

A collection can also publish a feed at /feed/rss/{collection} without any template: turn on Publish RSS Feed in the collection’s settings. See RSS Feeds.

That endpoint maps fields by name — which property is the title, which is the content — and that is its limit: it cannot build a title out of two fields or shape the content beyond rendering a Markdown field to HTML. It also reads the collection index, so every property it uses has to be indexed.

Use the endpoint when your collection already has a plain-text summary field and a usable title. Build the feed in Twig when you need control over either, or when you want a feed no URL parameter can filter.