Skip to content
bellog-notion logo

Bellog Notion CMS Integration

bellog-notion

Provides Notion CMS integration patterns and best practices for Bellog. Triggers when working with Notion data or implementing Notion-based features.

SKILL.md

Full skill instructions

Bellog Notion CMS Integration

This skill defines how to work with Notion as a CMS in the Bellog blog project.

Architecture Overview

Bellog uses Notion as a headless CMS:

  • Content is managed in a Notion database
  • Data is fetched via Notion API
  • Content is rendered using react-notion-x
  • Caching with Next.js for performance

Key Files

  • /​src/​lib/​notion.ts - Notion API client & queries
  • /​src/​lib/​posts.ts - Cached data fetching
  • /​src/​lib/​tags.ts - Tag aggregation
  • /​src/​types/​index.d.ts - Type definitions
  • /​src/​app/​api/​revalidate/​route.ts - Cache invalidation

Notion Database Schema

Properties Structure

interface NotionPostProperties {
  // Required properties
  title: {
    type: "title";
    title: Array<RichTextItemResponse>;
  };

  date: {
    type: "date";
    date: { start: string } | null;
  };

  description: {
    type: "rich_text";
    rich_text: Array<RichTextItemResponse>;
  };

  slug: {
    type: "rich_text";
    rich_text: Array<RichTextItemResponse>;
  };

  tags: {
    type: "multi_select";
    multi_select: Array<{
      name: string;
      color: string;
    }>;
  };

  status: {
    type: "select";
    select: {
      name: "published" | "draft" | "archived";
    } | null;
  };
}

Field Details

  • title (Title property) - Post title
  • date (Date property) - Publication date
  • description (Rich Text) - Brief summary/​excerpt
  • slug (Rich Text) - URL-friendly identifier
  • tags (Multi-select) - Post categories/​topics
  • status (Select) - Publication status

Utility Functions

From /​src/​lib/​notion.ts

1. Get All Published Posts
import { getAllPostsFromNotion } from '@/​lib/​notion';

// Fetches all posts with status = "published"
const posts = await getAllPostsFromNotion();

Returns:

Array<{
  id: string;
  title: string;
  slug: string;
  date: string;
  description: string;
  tags: string[];
  status: string;
}>

Query Details:

  • Filters: status = "published"
  • Sorts: date descending
  • Includes: All post properties
2. Get Post by Slug
import { getPostBySlugFromNotion } from '@/​lib/​notion';

const post = await getPostBySlugFromNotion('my-post-slug');

Returns: Single post object or null if not found

3. Get Post Content (RecordMap)
import { getPostRecordMap } from '@/​lib/​notion';

// Get full page content for rendering
const recordMap = await getPostRecordMap(pageId);

Returns: ExtendedRecordMap for react-notion-x

Use: When rendering full post content with NotionRenderer

Data Extraction Patterns

Extract Plain Text from Rich Text

function extractPlainText(
  richText: Array<RichTextItemResponse>
): string {
  return richText.map(item => item.plain_text).join('');
}

// Usage
const description = post.properties.description.rich_text
  .map(item => item.plain_text)
  .join('');

Extract Title

const title = post.properties.title.title
  .map(item => item.plain_text)
  .join('');

Extract Tags

const tags = post.properties.tags.multi_select
  .map(tag => tag.name);

Extract Date

const date = post.properties.date.date?.start || '';

Caching Strategy

Pattern from /​src/​lib/​posts.ts

Two-level caching:

  1. React cache() - Request deduplication
  2. Next.js unstable_cache() - Persistent caching
import { cache } from "react";
import { unstable_cache } from "next/​cache";
import { getAllPostsFromNotion } from './​notion';

export const getAllPosts = cache(
  unstable_cache(
    async () => {
      const posts = await getAllPostsFromNotion();

      // Sort by date descending
      return posts.sort((a, b) =>
        new Date(b.date).getTime() - new Date(a.date).getTime()
      );
    },
    ["all-posts"], // Cache key
    {
      revalidate: 3600, // 1 hour
      tags: ["posts", "notion"] // For invalidation
    }
  )
);

