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.