---
title: Add a changelog
navTitle: Changelog
description: Add a paginated changelog from Changesets, a CMS, or an API, with Show more, numbered archives, and Markdown output.
type: guide
summary: Connect collection or paged ChangelogSource data to five thin HTML, Markdown, and JSON adapters, preserving existing docs mappings.
prerequisites:
  - /docs/configuration
related:
  - /docs/proxy
  - /docs/agent-readiness
---

Publish release notes in batches with **Show more**, numbered archive pages, and matching Markdown output. Geistdocs provides the page UI; your app owns the source, caching, and route adapters.

In the Geistdocs repository, `/changelog` reads the package's released `CHANGELOG.md` with 10 entries per page. The standalone `apps/example-basic` app uses one entry per page to demonstrate pagination in English and Chinese. These examples are not enabled in newly scaffolded sites. The template's `(changelog)` route group and repo-only proxy mappings are excluded from the scaffold bundle.

## How consumers connect a changelog

Your app chooses where release data comes from. Geistdocs validates the entries and renders the shared page design, metadata, and Markdown output.

1. Create a server-side source that returns a collection or a requested page of releases, newest first.
2. Share the source, paths, and `pageSize` across the route factories.
3. Add the five thin adapters below for root HTML, numbered HTML, root Markdown, numbered Markdown, and JSON data.
4. Add Markdown proxy mappings and a navigation link.

Adding the page is the opt-in. There is no global enable flag, and the package does not create App Router files in an existing site.

## Read a Changesets changelog

Use `createChangesetsChangelogSource` from `@vercel/geistdocs/changelog` to read the durable `CHANGELOG.md` produced by Changesets. It does not read pending `.changeset/*.md` files or run the release process.

Create a server-side file that shares the source and options across all five adapters:

```ts title="lib/geistdocs/changelog.ts"
import { readFile } from "node:fs/promises";
import path from "node:path";
import { createChangesetsChangelogSource } from "@vercel/geistdocs/changelog";
import { config } from "@/lib/geistdocs/config";

const readChangelog = async () => {
  "use cache";

  return await readFile(path.join(process.cwd(), "CHANGELOG.md"), "utf8");
};

export const changelogOptions = {
  config,
  source: createChangesetsChangelogSource({ read: readChangelog }),
  path: "/changelog",
  markdownPath: "/changelog.md",
  pageSize: 10,
  title: "Changelog",
  description: "The latest releases, improvements, and fixes.",
};
```

This path assumes `CHANGELOG.md` is in the app root, where `process.cwd()` points when Next.js runs. Use a known, statically analyzable path so deployment file tracing can include the file. For a monorepo file outside the app root, use the correct relative path and configure deployment tracing bounds, including `outputFileTracingRoot`, if the deployed server needs to read it at runtime. A cache miss or refresh can still require the file.

The reader accepts a string or a promise of a string. The adapter recognizes level-two semantic version headings such as `## 1.2.0`, preserves file order and release Markdown, and skips `## Unreleased`. It returns version-only entries, which display as `v1.2.0`. The version becomes the entry ID, with `+` replaced by `_` for safe anchors. Changesets entries have no publication date; Geistdocs does not infer dates from Git history or file timestamps.

## Add the HTML page

Use the template's `getRootLang` helper with Cache Components:

```tsx title="app/[lang]/changelog/page.tsx"
import { createChangelogPage } from "@vercel/geistdocs/pages/changelog";
import { changelogOptions } from "@/lib/geistdocs/changelog";
import { getRootLang } from "@/lib/geistdocs/root-params";

const changelogPage = createChangelogPage({
  ...changelogOptions,
  getLang: getRootLang,
});

export const generateMetadata = changelogPage.generateMetadata;
export const generateStaticParams = changelogPage.generateStaticParams;
export default changelogPage.Page;
```

The root adapter uses `Page`, `generateMetadata`, and `generateStaticParams`. The page includes its own `<main>` element, so render it without another main wrapper. Without `getLang`, the helper reads `params.lang`; apps using root parameters should pass their server-side language getter.

