メインコンテンツへスキップ
kt-tech.blog

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

実装10分で読めます

この記事でわかること

  • 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のセットアップ

インストール

Bash
npm install @notionhq/client

クライアント初期化

TypeScript
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. 記事一覧の取得

TypeScript
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に変換する。

TypeScript
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('![](' + url + ')');
        break;
      // ... 他のブロックタイプ
    }
    lines.push('');
  }
  return lines.join('\n');
}

インライン装飾の変換

TypeScript
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で解決。

TypeScript
export const revalidate = 3600; // 1時間キャッシュ
export const dynamicParams = true;

export async function generateStaticParams() {
  return []; // ビルド時には生成しない
}
ポイント
ISR が使えるのは Node.js ランタイムの場合だけです。Cloudflare Pages の Edge Runtime では revalidategenerateStaticParams() も使えません。その場合はリクエスト時に取得し、Cache-Control: s-maxage と CDN キャッシュで ISR 相当の振る舞いを作ることになります。その辺のトレードオフは Cloudflare Workers→Pages出戻り記 に書いています。

リクエスト内のメモ化

同じリクエストの中で getList() が何度も呼ばれる(一覧と generateMetadata など)のを防ぐなら、React の cache() を使います。

TypeScript
import { cache } from 'react';

export const getList = cache(async () => {
  // ... API呼び出し
  return result;
});
重要

モジュールスコープの変数(let listCache = null のような形)に貯めてはいけません。筆者は実際にこれをやって事故にしました。Cloudflare Workers や Lambda のような実行環境では、インスタンス(isolate)が複数のリクエストで使い回されるため、一度埋まったキャッシュが生きている間ずっと返り続けます。結果、新しい記事を公開しても一覧に出てこない(しかもリロードすると出たり出なかったりする)という厄介なバグになります。

リクエストをまたいだキャッシュは、プロセスの外(CDN キャッシュや ISR)に任せるのが原則です。

6. カテゴリ・タグ一覧の取得

Notionのスキーマから直接selectオプションを取得できる。

TypeScript
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

Tips
Notion の内部画像 URL(file タイプ)は 1 時間で期限切れになります。外部 URL の画像を使うか、R2 等にアップロードして永続 URL にしてください。キャッシュした HTML に埋めた場合、後から画像だけが壊れるので原因に気づきにくいです。
Tips
dataSources.retrieve でスキーマを取得すれば、カテゴリやタグの選択肢をハードコードする必要がない
Tips
Notion API のレート制限(目安 3 req/s)に当たると 429 だけでなく 503 も返ってきます。ビルド時に全記事を取る構成なら、リトライを入れておかないと一部の記事だけ黙って欠けることがあります。

参考リンク

まとめ

  • Notion SDK v5ではdataSources APIを使う
  • Blocks→Markdown変換で記事本文を取得
  • レート制限は ISR や CDN キャッシュで逃げる。モジュールスコープの変数に貯めない(isolate が使い回されて古いデータが居座る)
  • スキーマからカテゴリ・タグを動的取得
  • Notionは無料でヘッドレスCMSとして十分実用的

更新履歴

  1. 「メモリキャッシュの追加」の実装を訂正。モジュールスコープの変数に貯める形を推奨していたが、Workers の isolate が使い回されるため古い一覧が返り続ける事故を実際に起こしている。React の cache() を使う形に差し替え、理由を警告として追記。Tips の同旨の記述も削除し、レート制限で 503 が返る件に差し替え。ISR が Edge Runtime では使えないことを注記。空の「目次」見出しを自動目次に差し替え。引用形式の Tips を callout に統一。参考リンクに関連記事を追加。「この記事でわかること」「対象読者」「前提条件」を概要プロパティへ移動。

この記事のタグ