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):
| Package | Version | Role |
|---|---|---|
next | 16.2.3 | Framework, MDX pages |
@notionhq/client | 2.3.0 | Notion API |
notion-to-md | 4.0.0-alpha.7 | Notion blocks → Markdown |
@next/mdx | 16.2.3 | Renders .mdx as pages |
@opennextjs/cloudflare | 1.19.1 | For 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:
- Queries both Notion databases, filtered to
Status = "Published". - Handles pagination — Notion caps at 100 results per query.
- Extracts defined properties — title, tags, date, slug — with type-safe helpers.
- Converts each page's block content to MDX with
NotionConverter. - Downloads images to
public/media/(Notion's S3 URLs expire in ~1 hour). - Writes
app/<type>/<slug>/page.mdx— a real Next.js file-based route. - 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.
metadataexport gives Next.js the<title>and<meta description>for SEO.- MDX body renders as
childrenof 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
| Property | Type | Notes |
|---|---|---|
| Title | title | Project name |
| slug | rich_text | URL slug (e.g. alpha-grove ) |
| Summary | rich_text | Card description |
| Role | rich_text | Your role |
| Year | rich_text | Year or range |
| Tags | multi_select | Tech tags |
| Featured | checkbox | Show on homepage |
| Status | status | Must be "Published" to include |
Blog
| Property | Type | Notes |
|---|---|---|
| Title | title | Slugified for URL |
| Category | select | Post category |
| Date | date | Publish date |
| Excerpt | rich_text | Listing blurb |
| Status | status | Must 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):
- ··· → Automations → New Automation.
- Add trigger: Status property → is changed to → Published .
- Add another trigger: Status property → is changed to → In Progress/Not Published .
- Action: Send webhook .
- Paste the Deploy Hook URL.
- 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:
- Create a Notion integration at notion.so/my-integrations.
- Share your Portfolio and Blog databases with it.
- Copy
scripts/fetch-notion.tsinto your project. - Fill in
.env.local/.dev.vars:
NOTION_TOKEN=ntn_xxxxx
NOTION_PORTFOLIO_DB_ID=xxxxx
NOTION_BLOG_DB_ID=xxxxx
- Add
"prebuild": "tsx scripts/fetch-notion.ts"topackage.json. npm run build.
Lessons Learned
- Don't fight file-based routing. Writing files into
app/and letting Next.js discover them beats dynamic routes +generateStaticParamsfor every case I tried. - Status vs select in Notion are different API types. They look identical in the UI. Use
statusfor the Published filter. - JS exports beat YAML frontmatter for MDX.
@next/mdxsupportsexport const metadatanatively. 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.
prebuildis 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!