Skip to main content
Back to Blog
Tutorials
4 min read
December 6, 2024

How to Build a Changelog with RSS Feed in Next.js

Create a changelog page with versioned entries, category badges, RSS feed, and email subscription for product updates.

Ryel Banfield

Founder & Lead Developer

A changelog keeps users informed about product updates. Here is how to build one with markdown content and an RSS feed.

Step 1: Changelog Data Structure

// content/changelog/entries.ts
export interface ChangelogEntry {
  version: string;
  date: string;
  title: string;
  description: string;
  changes: {
    type: "feature" | "improvement" | "fix" | "breaking";
    text: string;
  }[];
  image?: string;
}

export const changelog: ChangelogEntry[] = [
  {
    version: "2.4.0",
    date: "2026-05-30",
    title: "Team Collaboration",
    description: "Invite team members, assign roles, and collaborate in real time.",
    changes: [
      { type: "feature", text: "Team invite system with email invitations" },
      { type: "feature", text: "Role-based access control for team members" },
      { type: "feature", text: "Real-time presence indicators" },
      { type: "improvement", text: "Redesigned dashboard navigation" },
      { type: "fix", text: "Fixed notification badge count on mobile" },
    ],
  },
  {
    version: "2.3.0",
    date: "2026-05-15",
    title: "Advanced Analytics",
    description: "New analytics dashboard with custom date ranges and export.",
    changes: [
      { type: "feature", text: "Custom date range picker for analytics" },
      { type: "feature", text: "CSV and PDF export for reports" },
      { type: "improvement", text: "50% faster dashboard loading" },
      { type: "improvement", text: "Added chart tooltips with detailed data" },
      { type: "fix", text: "Corrected timezone handling in analytics" },
    ],
  },
  {
    version: "2.2.0",
    date: "2026-04-28",
    title: "API v2 Launch",
    description: "New API version with improved response formats and webhooks.",
    changes: [
      { type: "feature", text: "API v2 with improved response format" },
      { type: "feature", text: "Webhook delivery with retry logic" },
      { type: "breaking", text: "API v1 deprecated, sunset date January 2027" },
      { type: "improvement", text: "Rate limiting now uses sliding window" },
      { type: "fix", text: "Fixed pagination cursor encoding" },
    ],
  },
];

Step 2: Changelog Page

// app/changelog/page.tsx
import { changelog, type ChangelogEntry } from "@/content/changelog/entries";
import Link from "next/link";
import type { Metadata } from "next";

export const metadata: Metadata = {
  title: "Changelog",
  description: "See what is new and improved in our latest releases.",
  alternates: {
    types: {
      "application/rss+xml": "/changelog/rss.xml",
    },
  },
};

const typeBadge: Record<string, { label: string; className: string }> = {
  feature: { label: "New", className: "bg-green-100 text-green-800" },
  improvement: { label: "Improved", className: "bg-blue-100 text-blue-800" },
  fix: { label: "Fixed", className: "bg-yellow-100 text-yellow-800" },
  breaking: { label: "Breaking", className: "bg-red-100 text-red-800" },
};

function ChangelogEntryCard({ entry }: { entry: ChangelogEntry }) {
  return (
    <article className="relative pl-8 pb-12">
      {/* Timeline dot */}
      <div className="absolute left-0 top-1.5 h-3 w-3 rounded-full border-2 border-blue-600 bg-white" />
      {/* Timeline line */}
      <div className="absolute left-[5px] top-5 bottom-0 w-px bg-gray-200" />

      <div className="space-y-3">
        <div className="flex items-center gap-3">
          <span className="rounded-full bg-gray-100 px-2.5 py-0.5 text-xs font-semibold">
            v{entry.version}
          </span>
          <time className="text-sm text-gray-500">
            {new Date(entry.date).toLocaleDateString("en", {
              year: "numeric",
              month: "long",
              day: "numeric",
            })}
          </time>
        </div>

        <h2 className="text-xl font-semibold">{entry.title}</h2>
        <p className="text-gray-600">{entry.description}</p>

        {entry.image && (
          <img
            src={entry.image}
            alt={entry.title}
            className="rounded-lg border"
            loading="lazy"
          />
        )}

        <ul className="space-y-2">
          {entry.changes.map((change, i) => {
            const badge = typeBadge[change.type];
            return (
              <li key={i} className="flex items-start gap-2">
                <span
                  className={`mt-0.5 shrink-0 rounded px-1.5 py-0.5 text-xs font-medium ${badge.className}`}
                >
                  {badge.label}
                </span>
                <span className="text-sm">{change.text}</span>
              </li>
            );
          })}
        </ul>
      </div>
    </article>
  );
}

