SaaS Changelog System: Public Page, Versioned Releases, Email Subscribers, and RSS Feed
Build a SaaS changelog system with a public page, versioned release entries, email subscriber notifications, and RSS feed. Covers PostgreSQL schema, Next.js rendering, MDX content, and Resend email delivery.
A public changelog is one of the highest-ROI trust signals a SaaS can have. It tells prospects "this product is actively maintained" and tells customers "we heard your feedback." Most teams skip it because it feels like extra work โ but a well-built changelog system makes publishing a release as easy as writing a markdown file.
This guide builds the complete changelog: MDX content, versioned releases, subscriber email notifications, and RSS feed.
Content Strategy First
Before schema, decide your content model:
- Version-tagged releases:
v2.4.0โ good for developer tools - Date-based entries:
March 2027โ good for SaaS products users don't version-track - Category labels: New, Improved, Fixed, Deprecated โ helps users scan quickly
We'll support both version tags and date-based entries with category labels.
Database Schema
CREATE TYPE changelog_category AS ENUM ('new', 'improved', 'fixed', 'deprecated', 'security');
CREATE TYPE changelog_status AS ENUM ('draft', 'scheduled', 'published');
CREATE TABLE changelog_entries (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
title TEXT NOT NULL,
slug TEXT NOT NULL UNIQUE,
version TEXT, -- Optional: 'v2.4.0'
summary TEXT NOT NULL, -- One-line description for email/RSS
content_mdx TEXT NOT NULL, -- Full MDX body
categories changelog_category[] NOT NULL DEFAULT '{}',
status changelog_status NOT NULL DEFAULT 'draft',
published_at TIMESTAMPTZ,
scheduled_for TIMESTAMPTZ,
notify_sent BOOLEAN NOT NULL DEFAULT FALSE, -- Email notification sent?
notify_sent_at TIMESTAMPTZ,
image_url TEXT,
author_id UUID REFERENCES users(id),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_changelog_published ON changelog_entries(published_at DESC)
WHERE status = 'published';
CREATE INDEX idx_changelog_notify ON changelog_entries(notify_sent, published_at)
WHERE status = 'published' AND notify_sent = FALSE;
-- Email subscribers
CREATE TABLE changelog_subscribers (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email TEXT NOT NULL UNIQUE,
token TEXT NOT NULL UNIQUE DEFAULT encode(gen_random_bytes(32), 'hex'),
confirmed BOOLEAN NOT NULL DEFAULT FALSE,
confirmed_at TIMESTAMPTZ,
unsubscribed BOOLEAN NOT NULL DEFAULT FALSE,
unsubscribed_at TIMESTAMPTZ,
subscribed_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_subscribers_active ON changelog_subscribers(confirmed, unsubscribed)
WHERE confirmed = TRUE AND unsubscribed = FALSE;
๐ SaaS MVP in 8 Weeks โ Seriously
We have launched 50+ SaaS platforms. Multi-tenant architecture, Stripe billing, auth, role-based access, and cloud deployment โ all handled by one senior team.
- Week 1โ2: Architecture design + wireframes
- Week 3โ6: Core features built + tested
- Week 7โ8: Launch-ready on AWS/Vercel with CI/CD
- Post-launch: Maintenance plans from month 3
MDX Content Pipeline
// lib/changelog/mdx.ts
import { compileMDX } from "next-mdx-remote/rsc";
import { cache } from "react";
import rehypePrettyCode from "rehype-pretty-code";
import rehypeSlug from "rehype-slug";
import rehypeAutolinkHeadings from "rehype-autolink-headings";
import remarkGfm from "remark-gfm";
import { prisma } from "@/lib/prisma";
export interface ChangelogEntry {
id: string;
slug: string;
title: string;
version: string | null;
summary: string;
categories: string[];
publishedAt: Date;
imageUrl: string | null;
author: { name: string; avatar: string | null } | null;
}
export interface ChangelogEntryWithContent extends ChangelogEntry {
content: React.ReactNode;
}
export const getPublishedEntries = cache(
async (limit = 20, cursor?: string): Promise<ChangelogEntry[]> => {
const entries = await prisma.changelogEntry.findMany({
where: {
status: "published",
publishedAt: { lte: new Date() },
...(cursor ? { publishedAt: { lt: new Date(cursor) } } : {}),
},
orderBy: { publishedAt: "desc" },
take: limit,
include: {
author: { select: { name: true, avatarUrl: true } },
},
});
return entries.map((e) => ({
id: e.id,
slug: e.slug,
title: e.title,
version: e.version,
summary: e.summary,
categories: e.categories,
publishedAt: e.publishedAt!,
imageUrl: e.imageUrl,
author: e.author
? { name: e.author.name, avatar: e.author.avatarUrl }
: null,
}));
}
);
export const getEntryBySlug = cache(
async (slug: string): Promise<ChangelogEntryWithContent | null> => {
const entry = await prisma.changelogEntry.findUnique({
where: { slug, status: "published" },
include: { author: { select: { name: true, avatarUrl: true } } },
});
if (!entry || !entry.publishedAt) return null;
const { content } = await compileMDX({
source: entry.contentMdx,
options: {
mdxOptions: {
remarkPlugins: [remarkGfm],
rehypePlugins: [
[rehypePrettyCode, { theme: "github-dark" }],
rehypeSlug,
[rehypeAutolinkHeadings, { behavior: "append" }],
],
},
},
});
return {
id: entry.id,
slug: entry.slug,
title: entry.title,
version: entry.version,
summary: entry.summary,
categories: entry.categories,
publishedAt: entry.publishedAt,
imageUrl: entry.imageUrl,
author: entry.author
? { name: entry.author.name, avatar: entry.author.avatarUrl }
: null,
content,
};
}
);
Public Changelog Page
// app/changelog/page.tsx
import Link from "next/link";
import { getPublishedEntries } from "@/lib/changelog/mdx";
import { CategoryBadge } from "@/components/changelog/category-badge";
import { SubscribeForm } from "@/components/changelog/subscribe-form";
import { formatDistanceToNow } from "date-fns";
export const metadata = {
title: "Changelog | Acme",
description: "New features, improvements, and bug fixes in Acme.",
};
export const revalidate = 3600; // Revalidate hourly
export default async function ChangelogPage() {
const entries = await getPublishedEntries(50);
return (
<div className="max-w-3xl mx-auto px-4 py-16">
<div className="flex items-start justify-between mb-12">
<div>
<h1 className="text-3xl font-bold text-gray-900">Changelog</h1>
<p className="mt-2 text-gray-500">
New features, improvements, and fixes โ shipped regularly.
</p>
</div>
<div className="flex items-center gap-3">
<a
href="/changelog/rss.xml"
className="flex items-center gap-1.5 text-sm text-gray-500 hover:text-orange-500 transition-colors"
aria-label="RSS feed"
>
{/* RSS icon */}
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
<path d="M3.75 3a.75.75 0 00-.75.75v.5c0 .414.336.75.75.75H4c6.075 0 11 4.925 11 11v.25c0 .414.336.75.75.75h.5a.75.75 0 00.75-.75V16C17 8.82 11.18 3 4 3h-.25z" />
<path d="M3 8.75A.75.75 0 013.75 8H4a8 8 0 018 8v.25a.75.75 0 01-.75.75h-.5a.75.75 0 01-.75-.75V16a6 6 0 00-6-6h-.25A.75.75 0 013 9.25v-.5zM7 15a2 2 0 11-4 0 2 2 0 014 0z" />
</svg>
RSS
</a>
</div>
</div>
{/* Subscribe */}
<div className="mb-12 p-6 bg-blue-50 border border-blue-100 rounded-xl">
<p className="text-sm font-medium text-blue-900 mb-3">
Get notified when we ship something new
</p>
<SubscribeForm />
</div>
{/* Entries */}
<div className="space-y-12">
{entries.map((entry) => (
<article key={entry.id} className="flex gap-8">
{/* Date column */}
<div className="hidden sm:flex flex-col items-end gap-1 w-28 flex-shrink-0 pt-1">
<time
dateTime={entry.publishedAt.toISOString()}
className="text-sm text-gray-500 text-right"
>
{new Intl.DateTimeFormat("en-US", {
month: "short",
day: "numeric",
year: "numeric",
}).format(entry.publishedAt)}
</time>
{entry.version && (
<span className="text-xs font-mono text-gray-400">
{entry.version}
</span>
)}
</div>
{/* Timeline line */}
<div className="hidden sm:flex flex-col items-center">
<div className="w-2.5 h-2.5 rounded-full bg-blue-500 mt-1.5 ring-4 ring-blue-100 flex-shrink-0" />
<div className="w-px flex-1 bg-gray-200 mt-2" />
</div>
{/* Content */}
<div className="flex-1 pb-4">
<div className="flex flex-wrap gap-1.5 mb-2">
{entry.categories.map((cat) => (
<CategoryBadge key={cat} category={cat as any} />
))}
</div>
<Link href={`/changelog/${entry.slug}`}>
<h2 className="text-xl font-semibold text-gray-900 hover:text-blue-600 transition-colors">
{entry.title}
</h2>
</Link>
<p className="mt-2 text-gray-600 text-sm leading-relaxed">
{entry.summary}
</p>
<Link
href={`/changelog/${entry.slug}`}
className="mt-3 inline-block text-sm text-blue-600 hover:underline"
>
Read more โ
</Link>
</div>
</article>
))}
</div>
</div>
);
}
๐ก The Difference Between a SaaS Demo and a SaaS Business
Anyone can build a demo. We build SaaS products that handle real load, real users, and real payments โ with architecture that does not need to be rewritten at 1,000 users.
- Multi-tenant PostgreSQL with row-level security
- Stripe subscriptions, usage billing, annual plans
- SOC2-ready infrastructure from day one
- We own zero equity โ you own everything
Category Badge Component
// components/changelog/category-badge.tsx
import { cn } from "@/lib/cn";
type Category = "new" | "improved" | "fixed" | "deprecated" | "security";
const CATEGORY_CONFIG: Record<Category, { label: string; className: string }> = {
new: { label: "New", className: "bg-green-100 text-green-700 border-green-200" },
improved: { label: "Improved", className: "bg-blue-100 text-blue-700 border-blue-200" },
fixed: { label: "Fixed", className: "bg-orange-100 text-orange-700 border-orange-200" },
deprecated: { label: "Deprecated", className: "bg-gray-100 text-gray-600 border-gray-200" },
security: { label: "Security", className: "bg-red-100 text-red-700 border-red-200" },
};
export function CategoryBadge({ category }: { category: Category }) {
const config = CATEGORY_CONFIG[category] ?? CATEGORY_CONFIG.improved;
return (
<span
className={cn(
"inline-flex items-center px-2 py-0.5 rounded-md text-xs font-medium border",
config.className
)}
>
{config.label}
</span>
);
}
RSS Feed Route
// app/changelog/rss.xml/route.ts
import { NextResponse } from "next/server";
import { getPublishedEntries } from "@/lib/changelog/mdx";
const APP_URL = process.env.NEXT_PUBLIC_APP_URL!;
const SITE_NAME = "Acme Changelog";
export async function GET() {
const entries = await getPublishedEntries(50);
const rss = `<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
<channel>
<title>${escapeXml(SITE_NAME)}</title>
<link>${APP_URL}/changelog</link>
<description>New features, improvements, and fixes from Acme.</description>
<language>en</language>
<lastBuildDate>${entries[0]?.publishedAt.toUTCString() ?? new Date().toUTCString()}</lastBuildDate>
<atom:link href="${APP_URL}/changelog/rss.xml" rel="self" type="application/rss+xml" />
${entries
.map(
(e) => `
<item>
<title>${escapeXml(e.title)}</title>
<link>${APP_URL}/changelog/${e.slug}</link>
<guid isPermaLink="true">${APP_URL}/changelog/${e.slug}</guid>
<description>${escapeXml(e.summary)}</description>
<pubDate>${e.publishedAt.toUTCString()}</pubDate>
${e.categories.map((c) => `<category>${escapeXml(c)}</category>`).join("\n ")}
</item>`
)
.join("")}
</channel>
</rss>`;
return new NextResponse(rss, {
headers: {
"Content-Type": "application/xml; charset=utf-8",
"Cache-Control": "public, max-age=3600, s-maxage=3600",
},
});
}
function escapeXml(str: string): string {
return str
.replace(/&/g, "&")
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/"/g, """)
.replace(/'/g, "'");
}
Subscribe API
// app/api/changelog/subscribe/route.ts
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { resend } from "@/lib/resend";
import { z } from "zod";
const SubscribeSchema = z.object({
email: z.string().email(),
});
export async function POST(req: NextRequest) {
const body = await req.json();
const parsed = SubscribeSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json({ error: "Invalid email" }, { status: 400 });
}
const { email } = parsed.data;
// Upsert subscriber
const subscriber = await prisma.changelogSubscriber.upsert({
where: { email },
create: { email },
update: { unsubscribed: false, unsubscribedAt: null },
});
if (subscriber.confirmed) {
return NextResponse.json({ message: "Already subscribed" });
}
// Send confirmation email
const confirmUrl = `${process.env.NEXT_PUBLIC_APP_URL}/changelog/confirm?token=${subscriber.token}`;
await resend.emails.send({
from: "Acme <changelog@acme.com>",
to: email,
subject: "Confirm your changelog subscription",
html: `
<p>Click the link below to confirm your subscription to the Acme changelog:</p>
<p><a href="${confirmUrl}">${confirmUrl}</a></p>
<p>If you didn't subscribe, you can safely ignore this email.</p>
`,
});
return NextResponse.json({ message: "Check your email to confirm" });
}
// app/api/changelog/unsubscribe/route.ts
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
export async function GET(req: NextRequest) {
const token = req.nextUrl.searchParams.get("token");
if (!token) {
return NextResponse.json({ error: "Missing token" }, { status: 400 });
}
await prisma.changelogSubscriber.updateMany({
where: { token },
data: { unsubscribed: true, unsubscribedAt: new Date() },
});
return NextResponse.redirect(
new URL("/changelog?unsubscribed=1", req.url)
);
}
Email Notification on Publish
// lib/changelog/notify.ts
import { prisma } from "@/lib/prisma";
import { resend } from "@/lib/resend";
import { CategoryBadge } from "@/components/changelog/category-badge";
export async function notifySubscribers(entryId: string): Promise<number> {
const entry = await prisma.changelogEntry.findUnique({
where: { id: entryId },
});
if (!entry || entry.notifySent) return 0;
const subscribers = await prisma.changelogSubscriber.findMany({
where: { confirmed: true, unsubscribed: false },
select: { email: true, token: true },
});
if (!subscribers.length) return 0;
const APP_URL = process.env.NEXT_PUBLIC_APP_URL!;
const entryUrl = `${APP_URL}/changelog/${entry.slug}`;
// Send in batches of 50 (Resend batch limit)
const BATCH_SIZE = 50;
let sent = 0;
for (let i = 0; i < subscribers.length; i += BATCH_SIZE) {
const batch = subscribers.slice(i, i + BATCH_SIZE);
await resend.batch.send(
batch.map((sub) => ({
from: "Acme <changelog@acme.com>",
to: sub.email,
subject: `${entry.version ? `[${entry.version}] ` : ""}${entry.title}`,
html: buildEmailHtml({
title: entry.title,
version: entry.version ?? undefined,
summary: entry.summary,
categories: entry.categories,
entryUrl,
unsubscribeUrl: `${APP_URL}/api/changelog/unsubscribe?token=${sub.token}`,
}),
}))
);
sent += batch.length;
}
// Mark as sent
await prisma.changelogEntry.update({
where: { id: entryId },
data: { notifySent: true, notifySentAt: new Date() },
});
return sent;
}
function buildEmailHtml(params: {
title: string;
version?: string;
summary: string;
categories: string[];
entryUrl: string;
unsubscribeUrl: string;
}): string {
const CATEGORY_COLORS: Record<string, string> = {
new: "#16a34a", improved: "#2563eb",
fixed: "#ea580c", deprecated: "#6b7280", security: "#dc2626",
};
const badges = params.categories
.map(
(c) =>
`<span style="display:inline-block;padding:2px 8px;border-radius:4px;font-size:12px;font-weight:500;color:${CATEGORY_COLORS[c] ?? "#2563eb"};border:1px solid ${CATEGORY_COLORS[c] ?? "#2563eb"}20;background:${CATEGORY_COLORS[c] ?? "#2563eb"}10;margin-right:4px">${c.charAt(0).toUpperCase() + c.slice(1)}</span>`
)
.join("");
return `
<!DOCTYPE html>
<html>
<body style="font-family:system-ui,sans-serif;max-width:600px;margin:0 auto;padding:24px;color:#111827">
<div style="margin-bottom:16px">${badges}</div>
<h1 style="font-size:20px;font-weight:700;margin:0 0 4px">
${params.title}${params.version ? ` <span style="font-size:14px;color:#6b7280;font-weight:400">${params.version}</span>` : ""}
</h1>
<p style="color:#374151;margin:12px 0 20px;line-height:1.6">${params.summary}</p>
<a href="${params.entryUrl}" style="display:inline-block;background:#2563eb;color:white;padding:10px 20px;border-radius:6px;text-decoration:none;font-weight:500;font-size:14px">
Read full changelog โ
</a>
<hr style="border:none;border-top:1px solid #e5e7eb;margin:24px 0">
<p style="font-size:12px;color:#9ca3af">
You're receiving this because you subscribed to Acme's changelog.
<a href="${params.unsubscribeUrl}" style="color:#6b7280">Unsubscribe</a>
</p>
</body>
</html>`;
}
Admin: Publish + Notify API
// app/api/admin/changelog/[id]/publish/route.ts
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/auth";
import { prisma } from "@/lib/prisma";
import { notifySubscribers } from "@/lib/changelog/notify";
import { revalidatePath, revalidateTag } from "next/cache";
export async function POST(
req: NextRequest,
{ params }: { params: { id: string } }
) {
const session = await auth();
if (session?.user?.role !== "admin") {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const { notify = true } = await req.json();
const entry = await prisma.changelogEntry.update({
where: { id: params.id },
data: { status: "published", publishedAt: new Date() },
});
// Revalidate changelog pages
revalidatePath("/changelog");
revalidatePath(`/changelog/${entry.slug}`);
revalidateTag("changelog");
let notified = 0;
if (notify) {
notified = await notifySubscribers(entry.id);
}
return NextResponse.json({ published: true, subscribersNotified: notified });
}
Cost and Timeline Estimates
| Scope | Team | Timeline | Cost Range |
|---|---|---|---|
| Basic changelog page (static MDX) | 1 dev | 1โ2 days | $300โ600 |
| Full system (DB + subscribe + RSS) | 1 dev | 1 week | $1,500โ3,000 |
| + Admin CMS + scheduled publishing | 1โ2 devs | 2โ3 weeks | $4,000โ8,000 |
| Email notification: Resend costs | ~$0 under 3K/mo, $20/mo for 50K | โ | โ |
See Also
- SaaS In-App Notifications with SSE
- SaaS Email Sequences and Drip Campaigns
- Next.js Static Generation with ISR
- Next.js Draft Mode for CMS Previews
- SaaS Activity Feed Architecture
Working With Viprasol
A changelog that nobody reads is a missed opportunity. We build changelog systems that get subscribers, render beautifully, and make it frictionless for your team to publish โ MDX for rich content, email notifications via Resend, RSS for power users, and an admin interface that non-engineers can use.
What we deliver:
- PostgreSQL schema for entries, categories, and subscribers
- MDX rendering with syntax highlighting and heading anchors
- Public changelog page with timeline layout
- Double opt-in email subscription with Resend
- RSS feed with proper XML escaping
- Admin publish-and-notify workflow
Talk to our team about your changelog system โ
Or explore our SaaS development services.
About the Author
Viprasol Tech Team
Custom Software Development Specialists
The Viprasol Tech team specialises in algorithmic trading software, AI agent systems, and SaaS development. With 100+ projects delivered across MT4/MT5 EAs, fintech platforms, and production AI systems, the team brings deep technical experience to every engagement. Based in India, serving clients globally.
Building a SaaS Product?
We've helped launch 50+ SaaS platforms. Let's build yours โ fast.
Free consultation โข No commitment โข Response within 24 hours
Add AI automation to your SaaS product?
Viprasol builds custom AI agent crews that plug into any SaaS workflow โ automating repetitive tasks, qualifying leads, and responding across every channel your customers use.