ある朝、本番の Metronome.fm で統計の画面(モーダルで開く画面です)を開くと、「Failed to fetch statistics.」と出て、グラフが表示されませんでした。ページをハードリロードすると直りました。

- 統計を読み込めず、グラフの代わりにエラーが出た
原因は、デプロイの前から開いていたタブが、新しいデプロイのサーバーに対して、Server Action(ブラウザから呼べるサーバーの関数)を古い ID のまま呼んだことでした。こうしたクライアントとサーバーの版の食い違いを、version skew と呼びます。
このずれに備えて、以前から再読み込みを促すトーストを出すようにしていました。調べてみると、トーストの「再読み込み」のボタンは、モーダルを開いているあいだ押せなくなっていました。この記事では、version skew がどういうものか、Server Action では何が起きるかを書きます。そのうえで、今回有効にした Vercel の Skew Protection の設定と、ボタンが押せなかった理由と直し方を書きます。最後に、サインイン中に音色や表示の設定を端末をまたいで同期する機能で、トーストが出ていなかった件も書きます。
version skew とは
Web のアプリは、ページを開いたときに JavaScript を読み込み、そのあとはタブを閉じるまで同じものを使い続けます。一方、サーバーはデプロイのたびに新しい版に入れ替わります。
すると、ページを開いたあとにデプロイがあった場合、タブの中では古い版の JavaScript が動き、サーバーは新しい版になります。この食い違いが version skew です。
Metronome.fm は開いたまま練習に使うアプリなので、タブを長く開いたままにする使い方が多いと考えています。そのうえ、変更は main に入れるとすぐ本番に出るので、デプロイの回数も多くなります。この日も、統計を開くまでに朝から 4 回デプロイしていました。
Next.js のドキュメントは、version skew で起きることとして次の 3 つを挙げています。Server Function は、Server Action を含むサーバーの関数の呼び名です。
- 古い版の JavaScript や CSS のファイルを取りに行くが、サーバーにもうない
- 古い版の ID で Server Function を呼ぶが、サーバーがその ID を知らない
- 古い版で先読みしたページのデータが、新しいサーバーと合わない
今回当たったのは 2 つめです。
Server Action は ID で呼ばれる
Server Action は、ブラウザから呼べるサーバーの関数です。"use server" と書いた関数を、クライアントのコンポーネントから普通の関数のように呼べます。
裏では、関数そのものではなく ID がブラウザに渡ります。呼ぶと、ブラウザはその ID を付けて POST のリクエストを送り、サーバーは ID から関数を探して動かします。
この ID は、ビルドのときに作られます。Next.js のドキュメントによると、ID は暗号化された推測できない値で、セキュリティのためにビルドをまたいで作り直されることがあります。つまり、デプロイをまたぐと同じ関数でも ID が変わることがあります。
古いタブが古い ID で呼ぶと、新しいサーバーにはその ID の関数がありません。Next.js のサーバーは、状態コード 409 と x-nextjs-action-not-found: 1 というヘッダーを返します。ブラウザの側はこのヘッダーを見て、UnrecognizedActionError を投げます。

- サーバーが 409 を返し、ブラウザが「Server Action が見つからない」と UnrecognizedActionError を投げた
Next.js のサーバーのコードには、「Skew Protection のないデプロイではときどき起きるので、サーバーのログには error ではなく warning で出す」という意味のコメントがあります。
Vercel の Skew Protection を有効にする
Vercel には、このずれを防ぐ Skew Protection という機能があります。
有効にすると、ブラウザで動く Next.js は、送るリクエストにデプロイの ID を付けます。Server Action の ID とは別の、ページを読み込んだときのデプロイを表す ID です。JavaScript などのファイルの読み込み、画面の移動、Server Action の呼び出しが対象です。Vercel はその ID を見て、リクエストを古いデプロイに振り分けます。古いタブのリクエストは古いデプロイで処理されるので、ID が合わなくなることはありません。
ただし、Skew Protection を使えるのは Pro と Enterprise のプランだけです。Metronome.fm はこれまで Hobby のプランで動いていたので、設定の画面でもスイッチを入れられませんでした。

- Pro のプランの機能であることを示すバッジ
- スイッチは Disabled のまま
- 有効にするには、プランを上げる必要がある
今回の件をきっかけに、プランを Pro に上げて Skew Protection を有効にしました。設定は、プロジェクトの Settings の Advanced にあります。
Maximum Age は、古いデプロイに振り分ける期間です。選べるのは 4 時間・12 時間・1 日・7 日・30 日で、設定の画面を開いたときは 12 時間になっていました。数日開きっぱなしのタブも救えるよう、7 日にしました。

- スイッチを Enabled にする
- 古いデプロイに振り分ける期間(Maximum Age)を 7 days にした
期間を長くすると、不具合のある古い版にも長く振り分けられます。そのときは、直した版のデプロイをデプロイの一覧で選び、そのメニューから Skew Protection Threshold を設定します。それより古いデプロイには振り分けられなくなります。
ドキュメントには、System Environment Variables を使えるようにしておくことも書かれています。Next.js がデプロイの ID を読むためです。この設定は、プロジェクトの Settings の Environment Variables の下にあります。確かめると、もともと有効でした。