export default function ChangelogPage() {
  return (
    <main className="container max-w-2xl py-10">
      <div className="mb-10 flex items-center justify-between">
        <div>
          <h1 className="text-3xl font-bold">Changelog</h1>
          <p className="mt-1 text-gray-600">
            New updates and improvements to our product.
          </p>
        </div>
        <Link
          href="/changelog/rss.xml"
          className="rounded-lg border px-3 py-2 text-sm hover:bg-gray-50"
        >
          RSS Feed
        </Link>
      </div>

      <div className="relative">
        {changelog.map((entry) => (
          <ChangelogEntryCard key={entry.version} entry={entry} />
        ))}
      </div>
    </main>
  );
}

Step 3: RSS Feed

// app/changelog/rss.xml/route.ts
import { changelog } from "@/content/changelog/entries";

export async function GET() {
  const siteUrl = "https://rcbsoftware.com";

  const items = changelog
    .map(
      (entry) => `
    <item>
      <title>v${entry.version}: ${entry.title}</title>
      <link>${siteUrl}/changelog#v${entry.version}</link>
      <guid isPermaLink="false">changelog-v${entry.version}</guid>
      <pubDate>${new Date(entry.date).toUTCString()}</pubDate>
      <description><![CDATA[
        <p>${entry.description}</p>
        <ul>
          ${entry.changes.map((c) => `<li><strong>${c.type}:</strong> ${c.text}</li>`).join("")}
        </ul>
      ]]></description>
    </item>`
    )
    .join("");

  const rss = `<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
  <channel>
    <title>RCB Software Changelog</title>
    <link>${siteUrl}/changelog</link>
    <description>New updates and improvements to RCB Software.</description>
    <language>en-us</language>
    <lastBuildDate>${new Date(changelog[0].date).toUTCString()}</lastBuildDate>
    <atom:link href="${siteUrl}/changelog/rss.xml" rel="self" type="application/rss+xml" />
    ${items}
  </channel>
</rss>`;

  return new Response(rss, {
    headers: {
      "Content-Type": "application/rss+xml; charset=utf-8",
      "Cache-Control": "s-maxage=3600, stale-while-revalidate",
    },
  });
}

Step 4: Filter by Category

// components/changelog/ChangelogFilters.tsx
"use client";

import { useState } from "react";
import type { ChangelogEntry } from "@/content/changelog/entries";

const filterOptions = [
  { value: "all", label: "All" },
  { value: "feature", label: "Features" },
  { value: "improvement", label: "Improvements" },
  { value: "fix", label: "Fixes" },
  { value: "breaking", label: "Breaking" },
];

export function ChangelogFilters({
  entries,
  children,
}: {
  entries: ChangelogEntry[];
  children: (filtered: ChangelogEntry[]) => React.ReactNode;
}) {
  const [filter, setFilter] = useState("all");

  const filtered =
    filter === "all"
      ? entries
      : entries
          .map((entry) => ({
            ...entry,
            changes: entry.changes.filter((c) => c.type === filter),
          }))
          .filter((entry) => entry.changes.length > 0);

  return (
    <div>
      <div className="mb-6 flex gap-2">
        {filterOptions.map((opt) => (
          <button
            key={opt.value}
            onClick={() => setFilter(opt.value)}
            className={`rounded-full px-3 py-1.5 text-sm ${
              filter === opt.value
                ? "bg-gray-900 text-white"
                : "bg-gray-100 text-gray-600 hover:bg-gray-200"
            }`}
          >
            {opt.label}
          </button>
        ))}
      </div>
      {children(filtered)}
    </div>
  );
}

Step 5: Subscribe to Updates

// components/changelog/SubscribeChangelog.tsx
"use client";

import { useState } from "react";

export function SubscribeChangelog() {
  const [email, setEmail] = useState("");
  const [subscribed, setSubscribed] = useState(false);

  async function handleSubscribe(e: React.FormEvent) {
    e.preventDefault();

    await fetch("/api/changelog/subscribe", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ email }),
    });

    setSubscribed(true);
  }

  if (subscribed) {
    return (
      <p className="text-sm text-green-600">
        Subscribed! You will receive updates when we ship new features.
      </p>
    );
  }

  return (
    <form onSubmit={handleSubscribe} className="flex gap-2">
      <input
        type="email"
        value={email}
        onChange={(e) => setEmail(e.target.value)}
        placeholder="your@email.com"
        required
        className="rounded-lg border px-3 py-2 text-sm"
      />
      <button
        type="submit"
        className="rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700"
      >
        Subscribe
      </button>
    </form>
  );
}

Need Product Communication Tools?

We build changelogs, release note systems, and product update workflows. Contact us to keep your users informed.

changelogRSSrelease notescontentNext.jstutorial

Ready to Start Your Project?

RCB Software builds world-class websites and applications for businesses worldwide.

Get in Touch

Related Articles