Why Two Levels?

  • React cache(): Prevents duplicate fetches in single render
  • unstable_cache(): Persists data across requests with TTL

Cache Keys

["all-posts"]           // All posts list
["post-{slug}"]         // Individual post
["post-recordmap-{id}"] // Post content

Cache Tags

["posts", "notion"]     // Tag for bulk invalidation

On-Demand Revalidation

API Route

File: /​src/​app/​api/​revalidate/​route.ts

import { revalidateTag } from 'next/​cache';

export async function POST(request: Request) {
  // Verify secret
  const secret = request.nextUrl.searchParams.get('secret');

  if (secret !== process.env.REVALIDATION_SECRET) {
    return Response.json(
      { message: 'Invalid secret' },
      { status: 401 }
    );
  }

  // Invalidate cache
  revalidateTag('notion');

  return Response.json({
    revalidated: true,
    now: Date.now()
  });
}

Usage

Trigger from Notion webhook:

curl -X POST "https://bellog.com/​api/​revalidate?secret=YOUR_SECRET"

Manual trigger:

curl -X POST "http://localhost:3000/​api/​revalidate?secret=dev_secret"

What Gets Revalidated

All cached data with tag "notion":

  • Post list
  • Individual posts
  • Tag counts

Rendering Notion Content

With react-notion-x

import { NotionRenderer } from 'react-notion-x';
import { getPostRecordMap } from '@/​lib/​notion';

export default async function PostContent({ postId }: Props) {
  // Fetch content
  const recordMap = await getPostRecordMap(postId);

  return (
    <NotionRenderer
      recordMap={recordMap}
      fullPage={false}
      darkMode={false} // Handle with useTheme in client
      components={{
        // Custom component overrides
        Code: CustomCodeBlock,
        Collection: CustomCollection,
        Equation: CustomEquation
      }}
    />
  );
}

Custom Components

Override default rendering:

import { Code } from 'react-notion-x/​build/​third-party/​code';

// Custom code block with line numbers
function CustomCodeBlock({ block }) {
  return (
    <div className="custom-code-wrapper">
      <Code block={block} />
    </​div>
  );
}

Environment Variables

Required in .env.local

# Official Notion API
NOTION_API_KEY=secret_xxxxxxxxxxxxxxxxxxxxx
NOTION_DATABASE_ID=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

# notion-client (for react-notion-x)
NOTION_TOKEN_V2=v02%3Auser_token_or_cookie...
NOTION_ACTIVE_USER=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx

# Cache revalidation
REVALIDATION_SECRET=your_random_secret_string

Getting Values

NOTION_API_KEY:

  1. Go to https://www.notion.so/my-integrations
  2. Create new integration
  3. Copy "Internal Integration Token"

NOTION_DATABASE_ID:

  1. Open database in Notion
  2. Copy ID from URL: notion.so/​workspace/[THIS_PART]?v=...

NOTION_TOKEN_V2:

  1. Open Notion in browser
  2. DevTools → Application → Cookies
  3. Copy token_v2 value

NOTION_ACTIVE_USER:

  1. Same location as token_v2
  2. Copy notion_user_id value

Type Safety

Post Type

// /​src/​types/​index.d.ts

export interface Post {
  id: string;
  title: string;
  slug: string;
  date: string;
  description: string;
  tags: string[];
  status: PostStatus;
}

export type PostStatus = 'published' | 'draft' | 'archived';

Accessing Properties

// ✅ Type-safe access
import type { QueryDatabaseResponse } from '@notionhq/​client/​build/​src/​api-endpoints';

const page = response.results[0] as PageObjectResponse;

if (page.properties.title.type === 'title') {
  const title = page.properties.title.title
    .map(t => t.plain_text)
    .join('');
}

Error Handling

API Call Errors

try {
  const posts = await getAllPostsFromNotion();
  return posts;
} catch (error) {
  console.error('Failed to fetch posts:', error);

  // Return fallback
  return [];

  // Or rethrow for error boundary
  throw new Error('Failed to load posts');
}

Missing Properties

// Check property exists and has value
const date = post.properties.date?.date?.start || new Date().toISOString();

