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.