Claude Code Plugins

Community-maintained marketplace

Feedback

bellog-notion

@whddltjdwhd/bellog
2
0

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

Install Skill

1Download skill
2Enable skills in Claude

Open claude.ai/settings/capabilities and find the "Skills" section

3Upload to Claude

Click "Upload skill" and select the downloaded ZIP file

Note: Please verify skill by going through its instructions before using it.

SKILL.md

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

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.