Migrate Your Changelog from Notion, Confluence, or Markdown to a Hosted Page
An operational playbook for moving your changelog out of Notion, Confluence, CHANGELOG.md, Google Docs, or GitHub Releases — schema mapping, redirects, image migration, and a QA checklist.
Table of Contents
- TL;DR
- Signs you've outgrown your current setup
- The target schema (build this first)
- Source-by-source migration playbook
- Migrating from Notion
- Migrating from Confluence
- Migrating from CHANGELOG.md (Keep a Changelog)
- [1.4.0] - 2026-03-12
- Added
- Fixed
- Migrating from Google Docs
- Migrating from GitHub Releases
- Permalink preservation and 301 redirects
- Image and asset migration
- Post-migration QA checklist
- Rollout: silent vs announced
- FAQ
There's a specific moment every product team hits. The Notion page that started as "we'll just dump release notes here" is now four screens of nested toggles, nobody can find anything via Cmd-F, marketing wants an RSS feed, support wants an in-app widget, and someone in Berlin is asking why there's no German version. That moment is when your changelog has outgrown its home.
This guide is the operational manual for the move. Not why to migrate — you've already decided. How to migrate, source by source, without losing permalinks, breaking SEO, or spending three weeks copy-pasting. We'll cover Notion, Confluence, raw CHANGELOG.md, Google Docs, and GitHub Releases, with the schema mapping, redirect strategy, and post-migration QA that separates a clean cutover from a rollback.
TL;DR
- Audit first. Count entries, dates, images, and inbound links before you touch anything.
- Pick a target schema. Title, version, date, body (Markdown), tags, author, slug. Everything maps to those seven fields.
- Parse, don't retype. Each source has an export path. Notion gives you Markdown + CSV. Confluence has a REST API. GitHub Releases has GraphQL. CHANGELOG.md is already structured if it follows Keep a Changelog.
- Preserve permalinks. Map every old URL to a new one with 301 redirects. This is the single SEO-critical step.
- Migrate images to a CDN. Don't leave them on Notion's S3 — the signed URLs expire.
- Cutover quietly. Soft-launch, run QA, then announce. Reverse is hard once you've redirected DNS.
Signs you've outgrown your current setup
If three or more of these are true, migrate now:
- Users can't find a specific update without scrolling.
- You can't embed an in-app widget that pulls from your changelog.
- There's no RSS feed, so no Zapier, no Slack auto-post, no IFTTT.
- You have zero analytics on which entries get read.
- Multi-language is impossible or requires duplicate pages.
- Your changelog URL is
notion.so/yourcompany/...— not on your domain. - Engineering is the bottleneck for every publish.
- You can't schedule entries to go live at a specific time.
If you're still evaluating destinations, our best changelog tools comparison covers the realistic options. For release-notes-specific tooling, see best release notes tools.
The target schema (build this first)
Before you parse anything, agree on the destination shape. Every changelog entry, regardless of source, should map to:
| Field | Type | Notes |
|---|---|---|
| title | string | Human-readable, 50–80 chars |
| version | string | Optional, semver if you use it |
| published_at | ISO 8601 datetime | UTC, not local time |
| body | Markdown | Sanitized, images rewritten to CDN |
| tags | string[] | feature, fix, breaking, etc. |
| author | string or object | Who shipped it |
| slug | string | Stable identifier — drives permalink |
The slug is the most important field. It's what survives across the move. Slugs should be derived from the original title or ID, not regenerated, so old URLs stay reachable via redirects.
If your destination auto-creates slugs from titles at import time, you'll get new-billing-portal-2 collisions and broken redirects. Pre-compute slugs in the export step and import them as-is.
Source-by-source migration playbook
Migrating from Notion
Notion is where most changelogs start. The export is decent but the asset story is painful.
Export path: Workspace settings → Settings & members → Export content → Markdown & CSV. You get a zip with one .md per page and an images/ folder.
A minimal parser:
import { readFileSync, readdirSync } from "fs";
import { parse } from "csv-parse/sync";
import matter from "gray-matter";
import slugify from "slugify";
interface Entry {
slug: string;
title: string;
publishedAt: string;
tags: string[];
body: string;
}
export function parseNotionExport(dir: string): Entry[] {
const csv = parse(readFileSync(`${dir}/index.csv`, "utf8"), {
columns: true,
skip_empty_lines: true,
});
return csv.map((row: Record<string, string>): Entry => {
const filename = `${row.Name} ${row.id}.md`;
const raw = readFileSync(`${dir}/${filename}`, "utf8");
const { content } = matter(raw);
return {
slug: slugify(row.Name, { lower: true, strict: true }),
title: row.Name,
publishedAt: new Date(row.Date).toISOString(),
tags: row.Tags ? row.Tags.split(",").map((t) => t.trim()) : [],
body: rewriteImagePaths(content),
};
});
}
rewriteImagePaths is where you swap Notion's expiring S3 URLs for your own CDN — see the asset section below.
Migrating from Confluence
Confluence has two viable paths: the Cloud REST API (clean, programmatic) or a Space export (XML, ugly, but offline).
API path:
curl -u email:api_token \
"https://your.atlassian.net/wiki/rest/api/content?spaceKey=CHANGELOG&expand=body.storage,version,metadata.labels&limit=200"
You get JSON with body.storage.value containing Confluence's XHTML storage format. Convert it to Markdown with turndown plus a custom rule for Confluence macros (<ac:structured-macro> etc.) that you'll otherwise lose.
Migrating from CHANGELOG.md (Keep a Changelog)
If your project follows the Keep a Changelog convention, you have the easiest migration. The structure is already a schema:
## [1.4.0] - 2026-03-12
### Added
- New billing portal
### Fixed
- Webhook retry loop
Parse with keep-a-changelog (npm) or any AST-based Markdown parser. Each ## [version] - date becomes one entry. The ### Added/Changed/Fixed/Removed headings become tags. Done.
If your CHANGELOG.md is freeform, normalize it manually first. Twenty minutes of cleanup beats a parser that handles every edge case.
Migrating from Google Docs
Google Docs is the worst source — it's where changelogs go to die. There's no clean export.
The least-bad path: File → Download → Markdown (.md). Google's converter is mediocre but workable. Run it through pandoc for a second pass:
pandoc input.md -f gfm -t commonmark_x -o output.md --wrap=none
Then split on H1 or H2 boundaries depending on your doc structure. Expect to hand-fix 10–20% of entries.
Migrating from GitHub Releases
If you tag releases and write release notes on GitHub, the GraphQL API is your friend:
query Releases($owner: String!, $name: String!, $cursor: String) {
repository(owner: $owner, name: $name) {
releases(first: 100, after: $cursor, orderBy: { field: CREATED_AT, direction: DESC }) {
pageInfo { hasNextPage endCursor }
nodes {
tagName
name
publishedAt
descriptionHTML
description
isPrerelease
author { login }
}
}
}
}
description is already Markdown. tagName becomes your version. publishedAt is already ISO 8601. This is the cleanest migration of all five — usually a single afternoon.
Permalink preservation and 301 redirects
This is the section most teams skip and most teams regret.
If your old changelog has any inbound links — from your own docs, from customer emails, from Google's index, from Slack threads — those URLs need to keep working. The mechanism is a permanent 301 redirect from old URL to new URL.
For a Next.js destination, the redirect map lives in next.config.js:
/** @type {import('next').NextConfig} */
module.exports = {
async redirects() {
return [
{
source: "/notion/changelog/:slug",
destination: "/changelog/:slug",
permanent: true,
},
{
source: "/wiki/spaces/CHANGELOG/pages/:id/:slug",
destination: "/changelog/:slug",
permanent: true,
},
{
source: "/releases/tag/:tag",
destination: "/changelog/v/:tag",
permanent: true,
},
];
},
};
For a static destination on Vercel or Netlify, use _redirects or vercel.json. For Cloudflare in front of any host, Workers or Bulk Redirects.
Build the redirect map at export time, not after. Your parser already knows the old URL and the new slug — emit a CSV alongside the entries.
A 301 is permanent. Don't garbage-collect the redirect map after six months — Google will recrawl old URLs years later. Treat the map as part of your infrastructure, not a migration artifact.
Image and asset migration
Every source we covered has the same image problem: the original URLs won't survive. Notion S3 URLs expire. Confluence attachments require auth. Google Docs serves images from googleusercontent.com with rate limits. GitHub release assets live forever but rate-limit aggressively.
The fix is the same in every case:
- During parse, find every
and every<img src=...>. - Download the asset with the right credentials.
- Upload to your own object storage (S3, R2, Supabase Storage, etc.).
- Rewrite the body's references to the new CDN URL.
- Keep the alt text — it's your only accessibility signal.
Budget a few minutes for this even on a small migration. A 200-entry Notion changelog can have 1,500+ images.
Post-migration QA checklist
Run all of these before announcing:
- [ ] Entry count matches:
old_source_count == new_destination_count - [ ] Date range matches (oldest and newest entry).
- [ ] Spot-check 10 random entries: title, body, images, tags, date.
- [ ] All redirects return 301 (not 302, not 200, not 404). Test with
curl -I. - [ ] RSS feed validates against W3C feed validator.
- [ ] Sitemap includes every entry.
- [ ] OG images render for the 10 most-shared entries.
- [ ] Search inside the new tool returns expected results for 5 known queries.
- [ ] In-app widget loads on staging.
- [ ] Email digest preview renders without broken images.
- [ ] Keep-a-Changelog format is preserved if you used it (Added/Changed/Fixed sections still parse).
- [ ] Analytics fire on entry view.
If any of these fail, fix before cutover. The cost of a clean migration is one day of QA. The cost of a broken migration is six months of "we lost half our SEO traffic."
Rollout: silent vs announced
Two viable strategies:
Silent rollout — flip DNS, let redirects do their job, don't tell anyone for a week. Lower risk. You catch issues without a spotlight on them. Recommended for any changelog with meaningful traffic.
Announced rollout — publish a "we moved our changelog" post on day one, link from your homepage, send a digest. Higher engagement, but every issue becomes a public issue. Only do this if the migration also unlocks a user-visible feature (subscriptions, search, RSS) worth announcing.
Either way, automate from day one going forward. Manual migration was a one-time cost; manual publishing is a recurring tax. See our guide on automated release notes for the next step.
FAQ
The migration itself is one-time pain. The payoff is every week after — automated publishing, analytics that mean something, an in-app widget that actually drives feature adoption, and a changelog that lives on your domain instead of someone else's. Worth the day or two of parsing.
Related articles
12 Best Changelog Tools for SaaS in 2026 — Compared & Ranked
Compare the best changelog management tools for SaaS: features, pricing, real pros/cons. Find the perfect tool to keep users informed.
7 Best Release Notes Tools for SaaS Teams in 2026 (Compared)
Compare the best release notes tools for SaaS: pricing, AI, integrations, honest pros/cons. Find the right tool to publish notes users read.
Keep a Changelog Format Explained: The 2026 SaaS Adaptation Guide
Keep a Changelog is the de-facto developer standard. Here's how to adapt it for your B2B SaaS — categories, semver, conventional commits, and automation.
Automated Release Notes: Complete Guide for Product Teams
Automate release notes from commits, PRs, and tickets. Save hours per sprint while keeping users informed. Tools, templates, and best practices.