`config` and `source` are required. `path` defaults to `/changelog`, `title` to `Changelog`, and `description` to `The latest releases, improvements, and fixes.` Set `pageSize` in the shared options; it defaults to `10` and accepts integers from `1` to `100`. Set `markdownPath` only when both Markdown adapters are installed. It enables the shared **Copy page** control, including **View as Markdown**, and alternate metadata; it does not create an endpoint. These actions use the current numbered page's Markdown endpoint and honor `config.pageActions`, matching the docs controls.

Keep `path`, `markdownPath`, and `dataPath` app-local, without a locale or deployment `basePath`. Geistdocs adds those prefixes when generating public links and canonical URLs; it does not strip prefixes you supply. Set `config.siteUrl` for absolute canonical URLs.

## Add numbered HTML pages

Use the same factory's archive exports for `/changelog/page/2` and later pages:

```tsx title="app/[lang]/changelog/page/[page]/page.tsx"
import { createChangelogPage } from "@vercel/geistdocs/pages/changelog";
import { changelogOptions } from "@/lib/geistdocs/changelog";
import { getRootLang } from "@/lib/geistdocs/root-params";

const changelogPage = createChangelogPage({
  ...changelogOptions,
  getLang: getRootLang,
});

export const generateMetadata = changelogPage.generatePaginatedMetadata;
export const generateStaticParams = changelogPage.generatePageParams;
export default changelogPage.PaginatedPage;
```

`PaginatedPage` renders only the requested batch, with **Show more** for older releases and **Newer releases** for the previous page. Archive metadata uses the numbered HTML canonical URL and its `.md` alternate. `/changelog/page/1` redirects to `/changelog`.

## Add the optional Markdown route

Reuse the shared options for the root Markdown endpoint:

```ts title="app/[lang]/changelog.md/route.ts"
import { createChangelogMarkdownRoute } from "@vercel/geistdocs/routes/changelog";
import { changelogOptions } from "@/lib/geistdocs/changelog";

const changelogRoute = createChangelogMarkdownRoute(changelogOptions);

export const GET = changelogRoute.GET;
export const generateStaticParams = changelogRoute.generateStaticParams;
```

The root route returns the first batch with anchor IDs and a **Next** Markdown link when older releases exist. Its HTTP `Link` header points to the root HTML canonical URL. `path` still identifies the HTML page, not the Markdown endpoint. Route Handlers use context `params` to resolve the language.

Add the numbered Markdown handler as a sibling route. The same `GET` reads the optional `params.page`:

```ts title="app/[lang]/changelog-pages.mdx/[page]/route.ts"
import { createChangelogMarkdownRoute } from "@vercel/geistdocs/routes/changelog";
import { changelogOptions } from "@/lib/geistdocs/changelog";

const changelogRoute = createChangelogMarkdownRoute(changelogOptions);

export const GET = changelogRoute.GET;
export const generateStaticParams = changelogRoute.generatePageParams;
```

The proxy maps `/changelog/page/2.md` to `/[lang]/changelog-pages.mdx/2` internally. This handler serves the second batch with **Previous** and, when applicable, **Next** links. Its canonical `Link` header points to `/changelog/page/2`, not the internal handler path.

Add this mapping to your existing `createProxy` options, preserving its hooks, tracking, and static matcher:

```ts title="proxy.ts"
import { createProxy } from "@vercel/geistdocs/proxy";
import { config as geistdocsConfig } from "@/lib/geistdocs/config";

const proxy = createProxy({
  config: geistdocsConfig,
  additionalMarkdownRoutes: [
    {
      from: "/changelog/page/*path",
      to: "/[lang]/changelog-pages.mdx/*path",
    },
    { from: "/changelog", to: "/[lang]/changelog.md" },
  ],
});

export const config = {
  matcher: [
    "/((?!api(?:/|$)|_next/static|_next/image|favicon.ico|sitemap.xml|robots.txt).*)",
  ],
};

export default proxy;
```

