Changelog
Render a public changelog page in React and serialize the same entries as RSS 2.0 + JSON Feed 1.1.
@tour-kit/announcements ships two changelog primitives that share the same ChangelogEntry shape:
<ChangelogPage>— a drop-in React page (category filter + emoji reactions + media support) you mount on any route.serializeFeed— a pure RSS 2.0 + JSON Feed 1.1 serializer for the same entries.
Use them together: render the entries to a public web page with <ChangelogPage>, syndicate the same list as an RSS feed with serializeFeed. The renderer lives behind the @tour-kit/announcements/changelog subpath, so toast/modal/banner-only consumers do not pay the bundle cost.
<ChangelogPage> — render
Mount it anywhere. No router context required.
import { ChangelogPage } from '@tour-kit/announcements/changelog';
import type { ChangelogEntry } from '@tour-kit/announcements';
const entries: ChangelogEntry[] = [
{
id: 'evt-2026-05-04',
variant: 'modal',
title: 'Bulk export now available',
description: 'Export your data to CSV or PDF directly from the dashboard.',
permalink: 'https://acme.com/changelog/bulk-export',
publishedAt: new Date('2026-05-04T00:00:00Z'),
category: 'Features',
},
// ...
];
export default function ChangelogRoute() {
return <ChangelogPage entries={entries} />;
}The component derives its category sidebar from entry.category, prepends an "All" reset button, and supports ArrowDown/ArrowUp keyboard navigation with a roving tabindex.
Reactions
Pass onReact to capture clicks on the per-entry emoji row (👍 😐 👎). The component holds no reaction state — wire the callback to your own backend or analytics:
<ChangelogPage
entries={entries}
onReact={(entryId, emoji) => {
fetch('/api/changelog-reaction', {
method: 'POST',
body: JSON.stringify({ entryId, emoji }),
});
}}
/>Media
If an entry carries media (any URL <MediaSlot> understands — YouTube, Vimeo, Loom, Wistia, native video, GIF, Lottie), it renders above the entry body. See the media docs for supported sources.
Next.js URL-sync recipe (controlled mode)
Omitting category / onCategoryChange runs the page in uncontrolled mode (internal useState). Pass both to lift state into your URL:
'use client';
import { ChangelogPage } from '@tour-kit/announcements/changelog';
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
import type { ChangelogEntry } from '@tour-kit/announcements';
export function MyChangelog({ entries }: { entries: ChangelogEntry[] }) {
const sp = useSearchParams();
const router = useRouter();
const pathname = usePathname();
const category = sp.get('category');
const onCategoryChange = (next: string | null) => {
const params = new URLSearchParams(sp);
if (next) params.set('category', next);
else params.delete('category');
router.replace(`${pathname}?${params.toString()}`);
};
return (
<ChangelogPage
entries={entries}
category={category}
onCategoryChange={onCategoryChange}
/>
);
}The same pattern works with TanStack Router (useSearch + useNavigate) or Remix loaders — anywhere you can read and write a URL parameter.
Internationalization
The page reads from <LocaleProvider> (Phase 1 i18n primitives). Default English fallbacks ship in the component, so it works without any provider mounted. Override per-key:
| Key | English fallback |
|---|---|
changelog.empty | "No changelog entries yet" |
changelog.filter.all | "All" |
changelog.filter.label | "Filter by category" |
changelog.reactions.label | "Reactions" |
changelog.reaction.thumbs_up | "Thumbs up" |
changelog.reaction.neutral | "Neutral" |
changelog.reaction.thumbs_down | "Thumbs down" |
import { LocaleProvider } from '@tour-kit/core';
<LocaleProvider
locale="es"
messages={{
'changelog.empty': 'Aún no hay novedades',
'changelog.filter.all': 'Todas',
'changelog.reaction.thumbs_up': 'Pulgar arriba',
}}
>
<ChangelogPage entries={entries} />
</LocaleProvider>RTL locales (ar, he, fa, ur) automatically apply dir="rtl" to the page root.
Subpath import
The page lives behind @tour-kit/announcements/changelog so toast/modal/banner-only consumers tree-shake out the renderer. Importing <ChangelogPage> from @tour-kit/announcements will not work — use the subpath.
serializeFeed — syndicate
serializeFeed produces both an RSS 2.0 XML string and a JSON Feed 1.1 string from a list of ChangelogEntrys. The output is suitable for serving from app/changelog/rss.xml/route.ts and app/changelog/feed.json/route.ts in Next.js, or any other framework that returns raw Response bodies.
The serializer is a pure function — no DOM, no network I/O, no runtime XML library dependency. Every consumer-supplied string is XML-entity-escaped before reaching the output. Invalid publishedAt inputs throw TypeError rather than emitting Invalid Date strings into a live feed.
When to use
Pair serializeFeed with a public changelog page when:
- You publish new in-app announcements regularly and want subscribers to follow updates outside the app.
- You want both formats live (RSS for traditional readers; JSON Feed for modern, JSON-first clients) without two implementations.
- You already drive announcements through
<AnnouncementsProvider>— the sameAnnouncementConfigshape extends toChangelogEntry.
Define your entries
import type { ChangelogEntry } from '@tour-kit/announcements';
const entries: ChangelogEntry[] = [
{
id: 'evt-2026-05-04',
variant: 'modal',
title: 'Bulk export now available',
description: 'Export your data to CSV or PDF directly from the dashboard.',
permalink: 'https://acme.com/changelog/bulk-export',
publishedAt: new Date('2026-05-04T00:00:00Z'),
category: 'feature',
},
{
id: 'evt-2026-04-22',
variant: 'modal',
title: 'Fixed: dashboard filter persistence',
description: 'Filters now persist across page reloads.',
permalink: 'https://acme.com/changelog/filter-fix',
publishedAt: new Date('2026-04-22T00:00:00Z'),
category: 'fix',
},
];ChangelogEntry extends AnnouncementConfig, so any config that already drives an in-app announcement can be reused as a changelog entry by adding publishedAt and permalink.
GUID stability
The id field becomes the RSS <guid isPermaLink="false"> and the JSON Feed item id. It must be stable across publishes — rotating an id republishes the item to subscribers. Generate it once (e.g. from your CMS slug or a UUID) and keep it forever.
Wire the routes (Next.js App Router)
RSS — app/changelog/rss.xml/route.ts
import { serializeFeed } from '@tour-kit/announcements';
import { getChangelogEntries } from '@/lib/changelog';
export async function GET() {
const entries = await getChangelogEntries();
const { rss } = serializeFeed(entries, {
title: 'Acme Changelog',
description: 'Product updates from Acme',
siteUrl: 'https://acme.com',
feedUrl: 'https://acme.com/changelog',
});
return new Response(rss, {
headers: {
'Content-Type': 'application/xml; charset=utf-8',
'Cache-Control': 'public, max-age=600',
},
});
}JSON Feed — app/changelog/feed.json/route.ts
import { serializeFeed } from '@tour-kit/announcements';
import { getChangelogEntries } from '@/lib/changelog';
export async function GET() {
const entries = await getChangelogEntries();
const { jsonFeed } = serializeFeed(entries, {
title: 'Acme Changelog',
description: 'Product updates from Acme',
siteUrl: 'https://acme.com',
feedUrl: 'https://acme.com/changelog',
});
return new Response(jsonFeed, {
headers: {
'Content-Type': 'application/feed+json; charset=utf-8',
'Cache-Control': 'public, max-age=600',
},
});
}Headers
RSS readers expect application/xml (or text/xml) — modern feed validators accept both. JSON Feed 1.1 uses the dedicated application/feed+json MIME type. The charset=utf-8 suffix matches the <?xml version="1.0" encoding="UTF-8"?> declaration in the RSS body.
Options
interface SerializeFeedOptions {
title: string; // Channel/feed title
description: string; // Channel/feed description
siteUrl: string; // Home page URL (RSS <link>, JSON Feed home_page_url)
feedUrl: string; // Base feed URL — .xml and .json appended automatically
language?: string; // Defaults to 'en' if omitted
copyright?: string; // Optional copyright statement (RSS only)
}Pass feedUrl without a file extension — the serializer appends .xml for the RSS <atom:link rel="self"> and .json for the JSON Feed feed_url.
Caching
Most consumers cache aggressively. A 10-minute browser cache and a CDN-level revalidation works well for daily-or-slower changelog updates:
'Cache-Control': 'public, max-age=600, s-maxage=3600, stale-while-revalidate=86400'If you publish more often, drop max-age to 60 and rely on s-maxage + stale-while-revalidate to keep the CDN warm.
Validation
Both outputs are spec-compliant:
- RSS 2.0 validates against
https://www.rssboard.org/rss-specification. The serializer round-trips losslessly throughfast-xml-parserfor every emitted field (title, link, guid, pubDate, description, category, atom:link). - JSON Feed 1.1 validates against the official schema. The
versionfield is exactly"https://jsonfeed.org/version/1.1".
If you make changes to your entry shape and want to verify, paste the output into the W3C Feed Validator (RSS) or run it through the JSON Feed validator (JSON Feed).
Security
Every consumer-supplied string flows through XML entity escaping before reaching the RSS body. The serializer ships with a 1000-case property-based fuzz test that asserts no raw <, >, &, or ]]> ever appears in output.
For JSON Feed, JSON.stringify handles escaping natively — manually escaping JSON content would double-escape and corrupt data.
Trust boundary
The serializer is XSS-safe for arbitrary input. But if your getChangelogEntries() reads from a CMS that allows raw HTML in the description, that HTML lands in the RSS body as escaped text (the entire string is shown, including the tags) — not rendered HTML. Use contentHtml for the JSON Feed content_html field if you want raw HTML, and accept that RSS will continue to entity-escape the description.