// Provide defaults
const description = post.properties.description?.rich_text?.[0]?.plain_text || '';

Notion API Rate Limits

Limits:

  • 3 requests per second
  • Averaged over 1-minute window

Mitigation:

  • Caching with unstable_cache
  • Revalidate on-demand instead of frequent polling
  • Use TTL (1 hour) to reduce API calls

Adding New Properties

Steps

  1. Add to Notion Database

    • Open database in Notion
    • Add new property with desired type
  2. Update TypeScript Types

    // /​src/​types/​index.d.ts
    export interface Post {
      // ... existing fields
      author?: string; // New field
    }
    
  3. Extract in Notion Client

    // /​src/​lib/​notion.ts
    export async function getAllPostsFromNotion() {
      // ... existing code
    
      const posts = results.map(page => ({
        // ... existing fields
        author: extractPlainText(page.properties.author.rich_text)
      }));
    }
    
  4. Invalidate Cache

    curl -X POST "http://localhost:3000/​api/​revalidate?secret=dev_secret"
    

Best Practices

1. Always Cache

// ✅ Correct - Always use caching
export const getPosts = cache(
  unstable_cache(
    async () => await notion.query(...),
    ['key'],
    { revalidate: 3600 }
  )
);

// ❌ Wrong - Direct API calls
export async function getPosts() {
  return await notion.query(...); // No caching!
}

2. Handle Missing Data

// ✅ Correct - Provide defaults
const title = page.properties.title?.title?.[0]?.plain_text || 'Untitled';

// ❌ Wrong - Can crash if undefined
const title = page.properties.title.title[0].plain_text;

3. Use Tags for Invalidation

// ✅ Correct - Use tags
unstable_cache(
  fetchFunction,
  ['key'],
  { tags: ['posts', 'notion'] } // Can invalidate by tag
);

// ❌ Wrong - No tags
unstable_cache(
  fetchFunction,
  ['key'],
  {} // Can't invalidate efficiently
);

4. Separate Concerns

// ✅ Correct - API calls in notion.ts, caching in posts.ts
// /​src/​lib/​notion.ts
export async function getAllPostsFromNotion() { }

// /​src/​lib/​posts.ts
export const getAllPosts = cache(unstable_cache(...));

// ❌ Wrong - Mix concerns
export const getAllPosts = async () => {
  const response = await notion.databases.query(...); // Mixed!
}

5. Type Everything

// ✅ Correct - Explicit types
import type { PageObjectResponse } from '@notionhq/​client/​build/​src/​api-endpoints';

const page = result as PageObjectResponse;

// ❌ Wrong - Any types
const page: any = result;

Performance Tips

1. RecordMap Caching

// Cache post content separately
export const getPostContent = cache(
  unstable_cache(
    async (id: string) => await getPostRecordMap(id),
    ['post-content'],
    { revalidate: 3600, tags: ['notion'] }
  )
);

2. Parallel Fetching

// Fetch multiple posts in parallel
const [post1, post2] = await Promise.all([
  getPostBySlug('slug-1'),
  getPostBySlug('slug-2')
]);

3. Partial Revalidation

// Only revalidate specific posts
revalidatePath(`/​posts/​${slug}`);

// Instead of everything
revalidateTag('notion');

Common Patterns

Get Recent Posts

export async function getRecentPosts(limit: number = 5) {
  const allPosts = await getAllPosts();
  return allPosts.slice(0, limit);
}

Filter by Tag

export async function getPostsByTag(tag: string) {
  const allPosts = await getAllPosts();
  return allPosts.filter(post => post.tags.includes(tag));
}

Count Posts by Tag

// /​src/​lib/​tags.ts
export async function getTagCounts() {
  const posts = await getAllPosts();
  const counts = new Map<string, number>();

  posts.forEach(post => {
    post.tags.forEach(tag => {
      counts.set(tag, (counts.get(tag) || 0) + 1);
    });
  });

  return Array.from(counts.entries())
    .map(([tag, count]) => ({ tag, count }))
    .sort((a, b) => b.count - a.count);
}

Troubleshooting

Issue: Stale Data

Solution: Trigger revalidation

