Adding an Atom Feed to Ghost (Without Touching a Server)

Migrating from Jekyll to Ghost broke my social syndication, and neither project was going to fix it. Twenty minutes in the Ghost admin, no server access required.

Share
Adding an Atom Feed to Ghost (Without Touching a Server)
Photo by Jackson Sophat / Unsplash
💡
Came here from a search engine?
The short version: Ghost only publishes RSS, POSSE Party only reads Atom, and the fix is a custom /atom/ route plus an atom.hbs template, both editable from the Ghost admin in about twenty minutes. Skip straight to the fix ↓

The rest of this is why it happens, which matters if you want to avoid the next version of the same problem.

I was working on the migration of this site from Jekyll to Ghost for a while. The checklist was the usual one: content, URL structure, redirects, images, theme. Everything on it went through fine (well, almost, I still have some broken images, that I'll fix). But something else broke.

A few hours in, I started receiving emails error from my syndication tool. Like, every, f*cking, minute. So I had to take a look before I ruin my domain reputation and end up on Spamhaus.

What actually broke

I run POSSE Party to push new posts out to social platforms. Publish (on my) Own Site, Syndicate Elsewhere. It's a small, opinionated tool: it watches a feed, and when something new shows up, it fans it out. Self-hosted, no vendor lock-in, exactly my kind of thing.

Under Jekyll I used a syndication plugin that was building RSS + Atom feed, so it emitted Atom without my ever thinking about it. Ghost doesn't work that way. Ghost generates its own feed at /rss/, in RSS, and that's the whole offer.

New feed URL, new feed format, and POSSE Party had opinions about the second one:

checking the feed failed: Feed check failed:
undefined method 'link_rels' for an instance of Feedjira::Parser::RSSEntry

POSSE Party parses feeds with Feedjira and extends it with a link_rels helper. That helper was written for Atom entries and never for RSS ones. Point a POSSE Party instance at an RSS URL and it walks straight into a NoMethodError. Someone else hit the same wall and filed it as issue #7, which is now closed. The code still has no RSS path.

Why nobody upstream was going to fix this

Worth sitting with for a second, because the answer says something about how open ecosystems actually behave.

Ghost won't add Atom. RSS already covers the readers, the podcast apps, the newsletter bridges, the aggregators. A second feed format means a second template to maintain, a second thing to keep spec-compliant, a second source of support tickets, for a slice of users most CMS teams will never hear from. POSSE Party is a niche tool. I'd like Ghost to ship Atom, and I understand why it's nowhere near the top of anyone's backlog.

POSSE Party isn't adding RSS either. The maintainer runs Atom on his own site, so the RSS path never got exercised in the first place. Not a design stance against RSS, just a code path nobody had walked, which describes most bugs in most small tools. The issue got closed, the answer was Atom, and the last commit to the parser extension is still Atom-only.

Neither position is wrong. Neither one gets my posts onto social media.

This is the part of running an independent stack that doesn't make the pitch deck. Every tool is individually well-maintained and individually correct, and the failure lives in the space between two of them, where nobody's roadmap reaches.

And in this case the space between them isn't arbitrary. The two formats disagree about something POSSE tools happen to depend on.

Note here, since it's open-source software, I could do it myself and offer a solution, but I'm not versed enough in the language (Ruby) and I try not to do an AI assisted commit for my first contributions to a project.

RSS and Atom are not interchangeable

RSS 2.0 gives each item a single <link>, with no relation attribute. One item, one URL, and no way to say what that URL is for. Atom allows several links per entry, each carrying a rel that names its role: alternate, self, related, enclosure.

For a feed reader, that difference is academic. It wants the one URL, it gets the one URL, RSS is plenty.

POSSE Party uses it to choose. Its entry_url_for looks for a link marked rel="shorturl", falls back to rel="alternate", and only then settles for whatever link it can find. That's a reasonable thing to want when you're posting to platforms that count characters: let the author declare a short URL in the feed, and prefer it when there is one.

In core RSS 2.0 there's nowhere to say that. <link> is defined as "the URL of the item", one per item, no attributes. Nothing to hang a role on.

RSS is extensible, to be fair, and the extension everyone reaches for is Atom itself. Declare xmlns:atom and you can drop <atom:link rel="shorturl" href="…"/> straight into an RSS item. Feeds already do this at channel level for rel="self", constantly. So the spec isn't the blocker. Ghost doesn't emit it, POSSE Party doesn't look for it in RSS, and the moment your answer is "embed Atom inside your RSS", you may as well serve Atom.

Read further into the project and the same shape repeats. POSSE Party's entire per-post customization surface is an Atom element, <posse:post>, in its own XML namespace: which channel to post to, which image to attach, how to label the appended link, per-platform overrides. The tool is built on the assumption that a feed entry can carry structured extras.

Which makes the crash smaller and more interesting than it first looks. Nothing rejects RSS on principle. My Ghost feed has no short URL to find, so the lookup would have fallen through to the plain link and worked. It never gets there: link_rels isn't declared for RSS entries at all, so the call raises before the fallback runs. The missing piece is one line mirroring what the Atom parser already declares.

It also makes Ghost's position more defensible than my paragraph above did. Ghost optimises for the reader case, and for the reader case RSS genuinely is sufficient.

Which left two options: patch someone else's Ruby and wait for a merge, or teach my own site the format the tool already speaks. The second doesn't depend on a maintainer's weekend, and it leaves /rss/ untouched for everyone still subscribed to it.

The fix, in four steps

Ghost ships no Atom feed and no plugin for one. It does support custom routes with arbitrary content types, which is enough to build one from a template file and four lines of YAML. JWHorner's ghost-atom-feed is that template: MIT licensed, about forty lines.

What turns this from forking your existing default template into a twenty-minute job is that Ghost admin now handles both halves in the browser. Routes and redirects have their own editors, and the theme files got an in-browser code editor in May 2026, built by community member Murat Çorlu on Monaco. No download, no local build, no re-upload, no restart.

1. Add the route

Settings → Labs → Routes, and edit routes.yaml right there. Add this under the routes: block:

yaml

routes:
  /atom/:
    template: atom
    content_type: application/atom+xml

Save, and it applies immediately.

2. Add the template

Settings → Design & branding → Edit code. Create atom.hbs at the root of your active theme, same level as index.hbs, not inside partials/. Ghost resolves template: atom to atom.hbs on its own.

<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
	<title><![CDATA[ {{@site.title}} ]]></title>
	<subtitle><![CDATA[ {{@site.description}} ]]></subtitle>
	<link href="{{@site.url}}/atom/" rel="self" />
	<link href="{{@site.url}}" />
	<id>{{@site.url}}/</id>
	<updated>{{date format="YYYY-MM-DDTHH:mm:ssZ"}}</updated>

	{{#get "posts" limit="15" page="1" order="published_at desc" include="authors,tags"}}
	{{#foreach posts}}
	<entry>
		<title><![CDATA[ {{title}} ]]></title>
		<link href="{{url absolute="true"}}" />
		<id>{{url absolute="true"}}</id>
		<published>{{published_at}}</published>
		<updated>{{updated_at}}</updated>
		{{#foreach tags}}
		<category term="{{name}}"/>
		{{/foreach}}
		<summary>{{excerpt}}</summary>
		<content type="xhtml">
			<div xmlns="http://www.w3.org/1999/xhtml">
				{{content}}
			</div>
		</content>
		<author>
			<name>{{primary_author.name}}</name>
		</author>
	</entry>
	{{/foreach}}
	{{/get}}
</feed>

Read it before you paste it. The {{#get}} helper queries Ghost's Content API from inside the template, the same mechanism behind custom collections. And absolute="true" on {{url}} isn't decoration: relative URLs in a feed are how subscribers end up hitting 404s from inside their reader.

3. Advertise it (optional)

Settings → Code injection → Site header:

<link href="/atom/" type="application/atom+xml" rel="alternate" title="Atom feed" />

POSSE Party doesn't need this, since you'll paste the URL in by hand. Feed readers do. They sniff <link rel="alternate"> when someone drops your homepage URL into them. One line, cheap discoverability.

4. Point POSSE Party at it

Load https://yoursite.com/atom/ in a browser before anything else. You should get XML. If you get your 404 page instead, go back and check the YAML indentation. Once it renders, swap the feed URL in your POSSE Party config from /rss/ to /atom/.

Both feeds coexist from then on, and existing RSS subscribers never notice a thing.

Two caveats before you ship

The feed is capped at 15 posts, with no pagination. Fine for syndication, since POSSE Party only cares about what's new. Less fine if anyone tries to backfill an archive from it. Raise the limit if that's your case, but watch the response size, because type="xhtml" means every entry carries the full post content.

<content type="xhtml"> is stricter than it looks. It requires well-formed XML inside the wrapper <div>. Ghost's renderer behaves, but one raw HTML card with an unclosed tag or a stray ampersand produces a feed that parsers reject outright, and Atom parsers fail loudly rather than gracefully. If your posts lean on embeds, validate the output before wiring anything downstream.

What the migration actually taught me

Every migration guide covers content, URLs and redirects. None of them mention the integrations hanging off the old stack, which is where the "interesting" damage happens.

The fix took twenty minutes (thanks to AI-assisted investigation) rather than a filed issue and a two-month wait, for one reason: every layer was open. Ghost treats routing and templates as editable configuration instead of a fixed contract. ghost-atom-feed was forty lines of MIT-licensed Handlebars. POSSE Party's failure was legible in public, in an issue tracker, with the name of the missing method sitting right there in the stack trace.

Replace any one of those with a closed system and the answer becomes "submit a feature request, wait." or a laconic "thank you for contacting our support". Which is a far more concrete argument for portable tooling than the usual one about lock-in. When the stack is open all the way down, the gaps between tools are yours to close.

Mine cost twenty minutes, most of it spent on YAML indentation 😂 .