きっかけ
このブログは、フロントエンドに Next.js、CMS に Sanity、デプロイ先に Netlify を使っています。
最初は、Sanity Studio で記事を公開・編集すると、サイト側にもすぐ反映される構成にしていました。これは便利なのですが、実装を見直してみると、記事ページや一覧ページで force-dynamic を使っており、アクセスのたびに Netlify Functions 上で Sanity API を読みに行く形になっていました。
小規模な個人ブログならすぐ問題になるわけではありません。ただ、Netlify Free で運用するなら、アクセスごとに動的レンダリングするより、なるべく静的に返せる構成の方が安心です。
そこで今回は、通常アクセスでは静的HTMLを返し、Sanityで記事を投稿・編集したときだけ必要なページを再検証する構成に変更しました。
変更前の構成
変更前は、トップページ、記事一覧、記事詳細、カテゴリページ、タグページなどに force-dynamic を指定していました。export const dynamic = "force-dynamic";
この場合、ページ表示のたびにサーバー側で処理が走り、Sanity からデータを取得します。
流れとしてはこうです。ユーザーがページにアクセス
→ Netlify Function が実行される
→ Sanity API から記事データを取得
→ HTMLを生成して返す
Sanity Studioで記事を更新するとすぐ反映されるので、体験としては分かりやすいです。
ただし、アクセスが増えるほど Netlify Functions の実行回数や Sanity API request も増えます。記事サイトとしては、毎回動的に生成しなくてもよいページが多いので、少しもったいない構成でした。
検討した選択肢
考えた選択肢は大きく3つです。
1つ目は、完全に静的なサイトにして、記事更新のたびに Netlify で再デプロイする方法です。かなりシンプルで、アクセス時の負荷も低くできます。ただ、記事を少し直すだけでもサイト全体をビルドし直すことになります。
2つ目は、時間ベースの ISR を使う方法です。たとえば revalidate = 300 にすると、最大5分ごとに再生成されます。ただし、記事が更新されていなくても、期限切れ後にアクセスがあれば再生成が起きます。
3つ目は、Sanity の webhook を使って、記事が投稿・編集されたときだけ Next.js の revalidate API を呼ぶ方法です。
今回選んだのは3つ目です。通常アクセス
→ 静的HTMLを返す
Sanityで記事を投稿・編集
→ Sanity webhook
→ /api/revalidate
→ 必要なページだけ再検証
記事更新時だけ動けばよいので、Netlify Free での運用にも向いています。
通常ページを静的生成に戻す
まず、公開ページから force-dynamic を外しました。
代わりに、時間経過では再生成しないようにします。export const revalidate = false;
対象にしたのは、トップページ、記事一覧、記事詳細、カテゴリページ、タグページ、sitemap です。
これで、通常の公開ページはビルド時に静的生成されます。アクセスが来るたびに Sanity を読みに行く構成ではなくなります。
Sanity fetch をキャッシュする
公開データ用の Sanity client では CDN を使うようにしました。useCdn: !isDraftPerspective
公開ページ用の fetch には force-cache と cache tag を付けています。{
cache: "force-cache",
next: {
tags: ["sanity-content"]
}
}
この cache tag は、後で webhook からまとめて再検証するために使います。
一方で、プレビュー表示では常に最新の draft を読みたいので、cache: "no-store" にしています。
プレビューは別ルートに分離
通常ページを静的に寄せるうえで、draft preview と混ざらないようにする必要がありました。
そこで、公開記事ページとは別に、プレビュー専用のルートを用意しました。公開記事ページ:
/posts/[slug]
プレビュー専用ページ:
/preview/posts/[slug]
公開ページは静的生成、プレビューページだけ動的レンダリングにします。
Sanity Studio の「プレビュー」アクションも、公開URLではなく /preview/posts/[slug] を開くように変更しました。
これで、通常ページの静的化を保ちつつ、Sanity Studio からは draft を確認できます。
/api/revalidate を作る
次に、Sanity webhook から呼ぶための API route を作りました。/api/revalidate
この API は、Sanity から送られてきた payload を見て、必要なページだけ再検証します。
たとえば記事更新時には、以下のような payload を受け取ります。{
"_type": "post",
"slug": "akg-k872-review",
"categorySlug": "headphone",
"tagSlugs": ["review"]
}
この場合、再検証するパスは次のようになります。/
/posts
/sitemap.xml
/posts/akg-k872-review
/categories/headphone
/tags/review
記事詳細だけではなく、トップページや記事一覧、カテゴリ、タグ、sitemap も更新対象にしています。
新規記事の場合、記事詳細だけ更新しても一覧に出ないためです。
認証には Bearer token を使う
誰でも /api/revalidate を叩けると困るので、認証を入れました。
Netlify 側には、環境変数として secret を設定します。SANITY_REVALIDATE_SECRET=ランダムな長い文字列
Sanity webhook 側では、HTTP header に同じ値を入れます。Authorization: Bearer ランダムな長い文字列
API 側では、この header の値と process.env.SANITY_REVALIDATE_SECRET を比較します。
一致しなければ 401 Unauthorized を返します。
Sanity webhook の設定
Sanity の管理画面で webhook を作りました。
基本設定
Name:
Sound Notes - Revalidate Production
URL:
https://sound-notes.net/api/revalidate
Method:
POST
Trigger:
Create、Update、Delete を有効化
Filter
_type in ["post", "category", "tag"] && !(_id in path("drafts.**"))
draft の自動保存ではなく、公開側のドキュメント変更だけを対象にするためです。
Projection
{
"_type": _type,
"slug": slug.current,
"categorySlug": category->slug.current,
"tagSlugs": tags[]->slug.current
}
HTTP headers
Authorization: Bearer <SANITY_REVALIDATE_SECRET>
Sanity の webhook 設定には Secret という欄もありますが、今回はそこは使っていません。今回は HTTP header の Authorization を使って認証しています。
変更してよかったこと
一番大きいのは、アクセスごとに Sanity を読みに行かなくてよくなったことです。
記事サイトでは、多くのページは頻繁に変わりません。記事を投稿・編集したタイミングだけ更新できれば十分です。
今回の構成にしたことで、普段のアクセスは静的サイトに近い軽さになり、Sanity Studio で記事を更新したときだけ、必要なページを再検証できるようになりました。
Netlify Free で個人ブログを運用するなら、このくらいの構成がちょうどよさそうです。
まとめ
今回は、Next.js + Sanity + Netlify Free のブログを、アクセスごとに Sanity を読む構成から、Sanity webhook で必要なページだけ再検証する構成に変更しました。
内容としては
- 通常ページは静的生成する
- 時間ベースの ISR は使わない
- Sanity の公開データは CDN と cache tag を使う
- Sanity webhook から /api/revalidate を呼ぶ
- Authorization: Bearer ... で認証する
これでnetlifyのfree枠を圧迫しない、かつ重くないサイトになったと思います。ここまでの実装、ブログ記事内容も含めて全てCodexでやったのですが、これが3000円のプランにおまけでついてくるというのは本当に凄い時代ですね…