Workflow 001: CMS Integration
Fresh Last Updated: January 2025| Field | Value |
|---|---|
| Workflow ID | WF-001 |
| Version | 1.0 |
| Complexity | Medium |
| Estimated Setup | 2-4 hours |
Overview
Sync content from Notion databases to your website or CMS. This workflow enables content teams to use Notion as a headless CMS while automatically publishing to production.
Use Cases
- Blog posts authored in Notion, published to website
- Documentation maintained in Notion, deployed to docs site
- Product catalogs managed in Notion, synced to e-commerce
- Landing page content stored in Notion, rendered dynamically
Architecture
Prerequisites
- [ ] Notion database with content (see schema below)
- [ ] Node.js environment
- [ ] Destination CMS/website (Next.js, Astro, etc.)
- [ ] Completed SOP 002: Authentication
Recommended Database Schema
| Property | Type | Purpose |
|---|---|---|
| Title | Title | Post/page title |
| Slug | Text | URL-friendly identifier |
| Status | Select | Draft, Review, Published, Archived |
| Published Date | Date | When to publish |
| Author | People | Content author |
| Category | Select | Content category |
| Tags | Multi-select | Content tags |
| Excerpt | Text | Short description |
| Featured Image | Files | Hero image |
| SEO Title | Text | Custom meta title |
| SEO Description | Text | Meta description |
Implementation
Step 1: Project Setup
bash
mkdir notion-cms-sync
cd notion-cms-sync
npm init -y
npm install @notionhq/client dotenv gray-matterCreate .env:
env
NOTION_API_KEY=secret_xxxxx
NOTION_DATABASE_ID=xxxxx
OUTPUT_DIR=./contentStep 2: Content Fetcher
javascript
// src/fetcher.js
const { Client } = require('@notionhq/client');
require('dotenv').config();
const notion = new Client({ auth: process.env.NOTION_API_KEY });
async function fetchPublishedContent() {
const response = await notion.databases.query({
database_id: process.env.NOTION_DATABASE_ID,
filter: {
and: [
{
property: 'Status',
select: { equals: 'Published' }
},
{
property: 'Published Date',
date: { on_or_before: new Date().toISOString() }
}
]
},
sorts: [
{ property: 'Published Date', direction: 'descending' }
]
});
return response.results;
}
async function fetchPageContent(pageId) {
const blocks = [];
let cursor = undefined;
do {
const response = await notion.blocks.children.list({
block_id: pageId,
start_cursor: cursor,
page_size: 100
});
blocks.push(...response.results);
cursor = response.has_more ? response.next_cursor : undefined;
} while (cursor);
return blocks;
}
module.exports = { fetchPublishedContent, fetchPageContent };Step 3: Block to Markdown Transformer
javascript
// src/transformer.js
function blockToMarkdown(block) {
const type = block.type;
const content = block[type];
switch (type) {
case 'paragraph':
return richTextToMarkdown(content.rich_text) + '\n';
case 'heading_1':
return `# ${richTextToMarkdown(content.rich_text)}\n`;
case 'heading_2':
return `## ${richTextToMarkdown(content.rich_text)}\n`;
case 'heading_3':
return `### ${richTextToMarkdown(content.rich_text)}\n`;
case 'bulleted_list_item':
return `- ${richTextToMarkdown(content.rich_text)}\n`;
case 'numbered_list_item':
return `1. ${richTextToMarkdown(content.rich_text)}\n`;
case 'to_do':
const checkbox = content.checked ? '[x]' : '[ ]';
return `- ${checkbox} ${richTextToMarkdown(content.rich_text)}\n`;
case 'toggle':
return `<details>\n<summary>${richTextToMarkdown(content.rich_text)}</summary>\n\n</details>\n`;
case 'code':
const lang = content.language || '';
return `\`\`\`${lang}\n${richTextToMarkdown(content.rich_text)}\n\`\`\`\n`;
case 'quote':
return `> ${richTextToMarkdown(content.rich_text)}\n`;
case 'callout':
const emoji = content.icon?.emoji || '💡';
return `> ${emoji} ${richTextToMarkdown(content.rich_text)}\n`;
case 'divider':
return '---\n';
case 'image':
const url = content.type === 'external'
? content.external.url
: content.file.url;
const caption = content.caption?.length
? richTextToMarkdown(content.caption)
: 'image';
return `\n`;
case 'bookmark':
return `[${content.url}](${content.url})\n`;
case 'embed':
return `<iframe src="${content.url}" frameborder="0"></iframe>\n`;
case 'video':
const videoUrl = content.type === 'external'
? content.external.url
: content.file.url;
return `<video src="${videoUrl}" controls></video>\n`;
case 'table':
return '<!-- Table not yet supported -->\n';
default:
return '';
}
}
function richTextToMarkdown(richTextArray) {
if (!richTextArray || richTextArray.length === 0) {
return '';
}
return richTextArray.map(rt => {
let text = rt.plain_text;
// Apply annotations
if (rt.annotations) {
if (rt.annotations.bold) text = `**${text}**`;
if (rt.annotations.italic) text = `*${text}*`;
if (rt.annotations.strikethrough) text = `~~${text}~~`;
if (rt.annotations.code) text = `\`${text}\``;
}
// Apply link
if (rt.href) {
text = `[${text}](${rt.href})`;
}
return text;
}).join('');
}
function blocksToMarkdown(blocks) {
return blocks.map(blockToMarkdown).join('\n');
}
function extractMetadata(page) {
const props = page.properties;
return {
id: page.id,
title: props.Title?.title?.[0]?.plain_text || 'Untitled',
slug: props.Slug?.rich_text?.[0]?.plain_text || page.id,
status: props.Status?.select?.name || 'Draft',
publishedDate: props['Published Date']?.date?.start || null,
author: props.Author?.people?.[0]?.name || 'Anonymous',
category: props.Category?.select?.name || null,
tags: props.Tags?.multi_select?.map(t => t.name) || [],
excerpt: props.Excerpt?.rich_text?.[0]?.plain_text || '',
featuredImage: props['Featured Image']?.files?.[0]?.file?.url
|| props['Featured Image']?.files?.[0]?.external?.url
|| null,
seoTitle: props['SEO Title']?.rich_text?.[0]?.plain_text || null,
seoDescription: props['SEO Description']?.rich_text?.[0]?.plain_text || null,
lastEdited: page.last_edited_time,
url: page.url
};
}
module.exports = { blocksToMarkdown, extractMetadata, blockToMarkdown };Step 4: Content Sync Script
javascript
// src/sync.js
const fs = require('fs').promises;
const path = require('path');
const { fetchPublishedContent, fetchPageContent } = require('./fetcher');
const { blocksToMarkdown, extractMetadata } = require('./transformer');
const OUTPUT_DIR = process.env.OUTPUT_DIR || './content';
async function ensureDir(dir) {
try {
await fs.mkdir(dir, { recursive: true });
} catch (err) {
if (err.code !== 'EEXIST') throw err;
}
}
function generateFrontmatter(metadata) {
return `---
title: "${metadata.title.replace(/"/g, '\\"')}"
slug: "${metadata.slug}"
date: "${metadata.publishedDate}"
author: "${metadata.author}"
category: "${metadata.category || ''}"
tags: [${metadata.tags.map(t => `"${t}"`).join(', ')}]
excerpt: "${(metadata.excerpt || '').replace(/"/g, '\\"')}"
featuredImage: "${metadata.featuredImage || ''}"
seoTitle: "${metadata.seoTitle || metadata.title}"
seoDescription: "${metadata.seoDescription || metadata.excerpt || ''}"
lastEdited: "${metadata.lastEdited}"
notionUrl: "${metadata.url}"
---
`;
}
async function syncContent() {
console.log('🔄 Starting content sync...\n');
await ensureDir(OUTPUT_DIR);
const pages = await fetchPublishedContent();
console.log(`📄 Found ${pages.length} published pages\n`);
const syncedFiles = [];
for (const page of pages) {
try {
const metadata = extractMetadata(page);
console.log(`Processing: ${metadata.title}`);
// Fetch page content
const blocks = await fetchPageContent(page.id);
// Convert to markdown
const markdown = blocksToMarkdown(blocks);
// Generate full content with frontmatter
const fullContent = generateFrontmatter(metadata) + markdown;
// Write to file
const filename = `${metadata.slug}.md`;
const filepath = path.join(OUTPUT_DIR, filename);
await fs.writeFile(filepath, fullContent, 'utf-8');
syncedFiles.push(filename);
console.log(` ✅ Saved: ${filename}`);
// Rate limit: wait 350ms between pages
await new Promise(resolve => setTimeout(resolve, 350));
} catch (error) {
console.error(` ❌ Error processing ${page.id}:`, error.message);
}
}
console.log(`\n✅ Sync complete! ${syncedFiles.length} files updated.`);
// Write manifest
const manifest = {
lastSync: new Date().toISOString(),
files: syncedFiles,
total: syncedFiles.length
};
await fs.writeFile(
path.join(OUTPUT_DIR, '_manifest.json'),
JSON.stringify(manifest, null, 2)
);
return syncedFiles;
}
// Run if called directly
if (require.main === module) {
syncContent().catch(console.error);
}
module.exports = { syncContent };Step 5: API Endpoint for Real-time Fetching
javascript
// api/content/[slug].js (Next.js API route)
const { Client } = require('@notionhq/client');
const notion = new Client({ auth: process.env.NOTION_API_KEY });
export default async function handler(req, res) {
const { slug } = req.query;
try {
// Find page by slug
const response = await notion.databases.query({
database_id: process.env.NOTION_DATABASE_ID,
filter: {
and: [
{ property: 'Slug', rich_text: { equals: slug } },
{ property: 'Status', select: { equals: 'Published' } }
]
}
});
if (response.results.length === 0) {
return res.status(404).json({ error: 'Not found' });
}
const page = response.results[0];
// Fetch blocks
const blocks = await fetchAllBlocks(page.id);
// Transform
const content = {
metadata: extractMetadata(page),
blocks: blocks.map(transformBlock),
markdown: blocksToMarkdown(blocks)
};
// Cache for 60 seconds
res.setHeader('Cache-Control', 's-maxage=60, stale-while-revalidate');
res.json(content);
} catch (error) {
console.error('Error fetching content:', error);
res.status(500).json({ error: 'Internal server error' });
}
}
async function fetchAllBlocks(blockId) {
// Implementation from fetcher.js
}Step 6: Incremental Sync with Webhooks
javascript
// src/incremental-sync.js
const express = require('express');
const crypto = require('crypto');
const { syncContent } = require('./sync');
const app = express();
app.use(express.raw({ type: 'application/json' }));
app.post('/webhook/notion', async (req, res) => {
// Verify webhook signature
const signature = req.headers['x-notion-signature'];
const timestamp = req.headers['x-notion-timestamp'];
const payload = req.body.toString();
const expectedSig = crypto
.createHmac('sha256', process.env.NOTION_WEBHOOK_SECRET)
.update(`${timestamp}.${payload}`)
.digest('hex');
if (signature !== expectedSig) {
return res.status(401).send('Invalid signature');
}
const event = JSON.parse(payload);
// Handle verification
if (event.type === 'verification') {
return res.json({ challenge: event.challenge });
}
console.log('Received webhook:', event.type);
// Trigger sync for relevant events
if (['page.created', 'page.updated'].includes(event.type)) {
// Run sync in background
syncContent()
.then(() => console.log('Incremental sync complete'))
.catch(err => console.error('Sync failed:', err));
}
res.status(200).send('OK');
});
app.listen(3000, () => {
console.log('Webhook server listening on port 3000');
});Step 7: Caching Layer
javascript
// src/cache.js
const fs = require('fs').promises;
const path = require('path');
const CACHE_DIR = './.cache';
const CACHE_TTL = 60 * 60 * 1000; // 1 hour
async function getCached(key) {
try {
const filepath = path.join(CACHE_DIR, `${key}.json`);
const stat = await fs.stat(filepath);
// Check if expired
if (Date.now() - stat.mtimeMs > CACHE_TTL) {
return null;
}
const data = await fs.readFile(filepath, 'utf-8');
return JSON.parse(data);
} catch {
return null;
}
}
async function setCache(key, data) {
await fs.mkdir(CACHE_DIR, { recursive: true });
const filepath = path.join(CACHE_DIR, `${key}.json`);
await fs.writeFile(filepath, JSON.stringify(data));
}
async function clearCache() {
try {
await fs.rm(CACHE_DIR, { recursive: true });
} catch {
// Ignore if doesn't exist
}
}
module.exports = { getCached, setCache, clearCache };Deployment
Option 1: Scheduled Sync (Cron)
bash
# crontab -e
# Run every 15 minutes
*/15 * * * * cd /path/to/project && node src/sync.js >> /var/log/notion-sync.log 2>&1Option 2: GitHub Actions
yaml
# .github/workflows/sync.yml
name: Sync Notion Content
on:
schedule:
- cron: '*/15 * * * *' # Every 15 minutes
workflow_dispatch: # Manual trigger
jobs:
sync:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
- run: npm ci
- name: Sync content
env:
NOTION_API_KEY: ${{ secrets.NOTION_API_KEY }}
NOTION_DATABASE_ID: ${{ secrets.NOTION_DATABASE_ID }}
run: node src/sync.js
- name: Commit changes
run: |
git config --local user.email "action@github.com"
git config --local user.name "GitHub Action"
git add content/
git diff --staged --quiet || git commit -m "Sync content from Notion"
git pushOption 3: Vercel Serverless
javascript
// api/sync.js
import { syncContent } from '../src/sync';
export default async function handler(req, res) {
// Verify cron secret
if (req.headers.authorization !== `Bearer ${process.env.CRON_SECRET}`) {
return res.status(401).json({ error: 'Unauthorized' });
}
try {
const files = await syncContent();
res.json({ success: true, synced: files.length });
} catch (error) {
res.status(500).json({ error: error.message });
}
}Verification Checklist
- [ ] Content fetches from correct database
- [ ] Only published content is synced
- [ ] Markdown renders correctly
- [ ] Images are accessible
- [ ] Frontmatter includes all metadata
- [ ] Rate limiting prevents 429 errors
- [ ] Cache improves performance
- [ ] Errors are logged properly
Troubleshooting
| Issue | Cause | Solution |
|---|---|---|
| Empty content | Wrong database ID | Verify database ID |
| Missing images | Notion URLs expire | Cache images locally |
| Broken formatting | Unsupported block type | Add transformer case |
| Stale content | Cache not invalidating | Reduce TTL or use webhooks |