# Changelog Page Template in React: RSS, Email, Permalinks

> Build a changelog page template in Next.js that brings users back: dated releases grouped as New, Improved and Fixed, a page per release, RSS and email signup.

- Author: [Serban Rusu](https://wingo-ui.com/blog/authors/serban), Founder of Wingo UI
- Published: Nov 6, 2026
- Category: [Build it: screens and apps](https://wingo-ui.com/blog/category/build)
- Reading time: 10 min
- Canonical: https://wingo-ui.com/blog/changelog-page-template

## TL;DR

A changelog page template should show dated releases newest first, group each release's changes as New, Improved and Fixed, give every release a permalink, and offer RSS and an email signup so people come back on their own. In Next.js, keep the releases in one data file that the page, a static page per release and the RSS route all read. Wingo UI's Changelog Page template renders the list, the filter and the subscribe row; you wire the data, the feed and the signup.

A changelog page is where a buyer checks whether your product is still moving and where a user finds out the thing they asked for shipped. Most of them fail at the second job: nobody remembers to visit a page, so the release that took a month gets read by five people. This tutorial builds a changelog page template in the Next.js App Router that people come back to: dated releases grouped as New, Improved and Fixed, a page per release, an RSS feed and an email signup, all fed from one data file. The UI is our [Changelog Page template](https://wingo-ui.com/templates/changelog-page), so we are not neutral, but the data model, the routes and the feed work the same with any React changelog component or release notes page template.

## What makes a SaaS changelog worth reading?

A SaaS changelog gets read when it is easy to scan and easy to follow. The first part is mostly agreed on. [Keep a Changelog 1.1.0](https://keepachangelog.com/en/1.1.0/) (checked November 6, 2026) lists the principles: written for people, an entry for every version, changes of the same type grouped together, versions you can link to, the latest first, and a release date on each, with ISO format (`2026-11-06`) as the recommended way to write it.

Keep a Changelog groups by six types: Added, Changed, Deprecated, Removed, Fixed and Security. That suits a developer library. For a product changelog read by customers, fewer groups are easier to scan. [Gleap's changelog template](https://www.gleap.ai/blog/changelog-template) (updated September 27, 2026, checked November 6, 2026) leads with New, Improved and Fixed, adds Deprecated and Security, and warns that too many labels make a changelog harder to scan. We go one step further and keep three. This is how the six map onto them:

| Keep a Changelog type | Group on a product changelog | How to write it |
|---|---|---|
| Added | New | What the user can do now that they could not before |
| Changed | Improved | What got faster, clearer or easier, in the user's words |
| Fixed, Security | Fixed | The symptom that is gone; for security, the impact without exploit details |
| Deprecated, Removed | Summary plus an Improved line | Say it in the release summary first, with the date and a link to the migration |

The second part, following, gets less attention. When we searched "changelog page template" on November 6, 2026, the top results were page builder templates (Framer, Ceptile), a Next.js and MDX starter on GitHub, and copy-paste text formats for a single entry. They focus on how the page looks. Little of it covers the three things that bring readers back: a link per release that keeps working, a feed, and an email when something ships.

## How do you install the changelog page template?

One command copies the template and the parts it imports. It is a Pro item, so the account behind the CLI needs Wingo UI Pro.

```bash
npx wingo-ui@latest add changelog-page
```

You get `components/templates/changelog-page.tsx` and `changelog-page-data.ts`, plus what they use: the free Marketing Section (for the page backdrop, the heading and the email form), the Marketing Header and Footer, the [Segmented Control](https://wingo-ui.com/components/segmented-control) for the New / Improved / Fixed filter, the [Badge](https://wingo-ui.com/components/badge) for group labels and the Latest tag, and the [Pagination](https://wingo-ui.com/components/pagination) for Load older releases. With no `releases` prop it renders eight sample releases of a made-up product called Acme.

> Live demo (Changelog Page): Pick Fixed in the filter and watch both the list and the release count change, then scroll: from 768px the version rail sticks beside the release you are reading. Try it at [Changelog Page](https://wingo-ui.com/templates/changelog-page) and install it with `npx wingo-ui@latest add changelog-page`.

## How do you store releases so the page, the feed and the emails agree?

Put them in one plain TypeScript file. The page, the per-release pages, the RSS route and anything that sends email all import it, so a release cannot appear in one and be missing from another.

```ts
// lib/changelog.ts
import type { ChangelogRelease } from "@/components/templates/changelog-page-data"

// newest first; date is an ISO day, yyyy-mm-dd
export const releases: ChangelogRelease[] = [
  {
    version: "1.4.0",
    date: "2026-11-03",
    title: "Recurring tasks",
    summary: "Repeat any task daily, weekly or on a custom schedule.",
    glyph: "repeat",
    tone: "success",
    changes: [
      { type: "new", text: "Recurring tasks with daily, weekly and custom schedules.", href: "/docs/recurring-tasks" },
      { type: "improved", text: "Boards with 500 cards open faster." },
      { type: "fixed", text: "Due dates no longer shift by a day across time zones." },
    ],
  },
  // older releases below
]

// the template's changelogAnchor, copied: its module is "use client", so server code cannot call it
export const releaseSlug = (version: string) => `v${version.replace(/[^a-z0-9]+/gi, "-")}`

export const findRelease = (slug: string) => releases.find((release) => releaseSlug(release.version) === slug)
```

The type import costs nothing on the server: types are erased at build time, and `changelog-page-data.ts` has no `"use client"` directive anyway, so you could import `CHANGELOG_RELEASES` from it too. The slug helper is different: `changelog-page.tsx` is a client module, and a server component that calls a function exported from one gets an error instead of a string. Copying the one-line helper keeps your slugs identical to the template's anchors, so `1.4.0` becomes `v1-4-0` everywhere.

`glyph` and `tone` pick the drawn cover (the glyph keys are in `CHANGELOG_GLYPHS`: timeline, repeat, workflow, doc, chart, plug, cloud, shield, sparkles). Pass `cover` with an image URL to use a screenshot instead.

## How do you render the changelog page in the App Router?

Split it in two, as with any client template: a server `page.tsx` for metadata and a small client wrapper for everything that is a function (`linkComponent`, `onSubscribe`).

```tsx
// app/(marketing)/changelog/page.tsx
import type { Metadata } from "next"
import { ChangelogView } from "./changelog-view"

export const metadata: Metadata = {
  title: "Changelog",
  description: "New features, improvements and fixes in Northwind, release by release.",
  alternates: {
    canonical: "/changelog",
    // adds <link rel="alternate" type="application/rss+xml"> so feed readers find it
    types: { "application/rss+xml": "/changelog/rss.xml" },
  },
}

export default function ChangelogRoute() {
  return <ChangelogView />
}
```

```tsx
// app/(marketing)/changelog/changelog-view.tsx
"use client"

import type { CSSProperties } from "react"
import Link from "next/link"
import { ChangelogPage } from "@/components/templates/changelog-page"
import { releases } from "@/lib/changelog"
import { subscribeToChangelog } from "./actions"

export function ChangelogView() {
  return (
    <ChangelogPage
      header={false}
      footer={false}
      // your layout's sticky header is 65px tall; the version rail sticks below it
      style={{ "--topbar-offset": "65px" } as CSSProperties}
      releases={releases}
      linkComponent={Link}
      rssHref="/changelog/rss.xml"
      onSubscribe={subscribeToChangelog}
      // every release is already loaded, so skip the 700 ms placeholder wait
      onLoadMore={() => {}}
      labels={{
        title: "What's new in Northwind",
        description: "New features, improvements and fixes, newest first.",
      }}
    />
  )
}
```

Three props are easy to miss. With `header={false}` the template stops reserving room for its own sticky bar, so pass your header's height through `--topbar-offset` or the rail slides under it. Without `onLoadMore`, Load older releases waits 700 ms to imitate a request; a no-op makes it instant when the data is local. And every visible string lives in `labels`: the title says Acme and the description promises a weekly release until you change them.

## Why does each release need its own page?

Because the anchor link breaks once the list paginates, and because one release at one URL is the easiest thing to share. Every release on the page has an `id` (`v1-4-0`), and the version pill links to it. But the page renders only `pageSize` releases at first (4 by default), so a link to `/changelog#v1-0-0` from an old email lands at the top of a page where that release does not exist yet.

A static page per release fixes that, and it gives RSS items, emails and posts a link that always works. `ChangelogReleaseEntry`, the part the template uses for each release, renders one on its own:

```tsx
// app/(marketing)/changelog/[slug]/page.tsx
import type { Metadata } from "next"
import { notFound } from "next/navigation"
import { findRelease, releaseSlug, releases } from "@/lib/changelog"
import { ReleaseView } from "./release-view"

export const dynamicParams = false

export function generateStaticParams() {
  return releases.map((release) => ({ slug: releaseSlug(release.version) }))
}

type Props = { params: Promise<{ slug: string }> }

export async function generateMetadata({ params }: Props): Promise<Metadata> {
  const release = findRelease((await params).slug)
  if (!release) return {}
  return {
    title: `${release.version}: ${release.title}`,
    description: release.summary,
    alternates: { canonical: `/changelog/${releaseSlug(release.version)}` },
  }
}

export default async function ReleaseRoute({ params }: Props) {
  const release = findRelease((await params).slug)
  if (!release) notFound()
  return <ReleaseView release={release} latest={release === releases[0]} />
}
```

```tsx
// app/(marketing)/changelog/[slug]/release-view.tsx
"use client"

import Link from "next/link"
import { ChangelogReleaseEntry } from "@/components/templates/changelog-page"
import type { ChangelogRelease } from "@/components/templates/changelog-page-data"
import { NewsletterSection } from "@/components/blocks/newsletter-section"
import { subscribeToChangelog } from "../actions"

export function ReleaseView({ release, latest }: { release: ChangelogRelease; latest: boolean }) {
  return (
    <main className="px-4 pt-6 sm:px-6">
      {/* the entry's title is an h2, so the page still needs an h1 */}
      <h1 className="sr-only">{`${release.version}: ${release.title}`}</h1>
      <ChangelogReleaseEntry release={release} latest={latest} layout="stacked" linkComponent={Link} />
      <NewsletterSection
        layout="inline"
        title="Get the next release by email"
        description="One short email when we ship. No other mail."
        // the default shows a sample reader count; hide it until you have a real one
        subscribers={0}
        onSubscribe={subscribeToChangelog}
        linkComponent={Link}
      />
    </main>
  )
}
```

The release is plain JSON, so it crosses from the server page to the client wrapper without trouble. Note the `subscribers={0}`: the [Newsletter Section](https://wingo-ui.com/components/newsletter-section) defaults to a sample count of 12,400 readers, which is fine in a demo and a fabricated number on your site. Zero hides the proof line.

> Live demo (Newsletter Section): Type an email and press Subscribe: an invalid address shows an error, a valid one spins, then the form turns into a success line. On a phone the field and button stack at full width. Try it at [Newsletter Section](https://wingo-ui.com/components/newsletter-section) and install it with `npx wingo-ui@latest add newsletter-section`.

## How do you add an RSS feed to a Next.js changelog?

A route handler that reads the same `releases` and returns RSS 2.0. Link each item to its release page, and put the grouped changes in the description so readers see the whole release without leaving their feed reader.

```ts
// app/(marketing)/changelog/rss.xml/route.ts
import { releases, releaseSlug } from "@/lib/changelog"

export const dynamic = "force-static"

const SITE = "https://northwind.dev"
const GROUPS = [["new", "New"], ["improved", "Improved"], ["fixed", "Fixed"]] as const
const escape = (text: string) =>
  text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;")

export function GET() {
  const items = releases
    .slice(0, 50)
    .map((release) => {
      const url = `${SITE}/changelog/${releaseSlug(release.version)}`
      const body = GROUPS.map(([type, label]) => {
        const changes = release.changes.filter((change) => change.type === type)
        if (!changes.length) return ""
        return `<h3>${label}</h3><ul>${changes.map((change) => `<li>${escape(change.text)}</li>`).join("")}</ul>`
      }).join("")
      return `<item>
  <title>${escape(`${release.version}: ${release.title}`)}</title>
  <link>${url}</link>
  <guid isPermaLink="true">${url}</guid>
  <pubDate>${new Date(`${release.date}T12:00:00Z`).toUTCString()}</pubDate>
  <description>${escape(`<p>${escape(release.summary)}</p>${body}`)}</description>
</item>`
    })
    .join("\n")

  const xml = `<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0"><channel>
<title>Northwind changelog</title>
<link>${SITE}/changelog</link>
<description>New features, improvements and fixes in Northwind.</description>
${items}
</channel></rss>`

  return new Response(xml, { headers: { "Content-Type": "application/rss+xml; charset=utf-8" } })
}
```

The description is HTML escaped twice on purpose: once for the text inside the HTML, once so the HTML itself is valid inside XML. Since Next.js 15, `GET` route handlers are not cached by default, so `force-static` is what turns the feed into a file built once per deploy. With Cache Components turned on, drop that line: a handler that reads no request data is prerendered anyway. Noon UTC (`T12:00:00Z`) keeps the release on its calendar day for readers from UTC-12 to UTC+11; the template goes further and formats its dates in UTC, so every visitor sees the same day. For a live changelog page example, our own [changelog](https://wingo-ui.com/changelog) works this way, with a page per version and an RSS feed. If you also run a blog, the [Next.js blog template post](https://wingo-ui.com/blog/nextjs-blog-template) covers a JSON Feed next to RSS.

## How do you wire the email signup?

`onSubscribe` gets the trimmed, already validated email. The form spins while the promise runs, shows the success line when it resolves and the error line when it rejects. That last part matters: a Server Action that returns `{ ok: false }` still resolves, so the visitor sees "You're in". Throw instead.

```ts
// app/(marketing)/changelog/actions.ts
"use server"

import { addSubscriber, sendConfirmation } from "@/lib/subscribers" // your database and mailer

export async function subscribeToChangelog(email: string) {
  if (!/^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/.test(email)) throw new Error("Invalid email")
  const { token, alreadyConfirmed } = await addSubscriber(email, { list: "changelog" })
  // double opt-in: nothing is sent to the list until they click the confirmation link
  if (!alreadyConfirmed) await sendConfirmation(email, token)
}
```

Use double opt-in and answer the same way for a new, pending or existing address, so the form cannot be used to check who is on your list. The default success line already says "Check your inbox to confirm", which matches that flow. When you publish a release, send one short email per release with the title, the summary and a link to its page. That makes the page per release the target of every email you send.

## How do you show what's new inside the app?

The public page reaches people who look for it. Users who are already signed in are easier to reach: put the last few releases inside the app, in a "What's new" panel or the help menu. A full changelog page is too heavy there; the [Timeline](https://wingo-ui.com/components/timeline) fits a side panel and reads the same data.

```tsx
// components/whats-new.tsx
"use client"

import { Timeline, type TimelineItemData } from "@/components/ui/timeline"
import { releases, releaseSlug } from "@/lib/changelog"

const items: TimelineItemData[] = releases.slice(0, 5).map((release) => ({
  id: releaseSlug(release.version),
  title: `${release.version}: ${release.title}`,
  description: release.summary,
  // noon UTC, so a release dated Nov 3 never shows as Nov 2 in New York
  time: `${release.date}T12:00:00Z`,
  tone: release.tone,
  href: `/changelog/${releaseSlug(release.version)}`,
}))

export function WhatsNew() {
  return <Timeline items={items} timeFormat="date" timePosition="side" locale="en-US" />
}
```

`timePosition="side"` puts the dates in a column on the start side once the timeline is 512px wide and folds them under the title in a narrow panel or on a phone. It is a container query, so it reacts to the panel's width. `timeFormat="date"` prints the day; the default `relative` ("3 days ago") suits activity feeds better than releases. Each title links to the release page.

## What are the common mistakes on a changelog page?

- **Commit messages as entries.** "fix tz offset in date util" means nothing to a customer; "Due dates no longer shift by a day across time zones" does. Write the symptom the user saw.
- **Bundling.** One change per line. A new integration and a billing fix are two entries, in two groups.
- **Long gaps.** A changelog whose newest entry is five months old tells a buyer the opposite of what you want. Ship small releases and write them up as you go.
- **Dead follow buttons.** The template's RSS button points at `/changelog/rss.xml` by default. Build the route or pass `rssHref=""` to hide it.
- **Sample content in production.** Search the rendered page for Acme before launch: the default title, the description, the newsletter count and, if you keep them, the template's own header and footer.

For the rest of the marketing site (pricing, contact, legal, about), the [SaaS website template guide](https://wingo-ui.com/blog/saas-website-template) covers each page and how they share one layout, and more step-by-step builds sit in the [Build it category](https://wingo-ui.com/blog/category/build). The support side of the same site, with search, topics and articles, is in our [help center template](https://wingo-ui.com/blog/help-center-template) guide.

## Where do you start?

Copy the data file and the routes above, then install the template with `npx wingo-ui@latest add changelog-page` and replace the sample releases with your last three. The template, the Timeline and the Newsletter Section are part of Wingo UI Pro, $8 a month, $80 a year or $150 once ([pricing](https://wingo-ui.com/pricing)); you can click through the [Changelog Page template](https://wingo-ui.com/templates/changelog-page) in all three designs (classic, minimal and editorial) before you decide.

## Components in this post

- [Changelog Page](https://wingo-ui.com/templates/changelog-page) (Pro): A React changelog page template: email and RSS subscribe, releases on a sticky version rail with drawn covers and New, Improved and Fixed groups. Install: `npx wingo-ui@latest add changelog-page`
- [Timeline](https://wingo-ui.com/components/timeline) (Pro): A React timeline component for activity feeds, audit trails, changelogs and order tracking, with icons, relative times and day groups. Install: `npx wingo-ui@latest add timeline`
- [Newsletter Section](https://wingo-ui.com/components/newsletter-section) (Pro): A React newsletter signup section for marketing pages: centered, split with the latest issue, a raised card or one compact row. Install: `npx wingo-ui@latest add newsletter-section`

## FAQ

### What should a SaaS changelog page include?

Releases newest first, each with a version, an ISO date, a short title, a one-sentence summary and its changes grouped by type (new, improved, fixed). Add a permalink per release, an RSS feed and an email signup so readers can follow it without remembering to check.

### What is the difference between a changelog and release notes?

A changelog is the running record of every release, in one place and one format. Release notes usually describe a single release in more depth, often as an email or a blog post that links back to its changelog entry.

### How do I add an RSS feed to a Next.js changelog?

Add a route handler at app/changelog/rss.xml/route.ts that reads the same releases array as the page and returns RSS 2.0 XML with the content type application/rss+xml. Mark it force-static and list it in the page metadata under alternates.types so feed readers find it.

### Should each changelog release have its own page?

Yes, once you have more releases than fit on the first screen. A static page per release gives RSS items, emails and social posts a link that always works, and each page can carry its own title and description for search.

### Is the Wingo UI Changelog Page template free?

No. The Changelog Page template, the Timeline and the Newsletter Section are Pro items, installed with npx wingo-ui@latest add once your account has Pro. The Marketing Section they build on is free.

---

Source: https://wingo-ui.com/blog/changelog-page-template
