Skip to main content
  1. Posts/

Write in Notion, Publish to GitHub Pages: The Ultimate Content Workflow (Part 2)

Image

Part 3: Setting Up GitHub Secrets #

Let’s store your Notion credentials securely in GitHub.

Step 1: Open Your Repository Settings #

  1. Go to your GitHub repository (rwgb.github.io)

  2. Click Settings (top menu)

  3. Click Secrets and variables β†’ Actions (left sidebar)

Step 2: Add Your Secrets #

Click “New repository secret” and add these two:

First Secret:

  • Name: NOTION_TOKEN

  • Value: Your integration token (ntn_…)

Second Secret:

  • Name: NOTION_DATABASE_ID

  • Value: Your database ID (32 characters)

These secrets are encrypted and only accessible to your GitHub Actions. Perfect for sensitive data.

Part 4: Creating the Sync Script #

Now for the fun partβ€”the Python script that converts Notion pages to Hugo markdown.

Step 1: Create the Script Directory #

In your repository, create a scripts folder:

mkdir scripts
cd scripts

Step 2: Create the Sync Script #

Create scripts/notion-sync.py:

#!/usr/bin/env python3
"""
Sync Notion database to Hugo markdown files
Converts Notion blocks to Hugo-compatible markdown
"""

import os
import re
import requests
from datetime import datetime
from pathlib import Path

# Configuration from environment variables
NOTION_TOKEN = os.environ.get('NOTION_TOKEN')
DATABASE_ID = os.environ.get('NOTION_DATABASE_ID')
OUTPUT_DIR = 'content/posts'

# Notion API setup
NOTION_API = 'https://api.notion.com/v1'
HEADERS = {
    'Authorization': f'Bearer {NOTION_TOKEN}',
    'Notion-Version': '2022-06-28',
    'Content-Type': 'application/json'
}

def query_database():
    """Fetch all published posts from Notion"""
    url = f'{NOTION_API}/databases/{DATABASE_ID}/query'

    # Only fetch published posts
    data = {
        'filter': {
            'property': 'Status',
            'select': {
                'equals': 'Published'
            }
        },
        'sorts': [
            {
                'property': 'Published Date',
                'direction': 'descending'
            }
        ]
    }

    response = requests.post(url, headers=HEADERS, json=data)
    response.raise_for_status()
    return response.json()['results']

def get_page_content(page_id):
    """Fetch all blocks from a Notion page"""
    url = f'{NOTION_API}/blocks/{page_id}/children'
    response = requests.get(url, headers=HEADERS)
    response.raise_for_status()
    return response.json()['results']

def notion_to_markdown(blocks):
    """Convert Notion blocks to markdown"""
    markdown = []

    for block in blocks:
        block_type = block['type']

        # Paragraph
        if block_type == 'paragraph':
            text = rich_text_to_markdown(block['paragraph']['rich_text'])
            if text.strip():
                markdown.append(f'{text}\n')

        # Headings
        elif block_type == 'heading_1':
            text = rich_text_to_markdown(block['heading_1']['rich_text'])
            markdown.append(f'## {text}\n')

        elif block_type == 'heading_2':
            text = rich_text_to_markdown(block['heading_2']['rich_text'])
            markdown.append(f'### {text}\n')

        elif block_type == 'heading_3':
            text = rich_text_to_markdown(block['heading_3']['rich_text'])
            markdown.append(f'#### {text}\n')

        # Lists
        elif block_type == 'bulleted_list_item':
            text = rich_text_to_markdown(block['bulleted_list_item']['rich_text'])
            markdown.append(f'- {text}\n')

        elif block_type == 'numbered_list_item':
            text = rich_text_to_markdown(block['numbered_list_item']['rich_text'])
            markdown.append(f'1. {text}\n')

        # Code blocks
        elif block_type == 'code':
            code = rich_text_to_markdown(block['code']['rich_text'])
            language = block['code']['language']
            markdown.append(f'```{language}\n{code}\n```\n')

        # Quotes
        elif block_type == 'quote':
            text = rich_text_to_markdown(block['quote']['rich_text'])
            markdown.append(f'> {text}\n')

        # Images
        elif block_type == 'image':
            url = block['image'].get('file', {}).get('url') or \
                  block['image'].get('external', {}).get('url')
            if url:
                caption = rich_text_to_markdown(block['image'].get('caption', []))
                alt_text = caption if caption else 'Image'
                markdown.append(f'![{alt_text}]({url})\n')

        # Divider
        elif block_type == 'divider':
            markdown.append('---\n')

    return '\n'.join(markdown)

