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
March 27, 2027
12 min read

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, "&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.

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 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.

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.