Migrating my blog from static data to Notion as a CMS
For a while my blog posts lived as a hardcoded array in lib/data.ts. That worked fine when I had two posts, but it meant every new post required a code change and a redeploy. I wanted to write from anywhere without touching the codebase, so I moved everything over to Notion.
Why I switched
I already use Notion for tracking work and personal projects, so it made sense to write posts there too instead of adopting a separate CMS. The API is straightforward, and Notion's new markdown endpoints made the integration much simpler than I expected.
Setting up the Notion integration
Creating the database
Each blog post is a row in a Notion database, with properties for slug, status, published date, and excerpt. Using a database instead of a standalone page means I can query, filter, and sort posts programmatically — something a plain page can't do.
Getting API access
Notion integrations need to be explicitly connected to each database you want to query — having access to one database doesn't automatically grant access to another. This tripped me up early on: I kept getting 404s until I realized my integration wasn't connected to the Posts database specifically.
Fetching posts with the markdown API
Notion recently added a markdown API (GET /v1/pages/:id/markdown) that returns a page's content as ready-to-use markdown, instead of the older block-based API where you have to manually walk a tree of block objects and reconstruct HTML yourself.
export async function getBlogPostMarkdown(pageId: string): Promise<string> {
const res = await fetch(`https://api.notion.com/v1/pages/${pageId}/markdown`, {
headers: {
Authorization: `Bearer ${import.meta.env.NOTION_TOKEN}`,
"Notion-Version": "2026-03-11",
},
});
const data = await res.json();
return data.markdown;
}
That markdown then gets parsed into HTML with marked at request time.
Building the table of contents
Once posts were flowing in from Notion, I wanted a scroll-tracking table of contents — inspired by the one on Fuma Nama's blog, which uses a curved SVG line to connect heading positions.
Extracting headings
I extract headings while parsing the markdown, generating a URL-safe slug for each one so the same pass that renders the HTML also produces the data the table of contents needs:
renderer.heading = ({ tokens, depth }) => {
const plain = tokens.reduce((acc, t) => acc + (t.text ?? t.raw ?? ""), "");
const slug = slugify(plain);
headings.push({ depth, text: plain, slug });
return `<h${depth} id="${slug}">${plain}</h${depth}>`;
};
The curved SVG connector
Each heading maps to a point in an SVG path, connected with cubic Bézier curves (the C command) instead of straight lines. As the reader scrolls, an IntersectionObserver-style check determines which headings are currently visible, and a clipped copy of the same path is revealed to show progress.
What I'd do differently
If I started over, I'd build the table of contents as a smaller, standalone script from day one instead of iterating on it live — the layout math (measuring heading positions, syncing to scroll) is fiddly enough that it benefits from being tested in isolation before wiring it into a real page.