[3/5] `unstable_cache` の中で `cookies()` を呼ぶと死ぬ — 本番で 38 件焼けてたエラーの正体
卒業翌日、 ぼくがメインで開発してる中古品 AI 査定 Web サービスの preview 環境で、 同じ根本原因で 38 件のエラーが焼かれてました。 `unstable_cache` と `cookies()` の境界線を踏み越えたときの Next.js App Router の振る舞いと、 ぼくの修正方針を書きます。
これは Part 2 の続きで、 OpenClaw 卒業の翌日(つまり昨晩)の話です。
朝、 Sentry の通知をまとめて開いてみたら、 ぼくがメインで開発を担当してる中古品 AI 査定の Web サービスの preview 環境で、 同じ根本原因に紐づくエラーが 計 38 件溜まってました。
このサービスはまだ未リリースで、 アクセスできるのはぼくと数人のテスターだけ。 ユーザー影響は数値上「0 人」やったんですが、 ページが 500 を返してるのは事実。 これは寝かしておけない。
原因を追ったら、 Next.js App Router の unstable_cache と cookies() の境界線を完全に踏み越えてました。 同じパターンを使ってる人、 多分結構いると思うので、 今日はその話を書きます。
症状: /faq と /news が 500 連発
Sentry のグループは 3 つに分かれてました。
- A 系:
Error: Route /faq used cookies() inside a function cached with unstable_cache(). ...(22 events) - B 系:
Error: An error occurred in the Server Components render.(11 events) - C 系:
Error: Rendered more hooks than during the previous render.(5 events)
A 系のエラーメッセージが、 ほぼ犯人を名指ししてくれてます。
Accessing Dynamic data sources inside a cache scope is not supported.「キャッシュスコープの中で動的データソースにアクセスできません」
B 系と C 系は、 A 系の連鎖崩壊で React の render が壊れたあとに出てた二次災害。 主犯を直せば 3 つとも消えるはず、 と予想しました。
主犯: unstable_cache() の中で await createClient() を呼んでた
該当の Server Action はこんな構造です。
"use server";
import { unstable_cache } from "next/cache";
import { createClient } from "@/utils/supabase/server";
const fetchFaqListCached = (categoryKey: string, searchKey: string) =>
unstable_cache(
async () => {
const supabase = await createClient(); // ← ココ
const { data } = await supabase
.from("faq_items")
.select("...")
.eq("status", "published");
return data ?? [];
},
["faq-list", categoryKey, searchKey],
{ revalidate: 300, tags: ["faq"] },
)();unstable_cache で囲んでるのは、 5 分 TTL でクエリを保存して DB 負荷を抑えるためです。 公開コンテンツ(FAQ / News)なので、 全ユーザーで結果を共有してよい想定でした。
問題は await createClient() の中身。
// utils/supabase/server.ts
import { cookies } from "next/headers";
export async function createClient() {
const cookieStore = await cookies(); // ← ここで cookies() を呼んでる
return createServerClient<Database>(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{ cookies: { ... } },
);
}Supabase 公式テンプレの SSR クライアントは、 リクエストの cookie を読む設計です。 そして cookies() は Next.js の世界で Dynamic data source に分類されてます。 cache スコープの中でこれを呼ぶと、 「キャッシュ対象は静的なはずなのに動的依存が混ざってる」という整合性違反になる。
エラーメッセージは正確には:
Route /faq used cookies() inside a function cached with unstable_cache(). Accessing Dynamic data sources inside a cache scope is not supported. If you need this data inside a cached function use cookies() outside of the cached function and pass the required dynamic data in as an argument.
直訳すると「cache の中で cookies 使うな、 必要なら外で呼んで引数で渡せ」と。
どうしてこうなったか: 「公開データを cache する」と「auth クライアントを使う」が同じ関数に同居してた
書いた当時のぼくの意図はこんな感じでした。
- FAQ は公開コンテンツ(全員に同じ結果)
- だから
unstable_cacheで 5 分 TTL - とりあえず Supabase クライアントは
createClient()でいつものやつを呼べばいい
createClient() がユーザーの cookie 経由で auth 情報を読むことは知ってたんですが、 「公開データの SELECT なら結局 anon RLS で評価されるんやから、 cookie が空でも同じ結果やろ」と思ってました。 結果は同じやけど、 Next.js のフレームワーク的には「cache の中で動的依存を持つ関数」を禁止してる、 という話。
createClient() の内部で cookies() を呼んでる以上、 たとえその結果が公開データであっても、 Next.js は「これは cookie に依存する関数」と判定する。
修正: cookies を読まないクライアントを別に作る
選択肢としては:
unstable_cacheを外して、 都度 DB 叩く(キャッシュ捨てる)createClient()を呼ばず、 cookies なしのクライアントを別に作るcookies()をunstable_cacheの外で呼んで、 引数で渡す(エラーメッセージの公式アドバイス)
公開データの SELECT で cookie は要らないから、 (2) が一番スッキリ。 新しく createPublicClient() を utils/supabase/public.ts に作りました。
// utils/supabase/public.ts
import { createServerClient } from "@supabase/ssr";
import type { Database } from "@/types/database";
/**
* 公開コンテンツ用の Supabase クライアント。
* cookies() を呼ばないため unstable_cache 内で使用可能。
* RLS は anon ロールで評価される。
*/
export function createPublicClient() {
return createServerClient<Database>(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
getAll: () => [], // 常に空
setAll: () => {}, // no-op
},
},
);
}そして cache 内の使い方を切り替える:
- import { createClient } from "@/utils/supabase/server";
+ import { createPublicClient } from "@/utils/supabase/public";
const fetchFaqListCached = (categoryKey: string, searchKey: string) =>
unstable_cache(
async () => {
- const supabase = await createClient();
+ const supabase = createPublicClient();
// … 以下は変更なし、 公開データの SELECT
},
...
)();cookies 周りを完全に呼ばないので、 unstable_cache の制約を踏まない。 RLS は anon ロールで評価されるけど、 そもそも .eq("status", "published") で公開コンテンツに絞ってるから、 anon でも結果は同じです。
修正対象は計 4 ファイル: actions/get-faq-list.ts / actions/get-faq-detail.ts / actions/get-news-list.ts / actions/get-news-detail.ts。 同じパターンが繰り返されてました。
`unstable_cache` の中で使っていいクライアントの判別
判別はシンプルで、 「呼び出すクライアントが内部で cookies() headers() auth() を呼ぶか」だけ確認してください。
- ✅ 呼ばない: 公開データ用の anon クライアント / service-role クライアント
- ❌ 呼ぶ: Supabase 公式テンプレの SSR
createClient()、 NextAuth のauth()ヘルパー、 自前でcookies()を呼ぶ wrapper
「結果が同じやから問題ない」 と思っても、 Next.js の判定基準は「結果」ではなく「呼んでる関数」なので、 関数ベースで考えるとミスらないです。
検証: deploy 後の preview を直叩きして 200 になることを確認
修正を develop ブランチに push したら、 Vercel が preview を更新してくれます。 deploy 完了を待って、 該当の URL を curl で叩いてみました。
vercel curl --deployment https://...-preview.vercel.app "/faq" -- -s -o /dev/null -w "HTTP=%{http_code}\n"
# HTTP=200 ✅
vercel curl --deployment https://...-preview.vercel.app "/news" -- -s -o /dev/null -w "HTTP=%{http_code}\n"
# HTTP=200 ✅200 が返ってきたので、 本番(preview)では同じエラーが再発生しない状態になったはず。 Sentry の該当 issue 3 つを resolved に手動マーク。
実は Sentry には「Fixes <ISSUE_ID> を commit message に書いたら、 deploy 時に自動 close する」 仕組みもあるんですが、 ぼくのプロジェクトは未リリースで Production deploy を意図的に避けてる(develop でしか走らせてない)ので、 release 検出が走らない。 なので手動 close でカバー。 ここは今後の改善ポイントとして memo しときました。
教訓
unstable_cacheの中で呼ぶクライアントは、cookies()/headers()/auth()を一切呼ばないものを選ぶ- 「結果が同じだから」じゃなく **「呼んでる関数」**で判別する
- 公開コンテンツ用の anon クライアントを別ファイルに切り出すと、 関数の意図が明確になって今後の事故も減る
- preview のみで Sentry を運用してる場合、
Fixes <ID>自動 close は効きにくいので、 手動 close 運用に振り切るのも一手
まとめ
このエラーは、 同パターンを使ってる Next.js App Router プロジェクトなら誰でも踏みうる罠です。 ぼくみたいに「公開データを cache してる Server Action」を書いてる場合、 一度全 Server Action を grep で見直してみることをおすすめします。
# 同じ罠を踏んでないかチェック
grep -rln "unstable_cache" actions/
grep -rln "createClient" actions/
# 両方ヒットするファイルは要確認次の Part では、 こういう「本番で 38 件焼けてた」みたいな事故を 次は deploy 前に検出するために、 Playwright と GitHub Actions で E2E 自動化を 1 時間で組んだ話を書きます。