مرکزی مواد پر جائیں
友田 陽大
Frontend
Next.js
TypeScript
App Router
キャッシュ管理
トラブルシューティング
実体験

『revalidateTag、効く時と効かない時があるんですけど』と言われて半日溶かした話 — Next.js App Router 4層キャッシュの落とし穴

Next.js App Routerの `revalidateTag` が『たまにしか効かない』という謎のバグを本番で踏んで、原因はData CacheではなくクライアントのRouter Cacheだったと気づくまでの実体験。4層キャッシュの責務と、それぞれの無効化手段を整理したトラブルシューティングの記録です。

Published
Reading time
10 min read
Author
友田 陽大
شیئر کریں

事の発端:金曜17時のSlack

PM:「お客さんから『編集しても一覧に反映されない時がある』ってクレーム来てるんですけど、見てもらっていいですか?」

金曜の夕方、一番聞きたくない種類のメッセージ。

該当機能は、Next.js 15 の App Router で作った SaaS の管理画面で、記事をServer Actionで更新 → 一覧ページに戻ると最新が反映される というごく普通の CRUD でした。コードは(当時の自分的には)教科書どおり。

// app/articles/actions.ts
"use server";
import { revalidateTag } from "next/cache";

export async function updateArticle(id: string, formData: FormData) {
  const title = String(formData.get("title") ?? "");
  await db.articles.update(id, { title });
  revalidateTag("articles"); // これで一覧が最新になる…はず
  redirect("/articles");
}
// app/articles/page.tsx
export default async function ListPage() {
  const res = await fetch(`${process.env.API}/articles`, {
    next: { tags: ["articles"], revalidate: 300 },
  });
  const articles = await res.json();
  return <ArticleTable rows={articles} />;
}

見るからに「動く」コード。ローカル(next dev)では何度試しても100%反映される

そう、ローカルでは


17:30〜18:00 本番での再現を試みる

本番URLで同じ操作を繰り返します。

  • 1回目:更新 → 一覧に戻ると反映されている
  • 2回目:違う記事を更新 → 一覧に戻ると古いまま
  • 3回目:再更新 → 反映される
  • 4回目:タブを閉じて開き直す → 反映される

再現性がある、でも確実じゃない。 この「半分だけ起こるバグ」が一番厄介で、しかも一番罠が多いパターン。

Slack にとりあえず状況だけ書く:

僕: 本番で再現確認中。完全に出ないわけでもなく、常に出るわけでもなくて、怪しい。今日中に原因特定までは行くつもり、直すのは週明けになるかも。


18:00〜19:00 最初の仮説(そして外れ)

仮説①:CDNのキャッシュが古い?

Vercelだから基本問題ないはず、と思いつつ Response ヘッダを見にいきます。

x-vercel-cache: MISS
age: 0
cache-control: private, no-cache, no-store, max-age=0, must-revalidate

毎回 MISS。CDN は無実。

仮説②:fetch の Data Cache が更新されていない?

ここで next/cache の挙動を自分なりに再確認。 revalidateTag("articles") を呼べば、next: { tags: ["articles"] } を付けた fetch の結果が「stale」マーク付きになり、次にそのルートが描画されるときに再フェッチされるはず

これを直接確かめるために、一覧ページの fetch 結果を console.log で吐かせてみました。

export default async function ListPage() {
  const res = await fetch(`${process.env.API}/articles`, {
    next: { tags: ["articles"], revalidate: 300 },
  });
  const articles = await res.json();
  console.log("[list] fetched at", new Date().toISOString(), "count=", articles.length);
  return <ArticleTable rows={articles} />;
}

Vercel Logs を眺めながら更新 → 一覧へ戻る、を繰り返します。

[list] fetched at 2026-04-10T09:14:03.181Z count=12 [list] fetched at 2026-04-10T09:14:27.551Z count=13 ← 新規投稿が反映された

(次の更新操作)

…ログが出ない。

出ない。RSCが再実行されていない。これはData Cacheの話じゃない。サーバーにそもそもリクエストが来ていない。

ここで、「クライアント側のキャッシュが生きている」という可能性が濃厚になります。


19:00〜20:00 Router Cacheという名の別人格

App Routerには、実は4つのキャッシュ層があります。これは公式ドキュメントにも書いてありますが、名前が紛らわしくて混乱しがちです。

存在場所スコープ無効化手段
①Request Memoizationサーバー(1リクエスト内)同一レンダリング中リクエスト終了時に自動で消える
②Data Cacheサーバー(永続)全ユーザー横断revalidateTag / revalidatePath
③Full Route Cacheサーバー(ビルド/再検証時)全ユーザー横断Data Cacheの失効に連動
④Router Cacheブラウザ(各タブ)そのユーザーのナビゲーションrouter.refresh() / 遷移

これまで僕が無効化していたのは revalidateTag("articles") で、これは ②Data Cache だけを消す道具でした。 しかし、ブラウザは直前に /articles を開いていた ④Router Cache のスナップショットを握っており、戻ってきたユーザーには、まずそれを見せるのです。

