【実装】Notion APIでブログシステムを構築する(Next.js 13 App Router × SDK v5)

この記事でわかること
- Notion SDK v5 で変わった API(databases → dataSources)の使い方
- Notion の Block を Markdown に変換する実装
- Notion API のレート制限をどこで逃げるか(そしてモジュールスコープに貯めてはいけない理由)
- スキーマからカテゴリ・タグの選択肢を動的に取る方法
Node.js 18 以上、Next.js 13(App Router)、@notionhq/client v5。Notion のインテグレーションが作成済みであること。
概要: Notion SDK v5とNext.js 13 App Routerを使って、Notionをバックエンドにしたブログシステムを構築する方法をまとめました。dataSources API、Blocks→Markdown変換、ISR設定など実装の詳細を解説します。
はじめに
NotionをヘッドレスCMSとして使い、Next.jsでブログを構築する手法が注目されている。公式SDKのv5では APIが大きく変更されたため、最新の実装パターンを解説する。
1. Notion SDK v5のセットアップ
インストール
npm install @notionhq/clientクライアント初期化
import { Client } from '@notionhq/client';
const notion = new Client({ auth: process.env.NOTION_API_KEY });v5での主要な変更点
| v4以前 | v5 |
|---|---|
| notion.databases.query() | notion.dataSources.query() |
| database_id パラメータ | data_source_id パラメータ |
| notion.databases.retrieve() | notion.dataSources.retrieve() |
2. データベース設計
推奨プロパティ構成
| プロパティ | 型 | 説明 |
|---|---|---|
| Title | title | 記事タイトル |
| Slug | rich_text | URLパス用のスラッグ |
| Category | select | カテゴリ分類 |
| Tags | multi_select | 技術タグ |
| Status | select | Draft / Published / Archived |
| Created | date | 公開日 |
| Eyecatch | url | アイキャッチ画像URL |
3. 記事一覧の取得
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 };
};4. Blocks → Markdown変換
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('');
}5. ISR設定
Notion APIにはレート制限(3リクエスト/秒)があるため、全ページをビルド時に静的生成するとタイムアウトする。ISRで解決。
export const revalidate = 3600; // 1時間キャッシュ
export const dynamicParams = true;
export async function generateStaticParams() {
return []; // ビルド時には生成しない
}revalidate も generateStaticParams() も使えません。その場合はリクエスト時に取得し、Cache-Control: s-maxage と CDN キャッシュで ISR 相当の振る舞いを作ることになります。その辺のトレードオフは Cloudflare Workers→Pages出戻り記 に書いています。リクエスト内のメモ化
同じリクエストの中で getList() が何度も呼ばれる(一覧と generateMetadata など)のを防ぐなら、React の cache() を使います。
import { cache } from 'react';
export const getList = cache(async () => {
// ... API呼び出し
return result;
});モジュールスコープの変数(let listCache = null のような形)に貯めてはいけません。筆者は実際にこれをやって事故にしました。Cloudflare Workers や Lambda のような実行環境では、インスタンス(isolate)が複数のリクエストで使い回されるため、一度埋まったキャッシュが生きている間ずっと返り続けます。結果、新しい記事を公開しても一覧に出てこない(しかもリロードすると出たり出なかったりする)という厄介なバグになります。
リクエストをまたいだキャッシュは、プロセスの外(CDN キャッシュや ISR)に任せるのが原則です。
6. カテゴリ・タグ一覧の取得
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
}))
};
};Tips
file タイプ)は 1 時間で期限切れになります。外部 URL の画像を使うか、R2 等にアップロードして永続 URL にしてください。キャッシュした HTML に埋めた場合、後から画像だけが壊れるので原因に気づきにくいです。dataSources.retrieve でスキーマを取得すれば、カテゴリやタグの選択肢をハードコードする必要がない参考リンク
- Notion API公式
- Next.js App Router
- @notionhq/client
- microCMSからNotion APIへブログCMSを完全移行する(別記事)
- Notion calloutブロックをNext.jsでカラフルUIにする(別記事)
まとめ
- Notion SDK v5ではdataSources APIを使う
- Blocks→Markdown変換で記事本文を取得
- レート制限は ISR や CDN キャッシュで逃げる。モジュールスコープの変数に貯めない(isolate が使い回されて古いデータが居座る)
- スキーマからカテゴリ・タグを動的取得
- Notionは無料でヘッドレスCMSとして十分実用的
更新履歴
- 「メモリキャッシュの追加」の実装を訂正。モジュールスコープの変数に貯める形を推奨していたが、Workers の isolate が使い回されるため古い一覧が返り続ける事故を実際に起こしている。React の cache() を使う形に差し替え、理由を警告として追記。Tips の同旨の記述も削除し、レート制限で 503 が返る件に差し替え。ISR が Edge Runtime では使えないことを注記。空の「目次」見出しを自動目次に差し替え。引用形式の Tips を callout に統一。参考リンクに関連記事を追加。「この記事でわかること」「対象読者」「前提条件」を概要プロパティへ移動。