`additionalMarkdownRoutes` is prepended to inferred or default docs mappings, or to explicit `markdownRoutes`. The changelog mapping therefore wins before a root `/*path` catch-all without removing docs negotiation. Explicit `markdownRoutes` still replaces inferred mappings. Keep mapping paths app-local and use `[lang]` in the destination.

Put the numbered mapping before the root mapping. These mappings enable `.md`, `.mdx`, `Accept: text/markdown`, and detected-agent requests for root and numbered pages, including localized paths such as `/cn/changelog/page/2.md`. Restart `next dev` after adding routes so the app-route manifest is regenerated.

## Add the Show more data handler

**Show more** requires a JSON route that serves the next batch using the same source and `pageSize`:

```ts title="app/[lang]/changelog/data/[page]/route.ts"
import { createChangelogDataRoute } from "@vercel/geistdocs/routes/changelog";
import { changelogOptions } from "@/lib/geistdocs/changelog";

const changelogRoute = createChangelogDataRoute(changelogOptions);

export const GET = changelogRoute.GET;
export const generateStaticParams = changelogRoute.generateStaticParams;
```

The default `dataPath` is `${path}/data`, so this setup fetches `/changelog/data/2` on the first **Show more** activation. To use another endpoint, set `dataPath` in the shared options and place this handler at the matching route. Configuring `dataPath` alone does not create a handler.

The root is pre-rendered with only the first 10 entries by default. The browser receives one batch initially, not the entire history. With JavaScript, **Show more** fetches and appends the next JSON batch without navigating. It shows a loading state and allows retry after a failed request. Without JavaScript, the same normal `href` opens the next numbered HTML page. Readers can also open the link in another tab.

## Use a custom source

Replace the Changesets source with an object implementing either form of `ChangelogSource`. A source can read local files, a CMS, a database, GitHub releases, or an HTTP API. `getEntries` is a convenience for a full collection; use `getPage` for a provider that fetches one batch at a time. Keep the shared options and route adapters unchanged:

```ts title="lib/geistdocs/changelog.ts"
import type {
  ChangelogEntry,
  ChangelogSource,
} from "@vercel/geistdocs/changelog";
import { config } from "@/lib/geistdocs/config";

// biome-ignore lint/suspicious/useAwait: Cache Components requires an async reader, including for this local data.
const readEntries = async (lang: string): Promise<ChangelogEntry[]> => {
  "use cache";

  return [
    {
      id: "1.2.0",
      title: lang === "cn" ? "保存搜索筛选条件" : "Saved search filters",
      body: lang === "cn"
        ? "保存**搜索筛选条件**，下次访问时继续使用。"
        : "Save **search filters** for your next visit.",
      version: "1.2.0",
      publishedAt: "2026-09-08",
    },
  ];
};

const source: ChangelogSource = {
  getEntries: ({ lang }) => readEntries(lang),
};

export const changelogOptions = {
  config,
  source,
  path: "/changelog",
  markdownPath: "/changelog.md",
  pageSize: 10,
  title: "Changelog",
  description: "Updates to search and navigation.",
};
```

`getEntries({ lang })` accepts a configured language and returns `ChangelogEntry[]` or a promise of that array. Geistdocs reads and validates the full collection on the server, then selects the requested batch. Only that batch reaches the browser. The source owns translation and fallback policy; this example falls back to English. The Changesets adapter serves the same file for every locale. Unsupported route locales return not found.

Keep asynchronous data access in an app-owned `"use cache"` reader with `cacheComponents: true`. Do not add `dynamic`, `revalidate`, or `fetchCache` route exports. Keep source getters and credentials in server-side modules, not the shared `geistdocs.tsx` config, which is available to client components.

Each entry follows these constraints:

| Field | Requirement |
| --- | --- |
| `id` | Stable, unique within the source result, and starts with an ASCII letter or digit. Remaining characters can be ASCII letters, digits, `.`, `_`, or `-`. Reuse IDs across translations to preserve release links. |
| `title` | Optional text, preferred over `version`. Required when `version` is omitted. A blank title falls back to a provided version. |
| `body` | Markdown text, not executable MDX. The UI supports GitHub Flavored Markdown and disables raw HTML. |
| `version` | Optional nonempty text. Required when `title` is omitted or blank. |
| `publishedAt` | Optional valid ISO date (`2026-09-08`) or timestamp with a timezone. Omit unknown dates. |