def rich_text_to_markdown(rich_text):
    """Convert Notion rich text to markdown formatting"""
    if not rich_text:
        return ''

    result = []

    for text_obj in rich_text:
        text = text_obj['plain_text']
        annotations = text_obj['annotations']

        # Apply markdown formatting
        if annotations['bold']:
            text = f'**{text}**'
        if annotations['italic']:
            text = f'*{text}*'
        if annotations['code']:
            text = f'`{text}`'
        if annotations['strikethrough']:
            text = f'~~{text}~~'

        # Handle links
        if text_obj.get('href'):
            text = f'[{text}]({text_obj["href"]})'

        result.append(text)

    return ''.join(result)

def extract_properties(page):
    """Extract front matter properties from Notion page"""
    props = page['properties']

    # Title
    title_prop = props.get('Name') or props.get('Title')
    title = ''
    if title_prop and title_prop.get('title'):
        title = title_prop['title'][0]['plain_text']

    # Date
    date_prop = props.get('Published Date') or props.get('Date')
    date = datetime.now().isoformat()
    if date_prop and date_prop.get('date'):
        date = date_prop['date']['start']

    # Slug
    slug_prop = props.get('Slug')
    slug = ''
    if slug_prop and slug_prop.get('rich_text'):
        slug = slug_prop['rich_text'][0]['plain_text']
    if not slug:
        # Auto-generate from title
        slug = re.sub(r'[^a-z0-9]+', '-', title.lower()).strip('-')

    # Description
    desc_prop = props.get('Description')
    description = ''
    if desc_prop and desc_prop.get('rich_text'):
        description = desc_prop['rich_text'][0]['plain_text']

    # Tags
    tags_prop = props.get('Tags')
    tags = []
    if tags_prop and tags_prop.get('multi_select'):
        tags = [tag['name'] for tag in tags_prop['multi_select']]

    # Category
    cat_prop = props.get('Category')
    category = 'Uncategorized'
    if cat_prop and cat_prop.get('select'):
        category = cat_prop['select']['name']

    return {
        'title': title,
        'date': date,
        'slug': slug,
        'description': description,
        'tags': tags,
        'category': category
    }

def create_hugo_post(page_id, properties, content):
    """Create Hugo markdown file with front matter"""
    slug = properties['slug']
    filename = f"{slug}.md"
    filepath = Path(OUTPUT_DIR) / filename

    # Build front matter
    tags_str = ', '.join([f'"{tag}"' for tag in properties['tags']])

    front_matter = f"""---
title: "{properties['title']}"
date: {properties['date']}
description: "{properties['description']}"
tags: [{tags_str}]
categories: ["{properties['category']}"]
draft: false
---

"""

    # Write file
    filepath.parent.mkdir(parents=True, exist_ok=True)
    with open(filepath, 'w', encoding='utf-8') as f:
        f.write(front_matter)
        f.write(content)

    print(f'βœ“ Created: {filename}')
    return filename

def sync():
    """Main sync function"""
    print('πŸ”„ Syncing Notion to Hugo...\n')

    # Fetch published posts
    try:
        pages = query_database()
        print(f'Found {len(pages)} published posts\n')
    except Exception as e:
        print(f'❌ Error querying database: {e}')
        return

    synced = 0
    errors = 0

    for page in pages:
        try:
            # Extract properties
            properties = extract_properties(page)

            # Fetch content
            blocks = get_page_content(page['id'])
            content = notion_to_markdown(blocks)

            # Create Hugo post
            create_hugo_post(page['id'], properties, content)
            synced += 1

        except Exception as e:
            print(f'βœ— Error processing page: {e}')
            errors += 1

    print(f'\nβœ… Sync complete!')
    print(f'   Synced: {synced} posts')
    if errors > 0:
        print(f'   Errors: {errors} posts')

if __name__ == '__main__':
    # Validate environment variables
    if not NOTION_TOKEN:
        print('❌ Error: NOTION_TOKEN not set')
        exit(1)
    if not DATABASE_ID:
        print('❌ Error: NOTION_DATABASE_ID not set')
        exit(1)

    sync()

This script does a lot, so let’s break down the key parts:

**query_database()**: Fetches only posts with Status = “Published”

**notion_to_markdown()**: Converts Notion’s block format to Hugo-compatible markdown

**extract_properties()**: Pulls metadata to create Hugo front matter

**create_hugo_post()**: Writes the final .md file

Step 3: Create Requirements File #

Create scripts/requirements.txt:

requests==2.31.0

That’s itβ€”we only need one dependency!

Part 5: Creating the GitHub Action #

Now we’ll automate everything with GitHub Actions.

Create the Workflow File #

Create .github/workflows/notion-sync.yml:

name: Sync Notion to Hugo

on:
  # Run every hour
  schedule:
    - cron: '0 * * * *'

  # Allow manual trigger
  workflow_dispatch:

  # Run when this workflow changes
  push:
    paths:
      - '.github/workflows/notion-sync.yml'
      - 'scripts/notion-sync.py'

jobs:
  sync:
    runs-on: ubuntu-latest

    steps:
      - name: Checkout repository
        uses: actions/checkout@v4
        with:
          submodules: recursive
          token: ${{ secrets.GITHUB_TOKEN }}

      - name: Setup Python
        uses: actions/setup-python@v4
        with:
          python-version: '3.11'

      - name: Install dependencies
        run: |
          pip install -r scripts/requirements.txt

      - name: Run Notion sync
        env:
          NOTION_TOKEN: ${{ secrets.NOTION_TOKEN }}
          NOTION_DATABASE_ID: ${{ secrets.NOTION_DATABASE_ID }}
        run: |
          python scripts/notion-sync.py

      - name: Check for changes
        id: verify_diff
        run: |
          git diff --quiet content/posts/ || echo "changed=true" >> $GITHUB_OUTPUT

      - name: Commit and push if changed
        if: steps.verify_diff.outputs.changed == 'true'
        run: |
          git config --local user.email "github-actions[bot]@users.noreply.github.com"
          git config --local user.name "github-actions[bot]"
          git add content/posts/
          git commit -m "πŸ”„ Sync from Notion"
          git push

What this workflow does:

  1. Runs every hour checking for new/updated posts

  2. Can be triggered manually for immediate syncs

  3. Pulls your code including the Hugo theme

  4. Installs Python and dependencies

  5. Runs the sync script with your Notion credentials

  6. Commits changes if new posts were added

  7. Triggers Hugo rebuild automatically (your existing workflow)

Part 6: Testing Everything #

Time for the moment of truth!

Step 1: Commit Everything #

git add scripts/ .github/workflows/
git commit -m "Add Notion sync integration"
git push origin main

Step 2: Manual Trigger #

Don’t wait an hourβ€”let’s test now:

  1. Go to Actions tab in GitHub

  2. Click “Sync Notion to Hugo”

  3. Click “Run workflow” β†’ “Run workflow”

  4. Watch it run (takes about 30 seconds)

Step 3: Check the Results #

If everything worked:

  1. Check the Actions log for the βœ“ symbols

  2. Look at your repository for new files in content/posts/

  3. Wait 2-3 minutes for Hugo to rebuild

  4. Visit your site and see your new post!

Troubleshooting Common Issues #

“400 Bad Request” Error #

Cause: Integration doesn’t have database access

Fix:

  1. Open Notion database

  2. Click “…” β†’ “Add connections”

  3. Select your integration

“401 Unauthorized” Error #

Cause: Token is wrong or expired

Fix:

  1. Check GitHub Secrets

  2. Regenerate token in Notion if needed

  3. Update the secret

Posts Not Syncing #

Checklist:

  • βœ“ Status = “Published” in Notion?

  • βœ“ Database ID correct?

  • βœ“ Integration has access?

  • βœ“ Check Actions tab for errors

Images Not Showing #

Issue: Notion image URLs expire after a while

Solutions:

  1. Use external images (upload to Imgur, Cloudinary, etc.)

  2. Download images to repo (modify script to download)

  3. Accept temporary URLs (good enough for most use cases)

Your New Publishing Workflow #

Here’s what publishing looks like now:

On Desktop: #

  1. Open Notion

  2. Write your post

  3. Set Status to “Published”

  4. Done. (Seriously, that’s it)

On Mobile: #

  1. Open Notion app

  2. Create new entry in database

  3. Write using voice-to-text while walking your dog

  4. Hit “Published”

  5. Post is live within an hour