Next.js 16 + Notion API で個人ブログを作ってつまずいた3つのポイント
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点です。
- Next.js 16 のキャッシュ挙動と Notion の更新反映タイミングのズレ
-
databases.queryの100件制限とページネーションの取り回し -
ブロック型(
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_more と next_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_cursorはstring | 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_childrenがtrueのときに再帰的にblocks.children.listを叩く必要がある -
画像ブロックの
file.urlは署名付き URL で有効期限があるため、ビルド時に取得したものをそのまま長期キャッシュすると切れる -
型ガードは
block.type === "paragraph"のような文字列リテラル比較で効かせるのがシンプル
まとめ・次にやること
この構成で個人ブログを立てる際の勘所を整理すると、
-
キャッシュは Next.js のバージョンに合わせて明示的に設計する(
revalidateを書く、更新トリガーを用意する) - ページネーション関数は最初から再利用できる形で切る
-
ブロック型はユニオン型で降ってくる前提で、
switchと型ガードを素直に書く
次は Notion のプロパティ変更に対する型の自動生成(zod ベースのバリデーション + 型生成)と、記事下書きを AI に壁打ちさせるパイプラインを組んでいきます。個人ブログは「自分の学習基盤」と割り切ると、多少実験的な構成でも回しやすいのでおすすめです。