Return entries newest first. Geistdocs preserves source order without sorting by date or version. Return `[]` when there are no releases; the page shows `No releases published yet.`, and Markdown contains only the page heading and configured description. Source failures and invalid entries propagate as errors rather than appearing as an empty changelog. An empty or title-only Changesets file returns no releases; unrecognized release content throws.

## Choose a title or version

Provide a title, a version, or both. When both are present, only the title appears as the release heading. Without a title, the heading uses the version with a `v` prefix; an existing `v` is not duplicated. HTML and Markdown use the same label.

```ts title="Release entry examples"
import type { ChangelogEntry } from "@vercel/geistdocs/changelog";

const entries = [
  {
    id: "saved-filters",
    title: "Saved search filters",
    version: "1.2.0",
    body: "Save **search filters** for your next visit.",
    publishedAt: "2026-09-08",
  },
  {
    id: "keyboard-navigation",
    title: "Keyboard navigation improvements",
    body: "Closing search returns focus to the search button.",
    publishedAt: "2026-09-04",
  },
  {
    id: "1.1.1",
    version: "1.1.1",
    body: "Fix navigation to nested documentation pages.",
  },
] satisfies ChangelogEntry[];
```

These headings are `Saved search filters`, `Keyboard navigation improvements`, and `v1.1.1`. TypeScript requires at least one of `title` or `version`, and runtime validation rejects entries where neither provides a label.

The release heading is also its permalink. Entries rendered on the current page use `#id`; an entry appended from page 2 links to `/changelog/page/2#id`, so reloading the link renders that batch. IDs remain stable when titles change, but numbered page membership can move as releases are published. These links are not permanent per-version URLs across publishing. A separate version badge or permalink button is not rendered.

## Clean Changesets release notes

The Changesets adapter removes bare generated commit prefixes, such as `f31d616:`, from Major, Minor, and Patch Changes lists. Existing PR and commit links, code examples, and authored prose remain intact. The HTML page, JSON batches, and Markdown routes use the same cleaned notes.

## Display publication dates

Set `publishedAt` when the source provides an authoritative publication date. Dates appear alongside entries on desktop and above their headings on mobile. Timestamps are formatted in UTC; date-only values retain their calendar date. Entries without a date omit it, and a wholly undated changelog has no date column.

To add dates to Changesets entries, wrap the Changesets source in your own `getEntries` implementation and join publication dates by version. For example, a consumer could combine local `CHANGELOG.md` bodies with GitHub release publication metadata. Omit dates that cannot be matched reliably.

## Read entries from an HTTP API

For a paged API, implement `getPage({ lang, page, pageSize })` and return `{ entries, total }` or a promise of that object. Geistdocs requests only the needed batch, without downloading earlier pages. `total` must be the exact number of releases matching the locale and any source filters, not an estimate or the current batch length.

Set the server-only `CHANGELOG_API_URL` environment variable to the endpoint. Set `CHANGELOG_API_TOKEN` if it requires a bearer token. This example validates an API response matching the paged source contract:

