Back to Blog
Development

How I Built my website with Next.js, Notion as a CMS, and Cloudflare Workers

TL;DR: I rebuilt my portfolio and blog as a Next.js 16 site where Notion is the CMS. A prebuild script pulls published pages from two Notion databases, writes them as file-based MDX routes, and OpenNext deploys the result to Cloudflare Workers. Change a Status property in Notion → the site rebuilds in 1–3 minutes. No dashboard, no database, no runtime queries.


The Problem

My old portfolio had content hardcoded in TypeScript. Blog posts were literal template strings inside page.tsx . Portfolio items lived in data/projects.ts . Every update was open Zed → edit file → commit → push.

I already draft everything in Notion. I wanted Notion to be the CMS — write, flip Status to "Published," site updates. No Sanity, no Contentful, no dashboard to maintain. Just Notion and a build script.

The Stack

Pinned versions (current on the live site):

PackageVersionRole
next16.2.3Framework, MDX pages
@notionhq/client2.3.0Notion API
notion-to-md4.0.0-alpha.7Notion blocks → Markdown
@next/mdx16.2.3Renders .mdx as pages
@opennextjs/cloudflare1.19.1For deploying Next on Cloudflare Workers

Architecture

Notion databases (Portfolio + Blog)
	↓  scripts/fetch-notion.ts   # npm prebuild hook
			app/blog/<slug>/page.mdx   # file-based routes
			app/portfolio/<slug>/page.mdx
			content/blog/_index.json.  # manifests for listing pages
			content/portfolio/_index.json
			public/media/*  # downloaded Notion images
  ↓  next build
  ↓  opennextjs-cloudflare build + deploy
   ↓  wrangler uploads Worker + static assets
elliotgluck.com  (Cloudflare Worker, global edge)

Two phases run sequentially on every deploy.

Phase 1: Fetch

scripts/fetch-notion.ts runs before next build via the prebuild npm lifecycle hook:

  1. Queries both Notion databases, filtered to Status = "Published" .
  2. Handles pagination — Notion caps at 100 results per query.
  3. Extracts defined properties — title, tags, date, slug — with type-safe helpers.
  4. Converts each page's block content to MDX with NotionConverter .
  5. Downloads images to public/media/ (Notion's S3 URLs expire in ~1 hour).
  6. Writes app/<type>/<slug>/page.mdx — a real Next.js file-based route.
  7. Writes JSON manifests for listing pages.

Phase 2: Build

next build picks up the generated .mdx files as normal page routes. No dynamic routing, no generateStaticParams , no runtime fetching. Each page.mdx is just a page.

Listing pages import the manifests directly:

import posts from "@/content/blog/_index.json";

JSON imports resolve at build time through the bundler, so this works in client components too.

The First Approach That Failed

I first tried [slug] dynamic routes with generateStaticParams and next-mdx-remote to render MDX strings at build time. Standard Next.js + MDX tutorial pattern.

It didn't work. Next.js 16 with Turbopack kept reporting generateStaticParams() as missing even though it was clearly exported. The error was misleading — the function existed but something in the module-resolution chain was breaking during the build-analysis pass.

Stepping back: Next.js already knows how to handle .mdx files as pages via @next/mdx . Writing MDX files directly into app/ as file-based routes — no dynamic routing — worked immediately.

Lesson: don't fight the framework. File-based routing is the happy path. The "dynamic" part happens before the build, not during it.

How the Generated MDX Pages Work

Each page.mdx the script writes looks like this:

import { BlogPostLayout } from "@/components/blog-post-layout"

export const post = {
  title: `Building Notion AI Agents for Job Search + Blog Ideation`,
  slug: "building-notion-ai-agents-for-job-search-blog-ideation",
  category: "Development",
  date: "2026-04-16",
  excerpt: `I'm using Notion AI agents...`,
}

export const metadata = {
  title: post.title,
  description: post.excerpt,
}

export default (props) => <BlogPostLayout post={post} {...props} />

The markdown body goes here...

The pattern:

  • JS exports for metadata, not YAML frontmatter. Next.js MDX natively supports export const metadata .
  • Layout component wraps content with header, back button, tags.
  • metadata export gives Next.js the <title> and <meta description> for SEO.
  • MDX body renders as children of the layout.

The Fetch Script

scripts/fetch-notion.ts is the heart of the system. Key decisions:

Type-safe property extraction

Notion's API returns properties as deeply nested discriminated unions. A rich-text property looks like:

{
  "type": "rich_text",
  "rich_text": [{ "type": "text", "plain_text": "Hello" }]
}

I wrote extractor helpers — getTitle , getRichText , getSelect , getMultiSelect , getCheckbox , getDate — that handle the unions safely. They log warnings for missing properties instead of throwing, so the build continues even if a Notion schema has a typo.

Slug generation

Portfolio items have an explicit slug property in Notion. Blog posts don't — slugs come from slugify(title) . Collisions append -2 , -3 , etc.

Graceful failure

If Notion is unreachable, the script logs a warning and exits with code 0. The build continues with whatever MDX files already exist on disk. Stale content > broken deploy.

Media downloads

notion-to-md v4's .downloadMediaTo() saves images locally:

const n2m = new NotionConverter(notion).downloadMediaTo({
  outputDir: "./public/media",
  transformPath: (localPath) => `/media/${path.basename(localPath)}`,
  preserveExternalUrls: true,
  failForward: true,
});

Downloaded images get stable paths like /media/image-abc123.png . External URLs (images hosted outside Notion) pass through untouched.

Notion Database Schemas

If you're deploying this on your own website, the schema for your notion database is going to depend on what content you want displayed on the final website; and will need to be defined in the notion sync script.

Property names are case-sensitive. The script expects these exact names:

Portfolio

PropertyTypeNotes
TitletitleProject name
slugrich_textURL slug (e.g. alpha-grove )
Summaryrich_textCard description
Rolerich_textYour role
Yearrich_textYear or range
Tagsmulti_selectTech tags
FeaturedcheckboxShow on homepage
StatusstatusMust be "Published" to include

Blog

PropertyTypeNotes
TitletitleSlugified for URL
CategoryselectPost category
DatedatePublish date
Excerptrich_textListing blurb
StatusstatusMust be "Published" to include

The Status property is key for having notion act like a true headless CMS. I set my database up with four options to choose from: idea, draft, review and published. You can get away with only having two, one for published and not published.

Note : Status must be a status type, not select . They look identical in Notion's UI but have different API types. Using select throws a filter-type mismatch. Cost me a debugging session.

Listing Pages: Client Components + JSON Imports

My website uses GSAP scroll animations extensively, so much of the website is composed of client components which can't use Node's fs to generate manifests on the fly.

To fix this, the fetch script writes JSON manifests alongside the MDX files. Listing pages import them directly:

"use client";
import projects from "@/content/portfolio/_index.json";

JSON imports resolve at build time through the bundler — no runtime, no fs .

TypeScript defaults to never[] for an empty JSON array, so content/types.d.ts declares the shape:

declare module "@/content/portfolio/_index.json" {
  interface PortfolioItem {
    slug: string;
    title: string;
    summary: string;
    // ...
  }
  const items: PortfolioItem[];
  export default items;
}

From Static Export to OpenNext on Cloudflare Workers

I originally wanted to use static exports with Cloudflare Pages as that is what I had used with my previous hardcoded website.

For this website, I migrated to OpenNext on Cloudflare Workers, which compiles the Next.js app into a worker that handles SSG, SSR, ISR, server actions, middleware, and image optimization without managing servers or locking in to Vercel.

Managing Notion Secrets

Set NOTION_TOKEN , NOTION_PORTFOLIO_DB_ID , NOTION_BLOG_DB_ID in the Cloudflare dashboard under Workers → Settings → Build → Environment Variables. The prebuild script reads process.env directly. No Worker, no HTTP, no auth.

Locally, the same values go in .dev.vars (Wrangler's local env file, gitignored). The fetch script loads it:

dotenv.config({ path: path.join(ROOT, ".dev.vars") });

Two places, one for local and one for production. That's it.

Auto-Deploy from Notion (Live CMS)

Final piece: make Notion feel like a real CMS. If you have notion plus, you can set automations in your database. I set mine up so that when a Status property was set to "Published" or "In Progress" (a status group of either Draft or Review), a webhook gets fired to the Cloudflare Workers build hook, and the site rebuilds and deploys automatically. This is important as we want the CMS not only to publish new posts, but remove posts that we want to unpublish.

Step 1: Cloudflare Deploy Hook

Cloudflare dashboard → Worker → Settings → Builds → Deploy Hooks. Create one, copy the URL:

<https://api.cloudflare.com/client/v4/accounts/><ACCOUNT_ID>/workers/deployments/hooks/<HOOK_ID>

POST to this URL triggers a new deployment.

Step 2: Notion automation

In each database (Portfolio and Blog):

  1. ··· → Automations → New Automation.
  2. Add trigger: Status property → is changed toPublished .
  3. Add another trigger: Status property → is changed toIn Progress/Not Published .
  4. Action: Send webhook .
  5. Paste the Deploy Hook URL.
  6. Enable.

Result

Write a post in Notion. Set Status to "Published." Wait 1–3 minutes. Live. Flip back to "Draft." Gone. No git commits, no CI config, no deploy buttons.

Trade-offs and Limitations

  • Build time scales with content. Every deploy re-fetches everything and re-downloads media. Fine at ~20 posts, not at 2,000.
  • notion-to-md v4 is alpha. Pin the exact version ( 4.0.0-alpha.7 ). I've hit one breaking change on a minor bump.
  • Notion's API is rate-limited. 3 requests/second average. The script is sequential and small, so I haven't hit it — but a large content library could.
  • Deploy hook has no auth. Anyone with the URL can trigger a rebuild. Cloudflare rate-limits it, and the rebuild just pulls Notion content — no user input — so the blast radius is "wasted build minutes."
  • Media URLs in draft previews break. Notion's signed S3 URLs expire in ~1 hour. Previewing unpublished content in Notion's own UI is fine; scraping it after expiry is not. Only matters if you preview-fetch drafts.

Reproducing This

The fetch script is open-source and designed to fork: github.com/ElliotGluck/notion-to-nextjs. README walks through integration, database schemas, MDX config, and Cloudflare deployment.

Minimal version:

  1. Create a Notion integration at notion.so/my-integrations.
  2. Share your Portfolio and Blog databases with it.
  3. Copy scripts/fetch-notion.ts into your project.
  4. Fill in .env.local / .dev.vars :
NOTION_TOKEN=ntn_xxxxx
NOTION_PORTFOLIO_DB_ID=xxxxx
NOTION_BLOG_DB_ID=xxxxx
  1. Add "prebuild": "tsx scripts/fetch-notion.ts" to package.json .
  2. npm run build .

Lessons Learned

  • Don't fight file-based routing. Writing files into app/ and letting Next.js discover them beats dynamic routes + generateStaticParams for every case I tried.
  • Status vs select in Notion are different API types. They look identical in the UI. Use status for the Published filter.
  • JS exports beat YAML frontmatter for MDX. @next/mdx supports export const metadata natively. No frontmatter parser needed.
  • JSON imports work in client components. The bundler resolves them at build time. You don't need a Server/Client split just to pass data.
  • prebuild is clean. One command — npm run build — fetches and builds.
  • Graceful degradation matters. The fetch script exits 0 on failure. A Notion outage shouldn't block a deploy.
  • Keep infra minimal. A separate content Worker felt "proper" but added a deploy, an auth mechanism, and a URL for no real benefit. Build-time env vars won.

Stay Tuned

If you're wondering why I have a fourth status option for Idea that does not trigger the rebuild automation? Subscribe to see how I built an AI agent that stays up to date with trending news online and centralizes it into my database, all within notion!

Further Reading