Skip to main content
userTourKit
@tour-kit/announcements

Changelog

Render a public changelog page in React and serialize the same entries as RSS 2.0 + JSON Feed 1.1.

domidex01Published Updated

@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:

KeyEnglish 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 same AnnouncementConfig shape extends to ChangelogEntry.

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 through fast-xml-parser for every emitted field (title, link, guid, pubDate, description, category, atom:link).
  • JSON Feed 1.1 validates against the official schema. The version field 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.