← Harsh Dodiya

Building a Notion-powered website

Notion page being used as a CMS for a website

I've been living inside Notion for close to a year now. Notes, calendar, task tracking, all of it. Somewhere along the way I noticed it wasn't just a notes app: it has a proper database engine sitting under the same interface. Rows, properties, filters, relations. That's when the idea started forming: what if a Notion page didn't have to stay inside Notion?

I went looking for how to actually do this: fetch a Notion workspace and render it as a real website, and found the documentation thin. There's an API, but very little written about actually integrating and shaping it into something production-ready. What I did find were several paid website builders doing exactly this: pointing at your Notion database and generating a hosted site from it, sold as a product. That's what tipped it from curiosity into “I should just build this myself.”

The stack

Framework
Next.js 14. App Router, so server and client rendering live in the same tree without a separate API layer.
Language
TypeScript. Notion's blocks are deeply nested and loosely typed; without types this turns into a pile of undefined.map() bugs fast.
Rendering
ISR. Static by default, regenerated in the background, never a full rebuild for one edit.
Source of truth
A Notion workspace. No separate CMS, no admin panel to maintain.

How content gets from Notion to the page

Step 01
Fetch the page and its blocks

Query the API for a page by ID, then recursively pull its children: paragraphs, headings, images, toggles, nested lists. The block tree goes arbitrarily deep, so this can't be a flat fetch.

Step 02
Map blocks to components

Each block type gets its own React component. This is where most of the iteration went: getting Notion's schema to look like my site instead of a Notion export.

Step 03
Cache the render, not just the data

Fetching on every request would be slow and would hammer Notion's rate limits. This is where the harder problem started.

Next.js wants to generate pages statically at build time for speed. Notion content changes constantly. That's the entire point of using it as a CMS. Those two facts are in direct tension.

A fully static site goes stale the moment I edit a page in Notion. Rebuilding the whole site for a typo fix felt wrong too. The fix is Incremental Static Regeneration (ISR). A page stays static, but regenerates itself in the background on a timer, or on demand.

app/[slug]/page.tsxts
export const revalidate = 60 // seconds, regenerate in the background at most this often

export default async function Page({ params }) {
  const page = await getNotionPage(params.slug)
  return renderBlocks(page.blocks)
}

That gets eventual consistency on a fixed timer, not quite real-time. To make edits show up sooner without regenerating everything on every request, I added a small sync script instead of relying on the timer alone:

Sync: fetch
Pull each tracked page's last_edited_time

The cheapest possible signal. No need to diff full content, just a timestamp.

Sync: compare
Check it against what was last rendered

If nothing changed, do nothing. This is what stops the script from wasting API calls on untouched pages.

Sync: revalidate
Trigger on-demand regeneration, only for what changed

A targeted revalidatePath call behind a small internal API route.

app/api/sync/route.tsts
export async function POST(req) {
  const pages = await getTrackedPages()

  for ( const page of pages) {
    constchanged = page.lastEditedTime > page.lastRenderedTime
    if (changed) {
      await revalidatePath(`/${page.slug}`)
    }
  }
}

A background ISR timer as the safety net, plus targeted revalidation for anything that actually changed. That combination is what makes edits in Notion show up on the live site within a minute or two, without ever doing a full rebuild.

Why not just fetch on every request?

Two reasons:

  • Latency. A Notion API round trip on every page load adds real time to first paint, and stacks up further when a page has deeply nested blocks that each need their own fetch.
  • Rate limits. Notion's API isn't built to take a fetch per visitor. ISR means real visitors are always served a pre-rendered page from cache; only the background regeneration talks to Notion at all.

What it actually does

  • 01
    Near-real-time syncEdit a page in Notion, see it live within a couple of minutes, no manual deploy.
  • 02
    Fully custom stylingBlocks render through my own components, not Notion's default look.
  • 03
    SEO-friendly by defaultStatically generated HTML, proper metadata per page, no client-side content flash.
  • 04
    Selective privacyIndividual pages can be gated so drafts or private notes never make it to a public URL.

The shape of it is general enough that the same setup works for more than one thing: a blog, a resume page, a resource hub, a knowledge base, since all of it is really the same problem: render this Notion page as HTML, keep it fresh, make it fast.


If you're building something similar, the two decisions that mattered most weren't the rendering. That part is mostly mechanical, block type in, component out. It was picking ISR over full static generation early, and treating last_edited_time as the signal to act on rather than polling everything on a blind schedule. Get those two right and the rest is just component work.