再現手順(これで腑に落ちた)

  1. ユーザーが /articles を開く → Router Cache にRSCペイロードが入る
  2. /articles/[id]/edit に遷移して更新 → revalidateTag("articles") でData Cacheはクリア済み
  3. redirect("/articles") で戻る
  4. Router Cache に(Data Cacheの失効と無関係に)まだ古いRSCペイロードが残っている
  5. ブラウザはそれを表示する
  6. 一定時間(デフォルトは数十秒)を跨ぐか、ハードリロードすれば消える

つまり、ユーザーが編集してから一覧に戻るまでの時間が長いときは「反映されているように見える」、短いときは「古いまま」。これが「効く時と効かない時がある」の正体でした。

ここまで来ると、さっき見たログの挙動(RSCが再実行されていない)も全部説明がつきます。サーバーに問い合わせる前にブラウザが自分で描画を決めていた、ということです。


20:00〜20:30 修正してみる

修正その1:Server Action のあとで router.refresh()

クライアント側で明示的にRouter Cacheを無効化する必要があります。Server Action 単体では Router Cache までは面倒見てくれない(場合がある)。

// app/articles/[id]/edit/EditForm.tsx
"use client";
import { useRouter } from "next/navigation";
import { useTransition } from "react";
import { updateArticle } from "../actions";

export function EditForm({ id }: { id: string }) {
  const router = useRouter();
  const [pending, start] = useTransition();

  return (
    <form
      action={(fd) => {
        start(async () => {
          await updateArticle(id, fd);
          // ビジネス目的:Router Cache(クライアント側キャッシュ)を捨てさせ、
          //           最新のData Cacheから引き直させる。これがないと、
          //           ユーザー体感で「反映されていない」と見える不具合が起きる。
          router.refresh();
          router.push("/articles");
        });
      }}
    >
      {/* フォーム本体 */}
      <button disabled={pending}>保存</button>
    </form>
  );
}

ポイントは router.refresh()router.push() より先に呼ぶ こと。逆順だとナビゲーション後の Router Cache 参照に refresh が届かないことがあり、挙動が不安定になります。

ローカルで再検証 → 100% 反映されるようになりました。本番に上げて20分、クレームは止まりました

修正その2:恒久対応としての設計指針

1回直したらOK、ではなく、チームの誰が書いても同じ罠を踏まないように したい。そこで以下の"決まり事"をプロジェクトに持ち込みました。

// lib/cache/revalidate.ts
// 目的:
//   App Router の 4 層キャッシュを「3行で正しく」無効化するための共通ヘルパ。
//   ・Server Action 側は tag の無効化責務だけを持つ
//   ・Client 側は router.refresh() の責務だけを持つ
//   呼び出し側で片方を忘れても、もう片方が補う設計にはしない(=忘れたら壊れる)。
//   代わりに、useOptimisticUpdate 経由の呼び出しを強制する ESLint ルールで守る。

import { revalidateTag, revalidatePath } from "next/cache";

// サーバー側:Data Cache 無効化
export async function invalidateArticles(): Promise<void> {
  // ビジネス目的:全ユーザーが編集操作の直後に最新版を見られるようにする
  revalidateTag("articles");
}

// サーバー側:特定ルートのFull Route Cacheまで消したい場合
export async function invalidateArticleDetail(id: string): Promise<void> {
  revalidatePath(`/articles/${id}`);
}
// lib/cache/useRevalidatingAction.ts
"use client";
import { useRouter } from "next/navigation";
import { useTransition } from "react";

/**
 * 任意のServer Actionを呼び、成功後にRouter Cacheを無効化して目的地へ遷移する共通フック。
 * ビジネス目的:Router Cacheの消し忘れによる「反映されない問題」を構造的に防止。
 * 呼び出し元は「何をしたいか」だけを書けばよい。
 */
export function useRevalidatingAction<A extends unknown[], R>(
  action: (...args: A) => Promise<R>,
  opts?: { redirectTo?: string },
) {
  const router = useRouter();
  const [pending, start] = useTransition();

  const run = (...args: A) =>
    start(async () => {
      await action(...args);
      router.refresh(); // Router Cacheを捨てる
      if (opts?.redirectTo) router.push(opts.redirectTo);
    });

  return [run, pending] as const;
}

使う側:

"use client";
import { useRevalidatingAction } from "@/lib/cache/useRevalidatingAction";
import { updateArticle } from "./actions";

export function EditForm({ id }: { id: string }) {
  const [run, pending] = useRevalidatingAction(
    (fd: FormData) => updateArticle(id, fd),
    { redirectTo: "/articles" },
  );
  return (
    <form action={(fd) => run(fd)}>
      {/* … */}
      <button disabled={pending}>保存</button>
    </form>
  );
}