curl -X POST "/​api/​revalidate?secret=YOUR_SECRET"

Issue: Rate Limited

Check: Are you calling API too frequently? Solution: Increase cache TTL, reduce manual revalidation

Issue: Missing Properties

Check: Is property name exactly matching? Solution: Use Notion API to inspect property names

Issue: Empty Content

Check: Is NOTION_TOKEN_V2 valid? Solution: Re-copy token from browser cookies

Quick Reference

// Fetch all posts
import { getAllPosts } from '@/​lib/​posts';
const posts = await getAllPosts();

// Fetch single post
import { getPostBySlugFromNotion } from '@/​lib/​notion';
const post = await getPostBySlugFromNotion('slug');

// Fetch post content
import { getPostRecordMap } from '@/​lib/​notion';
const recordMap = await getPostRecordMap(postId);

// Render content
import { NotionRenderer } from 'react-notion-x';
<NotionRenderer recordMap={recordMap} />

// Invalidate cache
revalidateTag('notion');

// Environment variables
NOTION_API_KEY
NOTION_DATABASE_ID
NOTION_TOKEN_V2
NOTION_ACTIVE_USER
REVALIDATION_SECRET

Remember: Notion is the source of truth. Always cache and always handle missing data gracefully.

More skills from majiayu000

xiaohongshu logo
majiayu000/claude-arsenal

xiaohongshu

xiaohongshu

286 148
View
agent-task-conductor logo
majiayu000/claude-skill-registry

agent-task-conductor

Conduct multi-agent task orchestration and workflow coordination.

663 1
View
conductor-setup logo
majiayu000/claude-skill-registry

conductor-setup

Initialize project with Conductor artifacts (product definition,

663 1
View
animation-designer logo
majiayu000/claude-skill-registry

animation-designer

Expert in web animations, transitions, and motion design using Framer Motion and CSS

663 1
View
diagramming logo
majiayu000/claude-skill-registry

diagramming

Creates Mermaid and ASCII diagrams for flowcharts, architecture, ERDs, state machines, mindmaps, and more. Use when user mentions diagram, flowchart, mermaid, ASCII diagram, text diagram, terminal diagram, visualize, C4, mindmap, architecture diagram, sequence diagram, ERD, or needs visual docume...

663 1
View
h3-pg logo
majiayu000/claude-skill-registry-data

h3-pg

PostgreSQL bindings for H3 hexagonal grid system. Use when working with H3 cells in Postgres, including spatial indexing, geometry/geography integration, and raster analysis.

23 1
View
conductor-development logo
majiayu000/claude-skill-registry

conductor-development

Context-Driven Development skill for projects using Conductor. Use this skill when you detect a `conductor/` directory in the project, when working on tasks defined in a `plan.md` file, or when the user asks about tracks, specs, or plans. Automatically applies TDD workflow, tracks task completion...

663 1
View
conductor-status logo
majiayu000/claude-skill-registry

conductor-status

Display project status, active tracks, and next actions

663 1
View
dockerization logo
majiayu000/claude-skill-registry

dockerization

Official Stakpak application containerization standard operating procedure, a step-by-step guidline to properly dockerize applications. This is a rule book curated by the Stakpak Team.

663 1
View

Popular AI tools

Kaiber logo
Video

Kaiber

Generate, edit, and beat-sync AI video with leading models in one workspace.

Paid
View
Vimcal logo
Productivity

Vimcal

The world's fastest calendar for remote work

Free
View

Transform Your Design with AI Designer by ImgCreator.ai

Freemium
View
Akool AI logo
Content & writing

Akool AI

Revolutionizing Video Production with AI-Powered Creativity

Paid
View

Extend an image past the frame and let AI fill the new aspect ratio.

Freemium
View
StarByFace logo
Security

StarByFace

Discover your celebrity doppelgänger with StarByFace!

Free
View
C

ChainClarity explains 700+ crypto whitepapers in plain English, with layered summaries, comparisons, research tools, alerts, and a $4.99 Pro plan.

Freemium
View
Opus Clip logo
Coding & apps

Opus Clip

Opus.ai: Revolutionize Your Web Experience

Free
View