How to add an RSS feed to your changelog
23 September 2026 · by Nordva, who make Nordva Launch
How do I add an RSS or Atom feed to my product's changelog?
Generate a feed file from the same entries your changelog page shows: RSS 2.0 needs a channel title, link and description, and each item a title or description; Atom needs an id, title and updated date on the feed and every entry. Escape every value, add a link rel="alternate" tag to the page so readers find it, and check it with the W3C Feed Validator.
A changelog without a feed asks people to remember to come back. A feed lets the few users who care most (and the tools they use: feed readers, automation services) be told when something ships. It is one static file, built from the entries you already have. Here is what the formats require, a generator you can run today, and the three mistakes that make feeds break in readers.
What a feed has to contain
RSS 2.0. The RSS 2.0 specification requires three elements on the channel: title, link (your changelog page) and description. Items are looser: “All elements of an item are optional, however at least one of title or description must be present.” In practice give every item a title, link, guid, pubDate and description. Two details matter:
- Dates follow RFC 822, as in
Sun, 20 Sep 2026 09:00:00 GMT. - The
guidis how readers recognise an entry: “an aggregator may choose to use this string to determine if an item is new.” Change it and readers show the entry again. Use the entry’s permanent URL and never change it.
Atom. RFC 4287 is stricter. The feed “MUST contain exactly one” id, title and updated, and an author unless every entry has one. Each entry needs the same three, plus either content or an alternate link. Dates follow RFC 3339 with an uppercase T and Z, which is exactly what JavaScript’s toISOString() produces. The feed “SHOULD contain” a link with rel="self" pointing at the feed’s own URL.
The RSS Best Practices Profile says an RSS feed “should contain an atom:link” with rel="self" too, and the W3C validator warns when it is missing.
Escaping: where feeds break
A changelog title like Faster <search> & filters is valid text on your page and invalid XML in a feed. Three rules:
- Escape every value that goes into the XML:
&,<,>, and quotes inside attributes. One unescaped ampersand makes the whole file unreadable to a strict parser, not just that entry. - HTML bodies are escaped once more. RSS allows “entity-encoded HTML” in
description, and Atom’stype="html"content says “any markup within MUST be escaped”. So<p>goes into the file as<p>. The reader unescapes it and renders the HTML. The alternative, wrapping HTML inCDATA, works too, but breaks if the body ever contains]]>. - Remove control characters. XML 1.0 forbids most characters below
U+0020, even escaped. A body pasted from a terminal can contain one, and it invalidates the file.
For RSS titles, which are plain text, the RSS Profile recommends & and < for & and <, because they work “in the widest number of aggregators”. The script below does that.
One more: if entry bodies can come from anyone other than you, sanitise the HTML before it goes into the feed. Feed readers render it.
A generator you can run today
No dependencies, Node 18 or later. It reads either a JSON file of entries or a CHANGELOG.md in the Keep a Changelog format (## [1.2.0] - 2026-09-20), and writes feed.xml (RSS 2.0) and feed.atom (Atom 1.0).
// build-feed.mjs: node build-feed.mjs changelog.json|CHANGELOG.md ./public/changelog
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { join } from "node:path";
const SITE = {
title: "Acme changelog",
description: "What is new in Acme.",
home: "https://acme.example/changelog/", // the page people read
base: "https://acme.example/changelog/", // where feed.xml and feed.atom will live
author: "Acme",
};
// XML 1.0 forbids most control characters even when escaped, so drop them first.
const clean = (s) => String(s ?? "").replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\uFFFE\uFFFF]/g, "");
const esc = (s) =>
clean(s)
.replace(/&/g, "&")
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/"/g, """)
.replace(/'/g, "'");
// RSS titles are plain text; the RSS Profile asks for hex references there.
// ">" is escaped too, because "]]>" is not allowed in XML text.
const escTitle = (s) => clean(s).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
// Keep a Changelog style: "## [1.2.0] - 2026-09-20", then lines and "- " bullets.
function fromMarkdown(md) {
const entries = [];
const parts = md.replace(/\r\n?/g, "\n").split(/^## /m).slice(1);
for (const part of parts) {
const [heading, ...lines] = part.split("\n");
const m = /^\[?([^\]\s]+)\]?\s+-\s+(\d{4}-\d{2}-\d{2})/.exec(heading);
if (!m) continue; // "## [Unreleased]" has no date: not published yet
const [, version, date] = m;
let html = "";
let inList = false;
for (const line of lines) {
// "[1.2.0]: https://..." link definitions at the end of the file are not part of an entry.
if (/^\[[^\]]+\]:\s*\S/.test(line)) continue;
const bullet = /^\s*[-*]\s+(.*)$/.exec(line);
if (bullet && !inList) { html += "<ul>"; inList = true; }
if (!bullet && inList) { html += "</ul>"; inList = false; }
if (bullet) html += `<li>${esc(bullet[1])}</li>`;
else if (/^###\s+/.test(line)) html += `<h3>${esc(line.replace(/^###\s+/, ""))}</h3>`;
else if (line.trim()) html += `<p>${esc(line.trim())}</p>`;
}
if (inList) html += "</ul>";
entries.push({ id: `v${version}`, title: `Version ${version}`, date: `${date}T00:00:00Z`, html });
}
return entries;
}
function load(file) {
const raw = readFileSync(file, "utf8");
const list = file.endsWith(".md") ? fromMarkdown(raw) : JSON.parse(raw);
return list
.map((e) => {
const date = new Date(e.date);
if (Number.isNaN(date.getTime())) throw new Error(`Entry "${e.title}" has no valid date: ${e.date}`);
// Plain text is escaped here. `html` is trusted: only put HTML you wrote yourself in it.
const html = e.html ?? clean(e.text).split(/\n{2,}/).map((p) => `<p>${esc(p)}</p>`).join("");
return { ...e, date, html, url: e.url ?? `${SITE.home}#${encodeURIComponent(e.id)}` };
})
.sort((a, b) => b.date - a.date)
.slice(0, 50);
}
function rss(entries) {
const items = entries
.map(
(e) => ` <item>
<title>${escTitle(e.title)}</title>
<link>${esc(e.url)}</link>
<guid isPermaLink="true">${esc(e.url)}</guid>
<pubDate>${e.date.toUTCString()}</pubDate>
<description>${esc(e.html)}</description>
</item>`,
)
.join("\n");
const newest = entries[0]?.date ?? new Date();
return `<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
<channel>
<title>${escTitle(SITE.title)}</title>
<link>${esc(SITE.home)}</link>
<description>${esc(SITE.description)}</description>
<atom:link href="${esc(SITE.base + "feed.xml")}" rel="self" type="application/rss+xml" />
<lastBuildDate>${newest.toUTCString()}</lastBuildDate>
${items}
</channel>
</rss>
`;
}
function atom(entries) {
const items = entries
.map(
(e) => ` <entry>
<id>${esc(e.url)}</id>
<title>${esc(e.title)}</title>
<link rel="alternate" type="text/html" href="${esc(e.url)}" />
<updated>${e.date.toISOString()}</updated>
<content type="html">${esc(e.html)}</content>
</entry>`,
)
.join("\n");
const newest = entries[0]?.date ?? new Date();
return `<?xml version="1.0" encoding="UTF-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
<id>${esc(SITE.home)}</id>
<title>${esc(SITE.title)}</title>
<link rel="alternate" type="text/html" href="${esc(SITE.home)}" />
<link rel="self" type="application/atom+xml" href="${esc(SITE.base + "feed.atom")}" />
<updated>${newest.toISOString()}</updated>
<author><name>${esc(SITE.author)}</name></author>
${items}
</feed>
`;
}
const [input = "changelog.json", outDir = "public/changelog"] = process.argv.slice(2);
const entries = load(input);
mkdirSync(outDir, { recursive: true });
writeFileSync(join(outDir, "feed.xml"), rss(entries));
writeFileSync(join(outDir, "feed.atom"), atom(entries));
console.log(`Wrote ${entries.length} entries to ${outDir}/feed.xml and feed.atom`);
A JSON input looks like this. Give each entry a stable id; use text for plain text (escaped for you) or html for markup you wrote:
[
{
"id": "2026-09-20-dark-mode",
"title": "Dark mode & a faster <search> box",
"date": "2026-09-20T09:00:00Z",
"text": "Settings now has a dark theme.\n\nSearch is faster on large projects."
},
{
"id": "2026-09-02-csv-export",
"title": "Export to CSV",
"date": "2026-09-02T14:30:00Z",
"html": "<p>Every table has an <strong>Export</strong> button.</p>"
}
]
Run it as part of your build, so the feed can never fall behind the page:
{ "scripts": { "prebuild": "node build-feed.mjs changelog.json public/changelog" } }
The entry title above contains & and < on purpose. The output escapes both, and both files pass the W3C validator (see below). Each item links to #id on your changelog page, so give each entry on the page a matching id attribute, or set url per entry if every entry has its own page.
Two choices in the script are deliberate. It keeps the newest 50 entries, because readers only need recent ones and a feed that grows forever gets slow to fetch. And it never writes the build time into lastBuildDate or updated: those take the newest entry’s date, so rebuilding without a new entry does not look like a change.
Let readers find it
Readers and browser extensions look for a link tag in the page’s head. The HTML standard says rel="alternate" with type="application/rss+xml" or application/atom+xml “creates a hyperlink referencing a syndication feed”, and that with several, “the first such element (in tree order) should be used as the default”. The RSS Autodiscovery spec asks for full URLs and a short title:
<head>
<link rel="alternate" type="application/rss+xml" title="Acme changelog (RSS)" href="https://acme.example/changelog/feed.xml" />
<link rel="alternate" type="application/atom+xml" title="Acme changelog (Atom)" href="https://acme.example/changelog/feed.atom" />
</head>
Put it on the changelog page, and on your home page too if you want a reader pointed at your domain to find it. Add a visible “RSS” link next to the changelog title for people who copy links by hand.
If your host lets you set content types, serve the files as application/rss+xml and application/atom+xml, the types the discovery tags name.
Check it before you announce it
Paste the URL, or the file’s contents, into the W3C Feed Validation Service, which checks RSS and Atom. Fix every error; read every warning. When checking pasted content, “Self reference doesn’t match document location” is expected (there is no location), and it disappears when you validate the live URL.
Then subscribe in a real reader and publish a test entry. Validators check syntax; a reader shows you whether the entry looks right, whether a second build shows it twice (your ids changed), and whether the links land on the entry.
If your changelog is on Nordva Launch
Every changelog in Nordva Launch already has all three formats, with no setup and no key:
https://api.nordva.dev/public/changelog/your-project-slug/feed.xml RSS 2.0
https://api.nordva.dev/public/changelog/your-project-slug/feed.atom Atom 1.0
https://api.nordva.dev/public/changelog/your-project-slug/feed.json JSON Feed 1.1
The hosted page at launch.nordva.dev/log/your-project-slug/ carries link rel="alternate" tags for all three, so readers find them from the page’s address. If you show the changelog on your own site, add the link tags above with these URLs. Entry bodies are Markdown, and the feeds escape them first and turn only a small fixed set (paragraphs, bullet lists, headings as bold lines, bold, italic, inline code and http(s) links) into HTML, so nothing a writer pastes can break the file or inject markup into readers.
An entry is one POST with a title and Markdown, from CI, a script, the dashboard, or an AI coding agent through the MCP server; the Changelog API page shows the call, and publishing from Claude Code or Cursor shows the agent route. The free plan includes 25 entries with the hosted page and feeds; Indie ($19 a month) has unlimited entries and adds email subscribers (up to 500), for users who would rather get an email than run a reader.
Who should not use Launch for this: if your changelog is already a page in a static site you build, the script above is all you need, and many site generators have a feed plugin. A hosted changelog earns its place when you want publishing to happen from code or an agent, with the page, feeds and emails following from one call.
Start free, no card.
Questions
- RSS or Atom?
- Both, if you generate them: it is the same loop twice. Atom's rules are stricter (every entry needs an id and an updated date in one fixed format), which makes it easier to get right. Feed readers read both.
- Should the feed carry the full entry or a summary?
- The full entry. A changelog entry is short, and people who follow a feed read it in the reader. Link each item to its entry on your page for people who want the context.
- What about JSON Feed?
- JSON Feed 1.1 is a third format some readers support. It needs version, title and items, and each item an id and content_html or content_text. It is easy to add, and JSON.stringify takes care of the escaping.
- How often do readers check the feed?
- That is up to each reader, not you. Serve the file with a sensible cache header and keep item ids stable, so a reader that checks late still sees each entry once.
Try it in your project
npx nordva@latest init
Free plan, no card, no invite.