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

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

- 公開日: 2026-04-18
- 著者: 友田 陽大
- タグ: Next.js, TypeScript, App Router, キャッシュ管理, トラブルシューティング, 実体験
- URL: https://tomodahinata.com/blog/revalidate-tag-nextjs-router-cache-trap
- カテゴリ: フロントエンド
- 総合ガイド: https://tomodahinata.com/blog/nextjs-16-app-router-cache-components-data-fetching

## 要点

- revalidateTag が効く時と効かない時がある正体は、無効化できるのが Data Cache だけで Router Cache は別物だから
- Router Cache はブラウザの各タブに存在し、同じタブ内で数十秒は古い RSC を握り続ける
- Server Action の後に router.refresh() を router.push() より先に呼び、クライアント側のキャッシュを捨てさせて解決する
- App Router のキャッシュは4層あり、Router Cache → Data Cache → CDN の順で切り分け脳内で混ぜない
- unstable_cache は tags を渡さない限り revalidateTag から見えない別の保管庫である

---

## 事の発端：金曜17時のSlack

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

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

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

```tsx
// 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");
}
```

```tsx
// 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` で吐かせてみました。

```tsx
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 までは面倒見てくれない（場合がある）。

```tsx
// 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、ではなく、**チームの誰が書いても同じ罠を踏まないように** したい。そこで以下の"決まり事"をプロジェクトに持ち込みました。

```ts
// 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}`);
}
```

```tsx
// 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;
}
```

使う側：

```tsx
"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()` でも直りません。

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

```tsx
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 を渡すだけ：

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