これで**「Router Cacheの無効化を忘れる」という事故そのものが、コード上で"書ける経路"から消えます**。今回のバグを踏んだ経験は、「忘れないように気をつける」ではなく「忘れても壊れない設計にする」ことで将来に還元するのがベストです。


翌日に気づいた追加の罠:unstable_cache は別の世界

月曜の朝、別画面で似たような "反映されない" 事例を見つけたのですが、今度は router.refresh() でも直りません。

該当コードはこうでした。

import { unstable_cache } from "next/cache";

const getRelated = unstable_cache(
  async (articleId: string) => db.articles.findRelated(articleId),
  ["related-articles"], // key parts
  { revalidate: 3600 /* tagsは書いていなかった! */ },
);

unstable_cache は tags を指定しない限り、revalidateTag から見えませんrevalidate: 3600 によって 1時間は古いままになる設計。

修正は tags を渡すだけ:

const getRelated = unstable_cache(
  async (articleId: string) => db.articles.findRelated(articleId),
  ["related-articles"],
  {
    revalidate: 3600,
    tags: ["articles"], // ビジネス目的:記事更新時に関連記事もまとめて失効させる
  },
);

地味ですが、これもハマる人多いポイントだと思います。unstable_cache で包んだ時点で、それはData Cacheとは別の保存庫。その保存庫の鍵を渡しておかないと、どこからも開けられなくなります。


振り返り:なぜこの罠は踏まれやすいのか

今回のバグで一番の反省点は、「キャッシュが消えた」= 「ユーザーに新しく見える」だと暗黙に思い込んでいた ことです。

正しい理解は次のように整理できます。

  • サーバー側キャッシュ(Data Cache / Full Route Cache)を消す:「次に誰かが見にきたとき、新しく作り直す」準備をする
  • クライアント側キャッシュ(Router Cache)を消す:「今このブラウザに、新しく見に来させる」ための行為

この2つは別の役目なので、Server Action 側で revalidateTag を呼んだだけでは、ブラウザ側の現在のユーザーはまだ古い風景を見ているかもしれない——これは仕様どおりなのです。

「たまに反映されない」は、たいてい"混線した別のキャッシュ層"が犯人。今後、似た報告を受けたら、迷わず Router Cache → Data Cache → CDN → ブラウザのメモリキャッシュの順で切り分けることにしました。


教訓(自分へのリマインダー)

  1. App Router のキャッシュは4層ある。消す手段もそれぞれ違う。脳内で混ぜない。
  2. revalidateTag の守備範囲は Data Cache(+ その延長のFull Route Cache)まで。クライアントの Router Cache は router.refresh() の領分。
  3. Server Action のあとに遷移するフローでは、router.refresh() を先に呼ぶ。共通ヘルパで包んで、書き忘れを仕組みで防ぐ。
  4. unstable_cache は別の保管庫revalidateTag で消したければ、そのキャッシュにも同じ tag を必ず刺す。
  5. 「たまに〜」は仮説の宝庫。決定論的に起こらないバグは、"時間に依存する別レイヤー"がいないかをまず疑う。

参考

  • Next.js 公式ドキュメント Caching の章("Router Cache" の節を読むと、今回の話がすべて書いてあります。後で読み直すと身に染みる…)
  • Next.js 公式ドキュメント revalidateTag / revalidatePath / unstable_cache の各項
  • 2026年春時点で利用した実装は Next.js 15 系。Next.js 16 では Router Cache の既定挙動が変わっているため、アップグレード時には staleTimes 設定を合わせて読み直すことをおすすめします

半日溶かしましたが、おかげで App Router のキャッシュ地図が頭に入りました。転んだ場所のメモを残しておくことが、次に同じ場所を歩く誰かへの一番の贈り物だと思っています。同じ場所で止まっている方の手がかりになれば。

友田

友田 陽大

وزیر (METI) ایوارڈ یافتہ پروڈکٹ کے ڈویلپر۔ TypeScript + Python + AWS کے ساتھ میں SaaS، صنعتی ڈیجیٹل تبدیلی اور پروڈکشن کے قابل جنریٹو اے آئی (RAG) تنہا ابتدا سے انتہا تک فراہم کرتا ہوں — تقاضوں کی تعیین سے انفراسٹرکچر اور آپریشن تک۔

Got a challenge?

From design to implementation and operations — solo × generative AI

Implementation like this article's, end to end from requirements to production. Start with a free 30-minute technical consult and tell me about your situation.

پروجیکٹ کی بنیاد پر کام اور تکنیکی مشاورت دونوں کے لیے دستیاب ہوں۔ 30 منٹ کی مفت مشاورت سے آغاز کریں۔

最短ルート:カレンダーから直接予約

相談内容が固まっている方は、フォーム送信よりその場で日程を確定する方がスムーズです。下記から空き時間をお選びください。

  • 30分のオンライン無料相談
  • Google Meet / Zoom / Microsoft Teams
  • NDA 商談前締結可・無理な営業はいたしません
無料相談の空き枠を予約する

یہ بھی پڑھنے کے قابل