```ts title="lib/geistdocs/changelog.ts"
import {
  type ChangelogSource,
  validateChangelogEntries,
} from "@vercel/geistdocs/changelog";
import { config } from "@/lib/geistdocs/config";

const readPage = async (lang: string, page: number, pageSize: number) => {
  "use cache";

  const endpoint = process.env.CHANGELOG_API_URL;
  if (!endpoint) {
    throw new Error("Set CHANGELOG_API_URL to your release notes endpoint.");
  }

  const url = new URL(endpoint);
  url.searchParams.set("lang", lang);
  url.searchParams.set("page", String(page));
  url.searchParams.set("pageSize", String(pageSize));
  const token = process.env.CHANGELOG_API_TOKEN;
  const response = await fetch(url, {
    headers: token ? { Authorization: `Bearer ${token}` } : undefined,
  });
  if (!response.ok) {
    throw new Error(`Release notes request failed: ${response.status}`);
  }

  const result: unknown = await response.json();
  if (
    !result ||
    typeof result !== "object" ||
    !("entries" in result) ||
    !Array.isArray(result.entries) ||
    !("total" in result) ||
    typeof result.total !== "number" ||
    !Number.isSafeInteger(result.total) ||
    result.total < 0
  ) {
    throw new Error("Invalid release page response.");
  }

  const entries = validateChangelogEntries(result.entries);
  const remaining = Math.max(0, result.total - (page - 1) * pageSize);
  if (entries.length !== Math.min(pageSize, remaining)) {
    throw new Error("Release page length does not match its total.");
  }
  return { entries, total: result.total };
};

const source: ChangelogSource = {
  getPage: ({ lang, page, pageSize }) => readPage(lang, page, pageSize),
};

export const changelogOptions = {
  config,
  source,
  path: "/changelog",
  markdownPath: "/changelog.md",
  pageSize: 10,
  title: "Changelog",
  description: "The latest releases, improvements, and fixes.",
};
```

The query parameters are an example API convention, not a Geistdocs requirement. Adapt the request and response mapping to your provider. Each in-range page must contain exactly `Math.min(pageSize, total - (page - 1) * pageSize)` entries. Return `{ entries: [], total: 0 }` for an empty history. Use a consistent newest-first order across pages, with unique IDs across the history.

Consumers choose the source and cache policy. This cached reader keys data by locale, page, and page size; configure cache lifetime or invalidation to match your publishing workflow. Only normalized release data reaches the public HTML and JSON endpoints. Keep credentials in server-side modules, not entry bodies or client-visible configuration.

## Make the changelog discoverable

Add `{ label: "Changelog", href: "/changelog" }` to `nav` in `geistdocs.tsx`. For agent discovery, you can also add a resource link through [`agent.links`](/docs/agent-readiness).

Standalone changelog entries are not automatically included in docs search, `/llms.txt`, or sitemaps. The Markdown endpoint and discovery links expose the changelog separately from the documentation corpus.

## Configure archive generation and errors

The HTML and Markdown factories expose `generatePageParams` for numbered archives. Export it as Next.js `generateStaticParams` to prebuild archive pages, as shown above. It reads page 1's total for each configured locale to generate `{ lang, page }` values starting at page 2. Rendering those archives then reads their respective batches. Prebuilding is optional; consumers can choose on-demand archive rendering instead. The data factory's `generateStaticParams` also includes page 1.

Page parameters are positive decimal integers without leading zeroes. Invalid values such as `0`, `02`, or `abc`, unsupported locales, and pages beyond the total return not found. Markdown and JSON handlers return HTTP `404`; HTML uses Next.js not-found handling. Source failures, invalid totals, invalid entries, and mismatched in-range batch lengths propagate as errors rather than appearing as empty releases or a missing page.

Individual release routes and a changelog RSS feed are not included.

## Check the routes locally

Open the template's `/changelog` and confirm it initially renders 10 entries. Select **Show more** and confirm it renders 20 entries without leaving the page. Reload a newly appended entry's title link and confirm the entry appears on its numbered archive.

Open `/changelog/page/2` and `/changelog/page/2.md`. Both should contain only the second batch; HTML metadata and the Markdown canonical `Link` header should identify `/changelog/page/2`. Root `/changelog.md` should contain only the first batch and a **Next** link, while the second Markdown page has **Previous** and **Next** links when applicable. Disable JavaScript and confirm **Show more** navigates to page 2.

The basic example uses `pageSize: 1`, so its root shows the custom title and **Show more** appends `v1.1.1`. Repeat these checks under `/cn/changelog` for Chinese body text and localized canonical URLs. `/docs/changelog` remains the separate setup guide, and existing `/docs` Markdown negotiation should continue to work.