- チェックが入っていれば、Next.js がデプロイの ID を読める
設定を変えたあとは、本番を次にデプロイしたときから効きます。
Skew Protection を有効にしても、古いタブの失敗がなくなるわけではありません。7 日より長く開いたままのタブや、削除したデプロイのページを開いたままのタブのリクエストは、新しいデプロイに届きます。そのため、ずれたときにアプリの側で何をするかは、これからも決めておく必要があります。
再読み込みを促すトースト
Hobby のプランのあいだの備えが、冒頭のトーストです。Skew Protection を有効にしたあとも残しています。
Server Action が UnrecognizedActionError で失敗したかは、Next.js の unstable_isUnrecognizedActionError で見分けます。見分けたら独自のイベントを window に送り、トーストを出す部品がそれを受けてトーストを出します。
import { unstable_isUnrecognizedActionError } from "next/navigation";
export const STALE_DEPLOYMENT_EVENT = "mfm:stale-deployment";
export const isStaleDeploymentError = (error: unknown): boolean =>
unstable_isUnrecognizedActionError(error);
/** 再読み込みを促すトーストを出す合図を送る */
export const notifyStaleDeployment = (): void => {
if (typeof window === "undefined") return;
window.dispatchEvent(new Event(STALE_DEPLOYMENT_EVENT));
};この判定は、Server Action を呼ぶ場所ごとには書いていません。失敗を記録するアプリのログの関数(console に出し、ログを集めるサービスにも送る自前の関数)の中に置きました。失敗をログに残している呼び出しなら、どこで起きても 1 か所の判定で拾えます。ログの関数には error 用と warning 用があります。判定を置いていたのは error 用で、古いタブの失敗と見分けたら、再読み込みで直る失敗なので warning に下げて記録していました。
トーストは閉じるまで残します。トーストのライブラリ sonner に渡す id を固定しているので、何度失敗しても 1 つにまとまります。
ログを集めるサービス Better Stack に届いたブラウザのログを見ると、2026 年 10 月 19 日までの 3 日間(ログを残している期間)で、UnrecognizedActionError は 6 件ありました。サインイン中に音色や表示の設定を端末をまたいで同期する機能が 4 件、統計が 2 件です。統計の 2 件は、冒頭の画面を開いたときのものです。設定の同期の 4 件では、トーストが出ていませんでした。理由は最後の節に書きます。
モーダルを開いていると押せなかった
手元で同じ状況を作ってみました。統計の画面のように、モーダルを開いたままトーストを出します。トーストはモーダルの上に出ますが、「再読み込み」を押しても何も起きません。
原因は、モーダルを作っている React Aria の仕組みでした。React Aria のモーダルは、開いているあいだ、モーダルの外にある要素をすべて inert にします。inert の付いた要素は、見えていてもクリックできず、フォーカスも当たりません。キーボードやスクリーンリーダーの操作が、モーダルの外へ移らないようにするためです。
トーストを描く sonner の要素は、モーダルの外にあります。そのため、画面の上では手前に見えていても、inert で押せなくなっていました。
押せるようにする
React Aria には、inert にしない要素を決める data 属性があります。data-react-aria-top-layer の付いた要素と、その中身は対象から外れます。トーストのように、モーダルの上に重ねて出すもののための目印です。
// Keep live announcer and top layer elements (e.g. toasts) visible.
for (let element of root.querySelectorAll(
"[data-live-announcer], [data-react-aria-top-layer]",
))
visibleNodes.add(element);sonner の要素には属性を渡せません。そこで、トーストを並べる sonner の Toaster 全体を、この data 属性を付けた枠で包みました。枠は display: contents にして、レイアウトには影響させません。
export const TOASTER_ATTRIBUTE = "data-react-aria-top-layer";
export const Toaster = () => (
// sonner の要素には属性を渡せないので、レイアウトに影響しない枠で包んで目印を付ける
<div className="contents" {...{ [TOASTER_ATTRIBUTE]: "true" }}>
<SonnerToaster position="bottom-center" /* … */ />
</div>
);React Aria は、モーダルを開いた瞬間にこの data 属性を探します。この枠はページを開いたときからあるので、トーストがモーダルより先に出ていても、あとから出ても対象から外れます。
もう 1 つ、モーダルの外を押すとモーダルが閉じる動きも調整しました。トーストを押すとモーダルまで閉じてしまわないように、外を押したかどうかの判定からトーストを除いています。
<ModalOverlay
// 画面の下の再生の帯とトーストを押しても、モーダルは開いたままにする
shouldCloseOnInteractOutside={(element) =>
!element.closest(`[${UNCOVERED_BOTTOM_ATTRIBUTE}], [${TOASTER_ATTRIBUTE}]`)
}
/* … */
/>これで、モーダルを開いたままでも「再読み込み」を押せるようになりました。

- モーダルを開いたままでも押せるようになった「再読み込み」
E2E のテストも足しました。Server Action への POST に x-nextjs-action-not-found のヘッダーを付けた応答を返して古いタブを再現し、モーダルを開いてから「再読み込み」を押します。直す前のコードでは、このテストはボタンを押せずに落ちました。
設定の同期でも案内を出す
ログを数えていて気づいた、設定の同期の漏れです。6 件のうち 4 件を占める設定の同期の失敗では、どれもトーストが出ていませんでした。
設定の同期は、回線が切れるなどで失敗しても次の機会にやり直せます。そのため、失敗を warning 用のログの関数で記録していました。古いタブの判定は error 用にしか置いていなかったので、この経路には判定がありませんでした。判定を warning 用にも置き、どちらで記録しても再読み込みを促すようにしました。
Skew Protection で古いタブの失敗は起きにくくなりました。それでも起きたときは、統計の画面でも設定の同期でも再読み込みの案内が出て、モーダルを開いたままでも押せます。