Next.js 16 + Notion API で個人ブログを作ってつまずいた3つのポイント

Next.js 16 + Notion API で個人ブログを作ってつまずいた3つのポイント

Next.js開発エンジニア

TL;DR

  • Notion をヘッドレス CMS として Next.js 16 で個人ブログを作る際、キャッシュ戦略・ページネーション・ブロック型の取り回しで詰まりやすい
  • 特に Next.js 16 のデフォルトキャッシュ挙動と Notion 側の更新タイミングがズレると「記事を直したのに反映されない」事故が起こる
  • この記事では実際にハマった3点と、採用した回避策を具体的なコードレベルで共有します

普段は建設部材の調達プラットフォームを作る AI エンジニアとして働いているのですが、個人の学びを外に出すために blog.skllll.com を立ち上げました。構成は Next.js 16(App Router)+ Notion API(@notionhq/client)+ Vercel デプロイという、よくあるヘッドレス CMS 構成です。

「Notion に書いて、ビルドしたら勝手に公開される」という運用は一見シンプルですが、実際に作ってみると想定外の挙動にそこそこ時間を溶かしました。同じ構成で個人ブログを立てたい駆け出しエンジニア向けに、つまずきやすいポイントを3つにまとめます。

問題

ざっくり言うと、ぶつかったのは以下の3点です。

  1. Next.js 16 のキャッシュ挙動と Notion の更新反映タイミングのズレ
  2. databases.query の100件制限とページネーションの取り回し
  3. ブロック型(blocks.children.list)のユニオン型と TypeScript の型ガード

解決方法とハマりどころ

ハマりどころ1: Next.js 16 のキャッシュ挙動と Notion の反映タイミング

App Router では fetch ベースのキャッシュと、ルートレベルの revalidate セグメントオプションの二段構えでキャッシュ制御します。Next.js 14 以降、デフォルトのキャッシュ戦略はバージョンごとに段階的に変更されてきたため、以前の記事の書き方をそのまま真似ると「はずの挙動」と違う結果になります(バージョン依存のため、利用するバージョンの公式ドキュメントで必ず確認してください)。

Notion API は @notionhq/client 内部で fetch を使っているため、Next.js のキャッシュ層の影響を受けます。つまり「Notion で記事を直したのに、本番で古いままになる」事故が発生しうるわけです。

私は以下の方針に落ち着きました。

// app/posts/[slug]/page.tsx
export const revalidate = 60; // ISR: 60秒ごとに再検証

import { Client } from "@notionhq/client";

const notion = new Client({ auth: process.env.NOTION_TOKEN });

export default async function PostPage({
  params,
}: {
  params: Promise<{ slug: string }>;
}) {
  const { slug } = await params; // Next.js 15 以降、params は Promise
  const res = await notion.databases.query({
    database_id: process.env.NOTION_DB_ID!,
    filter: {
      property: "slug",
      rich_text: { equals: slug },
    },
    page_size: 1,
  });

  const page = res.results[0];
  if (!page) return <div>Not Found</div>;

  // ... 本文レンダリング
}

ポイントは、

  • ルート全体の revalidate を明示して ISR にする
  • 「今すぐ反映したい」ケースは Notion 側に Webhook を置く or 管理画面から revalidatePath() を叩く API を用意する
  • 開発中はキャッシュ挙動が混乱のもとになるので、ローカル確認時はクエリパラメータ等で強制再取得する関数を分けておく

ハマりどころ2: databases.query の100件制限とページネーション

Notion API の databases.query は1回のレスポンスで最大100件しか返しません。記事が100件を超えると、そのままでは一覧ページから古い記事が消えることになります。レスポンスに含まれる has_morenext_cursor を使ってループする形に直す必要があります。

import { Client } from "@notionhq/client";

const notion = new Client({ auth: process.env.NOTION_TOKEN });

export async function queryAllPosts(databaseId: string) {
  const results: any[] = [];
  let cursor: string | undefined = undefined;

  while (true) {
    const res = await notion.databases.query({
      database_id: databaseId,
      start_cursor: cursor,
      page_size: 100,
      filter: {
        property: "Status",
        status: { equals: "Published" },
      },
    });

    results.push(...res.results);

    if (!res.has_more) break;
    cursor = res.next_cursor ?? undefined;
  }

  return results;
}

ここで注意したいのは、

  • next_cursorstring | null なので ?? undefined 相当の処理が必要
  • ループ中に新しい記事が追加されると、同じ記事が二重に返る・取りこぼす可能性があるため、本番の一覧更新は ISR と組み合わせるか、ビルド時に一括取得する設計にする
  • 当然ですが API のレート制限(Notion API は概ね秒間数リクエスト程度が目安)があるので、件数が多い場合は p-limit などで並列度を絞る

ハマりどころ3: ブロック型のユニオン型と TypeScript の型ガード

Notion のページ本文は blocks.children.list で取得します。返ってくるブロックは段落・見出し・コード・画像などを含むユニオン型(BlockObjectResponse)で、block.type で分岐しないと各フィールドにアクセスできません。TypeScript 初心者だと、ここで any に逃げて事故るパターンが多い印象です。

import type { BlockObjectResponse } from "@notionhq/client/build/src/api-endpoints";

type Block = BlockObjectResponse;

function renderBlock(block: Block) {
  switch (block.type) {
    case "paragraph":
      return (
        <p>
          {block.paragraph.rich_text.map((t, i) => (
            <span key={i}>{t.plain_text}</span>
          ))}
        </p>
      );
    case "heading_2":
      return (
        <h2>
          {block.heading_2.rich_text.map((t) => t.plain_text).join("")}
        </h2>
      );
    case "code":
      return (
        <pre>
          <code>
            {block.code.rich_text.map((t) => t.plain_text).join("")}
          </code>
        </pre>
      );
    default:
      // 未対応ブロックはスキップしてログに残す
      console.warn("unsupported block type:", block.type);
      return null;
  }
}

ハマりがちなのは以下。

  • children を持つブロック(トグル・コールアウト等)は has_childrentrue のときに再帰的に blocks.children.list を叩く必要がある
  • 画像ブロックの file.url は署名付き URL で有効期限があるため、ビルド時に取得したものをそのまま長期キャッシュすると切れる
  • 型ガードは block.type === "paragraph" のような文字列リテラル比較で効かせるのがシンプル

まとめ・次にやること

この構成で個人ブログを立てる際の勘所を整理すると、

  • キャッシュは Next.js のバージョンに合わせて明示的に設計する(revalidate を書く、更新トリガーを用意する)
  • ページネーション関数は最初から再利用できる形で切る
  • ブロック型はユニオン型で降ってくる前提で、switch と型ガードを素直に書く

次は Notion のプロパティ変更に対する型の自動生成(zod ベースのバリデーション + 型生成)と、記事下書きを AI に壁打ちさせるパイプラインを組んでいきます。個人ブログは「自分の学習基盤」と割り切ると、多少実験的な構成でも回しやすいのでおすすめです。