Claude-skill-registry bellog-notion
Provides Notion CMS integration patterns and best practices for Bellog. Triggers when working with Notion data or implementing Notion-based features.
git clone https://github.com/majiayu000/claude-skill-registry
T=$(mktemp -d) && git clone --depth=1 https://github.com/majiayu000/claude-skill-registry "$T" && mkdir -p ~/.claude/skills && cp -r "$T/skills/data/bellog-notion" ~/.claude/skills/majiayu000-claude-skill-registry-bellog-notion && rm -rf "$T"
skills/data/bellog-notion/SKILL.mdBellog 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
- Notion API client & queries/src/lib/notion.ts
- Cached data fetching/src/lib/posts.ts
- Tag aggregation/src/lib/tags.ts
- Type definitions/src/types/index.d.ts
- Cache invalidation/src/app/api/revalidate/route.ts
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
/src/lib/notion.ts1. 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:
descendingdate - 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
/src/lib/posts.tsTwo-level caching:
- React
- Request deduplicationcache() - Next.js
- Persistent cachingunstable_cache()
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
.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:
- Go to https://www.notion.so/my-integrations
- Create new integration
- Copy "Internal Integration Token"
NOTION_DATABASE_ID:
- Open database in Notion
- Copy ID from URL:
notion.so/workspace/[THIS_PART]?v=...
NOTION_TOKEN_V2:
- Open Notion in browser
- DevTools → Application → Cookies
- Copy
valuetoken_v2
NOTION_ACTIVE_USER:
- Same location as token_v2
- Copy
valuenotion_user_id
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
-
Add to Notion Database
- Open database in Notion
- Add new property with desired type
-
Update TypeScript Types
// /src/types/index.d.ts export interface Post { // ... existing fields author?: string; // New field } -
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) })); } -
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.