| 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.