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 acmeWhat 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 releaseHow 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 skippedindex.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 startThe 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
frontend-landing— the changelog shares the marketing site's brand.
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.
useChangelogBadgebelongs in the product app whose users you want to notify, pointed at this site's feed — not on the changelog itself.