メインコンテンツへスキップ
友田 陽大
Next.js
TypeScript
App Router
キャッシュ管理
トラブルシューティング
実体験

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

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

公開日
読了時間
11分
著者
友田 陽大

TL;DR(結論だけ知りたい方へ)

  • revalidateTagData Cache(サーバー側) しか無効化しない
  • Router Cache(クライアント側) は別物で、同じタブ内で 30 秒〜は古いRSCを握り続ける
  • Server Action のあとに router.refresh() を呼ぶ、あるいは確実に遷移する設計にすれば解決
  • 「効く時と効かない時がある」の正体は、ユーザーがたまたまRouter Cacheのstale時間を跨いだか否か というだけの話だった

事の発端:金曜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 のキャッシュ地図が頭に入りました。転んだ場所のメモを残しておくことが、次に同じ場所を歩く誰かへの一番の贈り物だと思っています。同じ場所で止まっている方の手がかりになれば。

同様の課題、抱えていませんか?

あなたのビジネス課題も、最新の技術で解決できます。 まずは30分の無料技術相談から、状況をお聞かせください。

無料技術相談を予約する

プロジェクト単位(請負)・技術顧問、どちらにも対応可能です

技術ブログ一覧に戻る