
bellog-notion
by whddltjdwhd
Castle Bell's custom blog
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:
datedescending - 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:
- React
cache()- Request deduplication - 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:
- 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
token_v2value
NOTION_ACTIVE_USER:
- Same location as token_v2
- Copy
notion_user_idvalue
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.
Score
Total Score
Based on repository quality metrics
SKILL.mdファイルが含まれている
ライセンスが設定されている
100文字以上の説明がある
GitHub Stars 100以上
1ヶ月以内に更新
10回以上フォークされている
オープンIssueが50未満
プログラミング言語が設定されている
1つ以上のタグが設定されている
Reviews
Reviews coming soon


