[3/5] `unstable_cache` の中で `cookies()` を呼ぶと死ぬ — 本番で 38 件焼けてたエラーの正体
卒業翌日、 ぼくがメインで開発してる中古品 AI 査定 Web サービスの preview 環境で、 同じ根本原因で 38 件のエラーが焼かれてました。 `unstable_cache` と `cookies()` の境界線を踏み越えたときの Next.js App Router の振る舞いと、 ぼくの修正方針を書きます。
卒業翌日、 ぼくがメインで開発してる中古品 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 つに分かれてました。
Error: Route /faq used cookies() inside a function cached with unstable_cache(). ... (22 events)Error: An error occurred in the Server Components render. (11 events)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 {
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 使うな、 必要なら外で呼んで引数で渡せ」と。
書いた当時のぼくの意図はこんな感じでした。
unstable_cache で 5 分 TTLcreateClient() でいつものやつを呼べばいいcreateClient() がユーザーの cookie 経由で auth 情報を読むことは知ってたんですが、 「公開データの SELECT なら結局 anon RLS で評価されるんやから、 cookie が空でも同じ結果やろ」と思ってました。 結果は同じやけど、 Next.js のフレームワーク的には「cache の中で動的依存を持つ関数」を禁止してる、 という話。
createClient() の内部で cookies() を呼んでる以上、 たとえその結果が公開データであっても、 Next.js は「これは cookie に依存する関数」と判定する。
選択肢としては:
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: {
そして 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() を呼ぶか」だけ確認してください。
createClient()、 NextAuth の auth() ヘルパー、 自前で cookies() を呼ぶ wrapper「結果が同じやから問題ない」 と思っても、 Next.js の判定基準は「結果」ではなく「呼んでる関数」なので、 関数ベースで考えるとミスらないです。
修正を 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() を一切呼ばないものを選ぶ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 時間で組んだ話を書きます。