
実装

NotionをヘッドレスCMSとして使い、Next.jsでブログを構築する手法が注目されている。公式SDKのv5では APIが大きく変更されたため、最新の実装パターンを解説する。
Notion SDK v5のセットアップと基本的な使い方
dataSources APIでのデータベースクエリ
NotionブロックからMarkdownへの変換実装
ISR(Incremental Static Regeneration)の設定
カテゴリ・タグ・アーカイブ機能の実装
Notion APIを使ったWebアプリ開発に興味がある方
Next.js App Routerの実践的な使い方を学びたい方
ヘッドレスCMSの選択肢としてNotionを検討している方
Node.js 18以上
Next.js 13(App Router)
@notionhq/client v5
Notion APIインテグレーション作成済み
npm install @notionhq/client
import { Client } from '@notionhq/client';
const notion = new Client({ auth: process.env.NOTION_API_KEY });
| v4以前 | v5 |
|---|---|
| notion.databases.query() | notion.dataSources.query() |
| database_id パラメータ | data_source_id パラメータ |
| notion.databases.retrieve() | notion.dataSources.retrieve() |
| プロパティ | 型 | 説明 |
|---|---|---|
| Title | title | 記事タイトル |
| Slug | rich_text | URLパス用のスラッグ |
| Category | select | カテゴリ分類 |
| Tags | multi_select | 技術タグ |
| Status | select | Draft / Published / Archived |
| Created | date | 公開日 |
| Eyecatch | url | アイキャッチ画像URL |
export const getList = async () => {
const allPages = [];
let cursor;
do {
const response = await notion.dataSources.query({
data_source_id: DATABASE_ID,
start_cursor: cursor,
page_size: 100,
filter: {
property: 'Status',
select: { equals: 'Published' },
},
sorts: [{
property: 'Created',
direction: 'descending',
}],
});
allPages.push(...response.results);
cursor = response.has_more ? response.next_cursor : undefined;
} while (cursor);
const contents = await Promise.all(
allPages.map(page => pageToBlog(page, false))
);
return { contents, totalCount: contents.length };
};
Notionの記事本文はBlockオブジェクトの配列として取得される。これをMarkdownに変換する。
async function blocksToMarkdown(blockId: string): Promise<string> {
const blocks = [];
let cursor;
do {
const response = await notion.blocks.children.list({
block_id: blockId,
start_cursor: cursor,
page_size: 100,
});
blocks.push(...response.results);
cursor = response.has_more ? response.next_cursor : undefined;
} while (cursor);
const lines = [];
for (const block of blocks) {
switch (block.type) {
case 'heading_1':
lines.push('# ' + richTextToPlain(block.heading_1.rich_text));
break;
case 'heading_2':
lines.push('## ' + richTextToPlain(block.heading_2.rich_text));
break;
case 'paragraph':
lines.push(richTextToMarkdown(block.paragraph.rich_text));
break;
case 'code':
lines.push('```' + block.code.language + '\n' + richTextToPlain(block.code.rich_text) + '\n```');
break;
case 'image':
const url = block.image.type === 'external'
? block.image.external.url
: block.image.file.url;
lines.push('');
break;
// ... 他のブロックタイプ
}
lines.push('');
}
return lines.join('\n');
}
function richTextToMarkdown(richText) {
return richText.map(t => {
let text = t.plain_text;
if (t.annotations?.bold) text = '**' + text + '**';
if (t.annotations?.italic) text = '*' + text + '*';
if (t.annotations?.code) text = '`' + text + '`';
if (t.href) text = '[' + text + '](' + t.href + ')';
return text;
}).join('');
}
Notion APIにはレート制限(3リクエスト/秒)があるため、全ページをビルド時に静的生成するとタイムアウトする。ISRで解決。
// src/app/blogs/[blogId]/page.tsx
export const revalidate = 3600; // 1時間キャッシュ
export const dynamicParams = true;
export async function generateStaticParams() {
return []; // ビルド時には生成しない
}
let listCache = null;
export const getList = async () => {
if (listCache) return listCache;
// ... API呼び出し
listCache = result;
return result;
};
Notionのスキーマから直接selectオプションを取得できる。
export const getCategoryList = async () => {
const db = await notion.dataSources.retrieve({
data_source_id: DATABASE_ID
});
const options = db.properties.Category?.select?.options || [];
return {
contents: options.map(opt => ({
id: encodeURIComponent(opt.name.toLowerCase()),
name: opt.name
}))
};
};
fileタイプ)は1時間で期限切れになる。外部URLの画像を使うか、R2等にアップロードして永続URLにすることdataSources.retrieveでスキーマを取得すれば、カテゴリやタグの選択肢をハードコードする必要がないgetListの結果をモジュールレベルでキャッシュすると効果的Notion SDK v5ではdataSources APIを使う
Blocks→Markdown変換で記事本文を取得
ISR + メモリキャッシュでレート制限を回避
スキーマからカテゴリ・タグを動的取得
Notionは無料でヘッドレスCMSとして十分実用的