Building a Blog with Notion as Your CMS
If you’ve ever wished you could just write in Notion and have your blog update automatically, you’re not alone. Notion’s rich editor, database features, and collaborative tools make it an ideal CMS — if you can bridge the gap to your static site.
In this guide, we’ll walk through setting up Notion as a headless CMS for an Astro blog.
Why Notion as a CMS?
Most headless CMS platforms (Contentful, Sanity, Strapi) are powerful but come with a learning curve and often a price tag. Notion offers something different:
- Familiar interface — If you already use Notion for notes, you know the editor
- Rich content — Tables, callouts, toggles, embedded media
- Database views — Filter by status, category, or publish date
- Free tier — More than enough for a personal blog
- Collaboration — Invite editors without teaching them a new tool
Architecture Overview
Here’s how the pieces fit together:
┌─────────┐ Webhook ┌──────────────┐ Dispatch ┌─────────┐
│ Notion │ ──────────────► │ CF Worker │ ───────────────► │ GitHub │
│ DB │ │ (validate) │ │ Actions │
└─────────┘ └──────────────┘ └────┬────┘
│
sync + build
│
┌────▼────┐
│ CF │
│ Pages │
└─────────┘When you change a page’s status to “Publishing” in Notion, a webhook fires. A Cloudflare Worker validates the request and triggers a GitHub Actions workflow that syncs content and deploys.
Setting Up the Notion Database
Create a new database in Notion with these properties:
| Property | Type | Purpose |
|---|---|---|
| Title | Title | Post title |
| Status | Select | Publishing workflow |
| Category | Select | Blog category |
| Tags | Multi-select | Post tags |
| Publish Date | Date | When to publish |
| Description | Text | SEO description |
| Hero Image | Files & Media | Cover image |
| Slug | Text | URL slug |
The Status property drives the entire workflow:
Draft → Ready → Publishing → Synced
↓
Sync Failed (on error)Connecting Notion to Your Blog
First, create a Notion integration and grab your API token:
// sync-notion.mjs
import { Client } from '@notionhq/client';
const notion = new Client({ auth: process.env.NOTION_TOKEN });
const response = await notion.databases.query({
database_id: process.env.NOTION_DATABASE_ID,
filter: {
property: 'Status',
select: { equals: 'Publishing' },
},
});For each page, we convert Notion blocks to Markdown:
async function pageToMarkdown(pageId: string): Promise<string> {
const blocks = await notion.blocks.children.list({
block_id: pageId,
});
return blocks.results
.map(block => blockToMarkdown(block))
.join('\n\n');
}The Webhook Handler
The Cloudflare Worker validates incoming webhooks using HMAC-SHA256:
async function verifySignature(
body: string,
signature: string,
secret: string,
): Promise<boolean> {
const encoder = new TextEncoder();
const key = await crypto.subtle.importKey(
'raw',
encoder.encode(secret),
{ name: 'HMAC', hash: 'SHA-256' },
false,
['verify'],
);
const sig = hexToBytes(signature);
return crypto.subtle.verify('HMAC', key, sig, encoder.encode(body));
}Security note: Always validate webhook signatures. Without verification, anyone could trigger rebuilds on your site.
What’s Next
In the next post of this series, we’ll cover the developer tools that make this workflow even smoother — from CLI scaffolding to automated deployments.
TL;DR: Notion + Cloudflare Workers + GitHub Actions = a free, fast, and familiar blogging workflow. Write where you think, publish where you want.