Skip to content

Workflow 001: CMS Integration

Fresh Last Updated: January 2025
FieldValue
Workflow IDWF-001
Version1.0
ComplexityMedium
Estimated Setup2-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
PropertyTypePurpose
TitleTitlePost/page title
SlugTextURL-friendly identifier
StatusSelectDraft, Review, Published, Archived
Published DateDateWhen to publish
AuthorPeopleContent author
CategorySelectContent category
TagsMulti-selectContent tags
ExcerptTextShort description
Featured ImageFilesHero image
SEO TitleTextCustom meta title
SEO DescriptionTextMeta description

Implementation

Step 1: Project Setup

bash
mkdir notion-cms-sync
cd notion-cms-sync
npm init -y
npm install @notionhq/client dotenv gray-matter

Create .env:

env
NOTION_API_KEY=secret_xxxxx
NOTION_DATABASE_ID=xxxxx
OUTPUT_DIR=./content

Step 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 `![${caption}](${url})\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>&1

Option 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 push

Option 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

IssueCauseSolution
Empty contentWrong database IDVerify database ID
Missing imagesNotion URLs expireCache images locally
Broken formattingUnsupported block typeAdd transformer case
Stale contentCache not invalidatingReduce TTL or use webhooks

See Also

Built with VitePress | Powered by Claude AI