smithery/whddltjdwhd

bellog-notion

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

Installation

$ npx skills add smithery/whddltjdwhd --skill bellog-notion

Also in this package

Other skills from smithery/whddltjdwhd.

npx skills add smithery/whddltjdwhd

Browse all from smithery/whddltjdwhd

More details

Agent compatibility

Declared targets from SKILL.md / docs. Unmarked agents are not listed — the skill may still install via the CLI.

Claude Code Not declared
Cursor Not declared
Codex Not declared
GitHub Copilot Not declared
Windsurf Not declared
Gemini CLI Not declared
Cline Not declared
OpenCode Not declared

Package contents

Files included with this skill beyond the listing page.

  • skill md SKILL.md 13,767 B
  • docs SUMMARY.md 170 B

History

  1. First recorded snapshot · 0 installs

SKILL.md

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

NOTIONAPIKEY:

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

NOTIONDATABASEID:

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

NOTIONTOKENV2:

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

NOTIONACTIVEUSER:

  1. Same location as token_v2
  2. Copy notionuserid 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

  1. Update TypeScript Types

``typescript // /src/types/index.d.ts export interface Post { // ... existing fields author?: string; // New field } ``

  1. Extract in Notion Client

```typescript // /src/lib/notion.ts export async function getAllPostsFromNotion() { // ... existing code

const posts = results.map(page => ({ // ... existing fields author: extractPlainText(page.properties.author.rich_text) })); } ```

  1. Invalidate Cache

``bash 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 NOTIONTOKENV2 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.