TL;DR(結論だけ知りたい方へ)
revalidateTagは Data 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 のスナップショットを握っており、戻ってきたユーザーには、まずそれを見せるのです。
再現手順(これで腑に落ちた)
- ユーザーが
/articlesを開く → Router Cache にRSCペイロードが入る /articles/[id]/editに遷移して更新 →revalidateTag("articles")でData Cacheはクリア済みredirect("/articles")で戻る- Router Cache に(Data Cacheの失効と無関係に)まだ古いRSCペイロードが残っている
- ブラウザはそれを表示する
- 一定時間(デフォルトは数十秒)を跨ぐか、ハードリロードすれば消える
つまり、ユーザーが編集してから一覧に戻るまでの時間が長いときは「反映されているように見える」、短いときは「古いまま」。これが「効く時と効かない時がある」の正体でした。
ここまで来ると、さっき見たログの挙動(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 → ブラウザのメモリキャッシュの順で切り分けることにしました。
教訓(自分へのリマインダー)
- App Router のキャッシュは4層ある。消す手段もそれぞれ違う。脳内で混ぜない。
revalidateTagの守備範囲は Data Cache(+ その延長のFull Route Cache)まで。クライアントの Router Cache はrouter.refresh()の領分。- Server Action のあとに遷移するフローでは、
router.refresh()を先に呼ぶ。共通ヘルパで包んで、書き忘れを仕組みで防ぐ。 unstable_cacheは別の保管庫。revalidateTagで消したければ、そのキャッシュにも同じ tag を必ず刺す。- 「たまに〜」は仮説の宝庫。決定論的に起こらないバグは、"時間に依存する別レイヤー"がいないかをまず疑う。
参考
- Next.js 公式ドキュメント
Cachingの章("Router Cache" の節を読むと、今回の話がすべて書いてあります。後で読み直すと身に染みる…) - Next.js 公式ドキュメント
revalidateTag/revalidatePath/unstable_cacheの各項 - 2026年春時点で利用した実装は Next.js 15 系。Next.js 16 では Router Cache の既定挙動が変わっているため、アップグレード時には staleTimes 設定を合わせて読み直すことをおすすめします
半日溶かしましたが、おかげで App Router のキャッシュ地図が頭に入りました。転んだ場所のメモを残しておくことが、次に同じ場所を歩く誰かへの一番の贈り物だと思っています。同じ場所で止まっている方の手がかりになれば。