Back to Blog

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.

Viprasol Tech Team
12 min read
Updated 2027

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>
  );
}
SaaS - SaaS Changelog System: Public Page, Versioned Releases, Email Subscribers, and RSS Feed

💡 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, "&amp;")
    .replace(/</g, "&lt;")
    .replace(/>/g, "&gt;")
    .replace(/"/g, "&quot;")
    .replace(/'/g, "&apos;");
}

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

ScopeTeamTimelineCost Range
Basic changelog page (static MDX)1 dev1–2 days$300–600
Full system (DB + subscribe + RSS)1 dev1 week$1,500–3,000
+ Admin CMS + scheduled publishing1–2 devs2–3 weeks$4,000–8,000
Email notification: Resend costs~$0 under 3K/mo, $20/mo for 50K

See Also


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.

SaaSChangelogNext.jsMDXRSSEmailTypeScriptPostgreSQL
Share this article:

About the Author

V

Viprasol Tech Team

Custom Software Development Specialists

The Viprasol Tech team specialises in algorithmic trading software, AI agent systems, and SaaS development. With 1000+ projects delivered across MT4/MT5 EAs, fintech platforms, and production AI systems, the team brings deep technical experience to every engagement.

MT4/MT5 EA DevelopmentAI Agent SystemsSaaS DevelopmentAlgorithmic Trading

Building a SaaS Product?

We've helped launch 50+ SaaS platforms. Let's build yours — fast.

Free consultation • No commitment • Response within 24 hours

Viprasol · AI Agent Systems

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.