Changelog

A changelog / release-notes site — MDX releases rendered to a list and per-release pages with highlighted code, plus an RSS feed, powered by @voltro/changelog.

A changelog / release-notes site. Write each release as an .mdx file; the template renders a reverse-chronological list, one page per release with syntax-highlighted code, and an RSS feed. Powered by @voltro/changelog. Template id: changelog.

Scaffold

voltro create-project myapp --web=changelog
# or
voltro add-app changelog --template=changelog --to acme

What ships

apps/acme/changelog/                    # dir named by the app, not the template
├── app.config.ts                       # type:web, port:<allocated>
├── package.json                        # build = generate RSS, then voltro build
├── tsconfig.json
├── content/
│   └── releases/
│       ├── 0.1.0.mdx                   # one file per release (frontmatter + markdown)
│       └── 0.2.0.mdx
├── scripts/
│   └── generate-rss.mjs                # writes public/rss.xml at build time
└── src/
    ├── globals.css
    ├── globals.d.ts
    ├── lib/
    │   └── releases.ts                 # loadReleases over the MDX glob
    └── pages/
        ├── layout.tsx                  # BlogLayout shell
        ├── index.tsx                   # the release list
        └── [slug].tsx                  # one page per release

How it works

A release is an .mdx file with frontmatter. version and releasedAt are required; everything else is optional:

---
version: 0.2.0
releasedAt: 2026-06-21
slug: v0-2-0          # URL segment; defaults to a slugified version
title: Typed env + faster boot
summary: Declare your environment once; it's validated at startup.
tags: [feature, performance]   # feature | fix | breaking | security | performance
---

## What changed

Markdown body — code fences are highlighted at build time.

src/lib/releases.ts reads every file via Vite's raw glob and turns them into typed, sorted records:

import { loadReleases } from '@voltro/changelog'

const sources = import.meta.glob('../../content/releases/*.mdx', {
  query: '?raw', import: 'default', eager: true,
}) as Record<string, string>

export const releases = loadReleases(sources)   // newest first, invalid files skipped

index.tsx maps releases to the list. [slug].tsx enumerates the release slugs with getStaticPaths so voltro build pre-renders one HTML page per release, and its loader calls highlightRelease — at build time (Node) that runs shiki over the code fences; in the browser it's a no-op and the plain markdown HTML is used.

Both pages are renderMode: 'static' + interactive: 'none', so the output is pure content HTML with no JS bundle.

RSS feed

pnpm build runs scripts/generate-rss.mjs (which calls renderReleaseRss) before the static build, emitting public/rss.xml → served at /rss.xml. Set SITE_URL so the feed's links are absolute:

SITE_URL=https://changelog.example.com pnpm build && pnpm start

The feed is generated as a static file rather than a route because the build pre-renders HTML pages — a feed is a non-HTML artifact.

"What's new" badge in another app

The browser-only @voltro/changelog/web subpath ships useChangelogBadge, which polls this site's rss.xml and tells a product app's nav when there's an unread release. Add it to that app (not this one):

import { useChangelogBadge } from '@voltro/changelog/web'

const { hasUnread, latest, markRead } = useChangelogBadge({
  url: 'https://changelog.example.com/rss.xml',
})
// render a dot when `hasUnread`; call `markRead()` when the user opens it.

Add a release

Drop a new .mdx file in content/releases/. Files missing version / releasedAt are skipped; the list re-sorts newest-first automatically and voltro build emits one more page.

Pairs well with

Anti-patterns

  • Rendering markdown client-side. The template renders at build / loader time so content reaches React as pre-rendered HTML — faster, and indexable.
  • Putting the badge on the changelog site. useChangelogBadge belongs in the product app whose users you want to notify, pointed at this site's feed — not on the changelog itself.