セルフホストの Next.js で Failed to find Server Action に悩んでいる人向けです。
公式ドキュメントの Version Skew の節を読んでも解決しなかった人、そして 「検知してリロードするしかない」という情報に行き着いて詰まっている人 が対象です。
環境: Next.js 16.3 / App Router / self-hosted(AWS ECS + CodeBuild)
「検知してリロード」しか出てこない問題
この症状を調べると、見つかる対策はだいたいこれ一択でした。
Version Skew を検知したら、一度だけ
location.reload()する(無限リロード防止のガード付きで)
// よく見かける形
window.addEventListener("unhandledrejection", (e) => {
if (!/Failed to find Server Action|ChunkLoadError/.test(String(e.reason))) return;
if (sessionStorage.getItem("reloaded")) return; // 無限リロード防止
sessionStorage.setItem("reloaded", "1");
location.reload();
});
Next.js 公式の deploymentId も、やっていることは本質的に同じです(版ズレを検知したらハードナビゲーション=フルリロードに切り替える)。
でもこれ、避けたかったんです。
リロードすれば確かに新しいバンドルを取り直せます。でもそれは 「壊れた」という結果をユーザーに押し付けて、入力内容を捨てているだけです。公式ドキュメントも認めています。
there may be a loss of application state ... component state like
useStatewould be lost.
— Next.js Self-Hosting ガイド
当社のサービスは管理画面で、リッチテキストで長文のお知らせ本文を書くような画面が多数あります。 そこで黙ってリロードが走ったら、書いたものが全部消えます。
エラー画面が出るより、書いたものが消える方が体験として明確に悪い。 「保存を押したら勝手にリロードされて、書いた文章が消えた」はクレームになります。ダイアログで「再読み込みしてください」と丁寧に伝えたとしても、押した瞬間に消えるのは変わりません。
なので方針を変えました。リロードでリカバリする方向ではなく、「そもそも ID が変わらないようにする」方向を探した結果が以下です。
結論
NEXT_SERVER_ACTIONS_ENCRYPTION_KEY を固定して、ビルド時に注入する。 これだけです。
# 鍵を作る(base64 で 16/24/32 バイト)
openssl rand -base64 32
# Dockerfile — builder ステージに置く(runner に渡しても効きません)
FROM node:24-alpine AS builder
ARG NEXT_SERVER_ACTIONS_ENCRYPTION_KEY
ENV NEXT_SERVER_ACTIONS_ENCRYPTION_KEY=${NEXT_SERVER_ACTIONS_ENCRYPTION_KEY}
COPY . .
RUN npm run build
ポイントは「ビルド時」であること。 実行時(next start や ECS のタスク定義)に渡しても効きません。Server Action ID はビルド時に確定してバンドルへ焼き込まれるためです。
deploymentId では解決しません(後述)。
症状と発生条件
症状
Failed to find Server Action "60fae4f4e92e329800da539c65f519f6757ac1a55f".
This request might be from an older or newer deployment.
エラー画面に落ちて、フォームの入力内容が消えます。
発生条件
- ユーザーが編集画面を開く
- そのまま放置する
- その間にデプロイが走る
- 戻ってきて「保存」を押す → 失敗
特定のページのバグではありません。 Server Actions で保存している画面すべてで起こり得ます。リロードすれば直りますが、入力は戻りません。
なぜ起きるか
Server Action ID は、実測すると次の式で採番されています。
Server Action ID = ハッシュ(暗号化キー + ファイルパス + export名)
そして NEXT_SERVER_ACTIONS_ENCRYPTION_KEY を設定していないと、暗号化キーはビルドごとにランダム生成されます。
By default, a unique encryption key is generated for each build.
— Next.js Self-Hosting ガイド
つまりリリースするたびに全 Action の ID が総入れ替えになります。デプロイ前に開いていたタブは、サーバーが知らない ID を送ることになる。これが原因です。
公式ドキュメントを読んでも解決しない理由
ここで沼りました。紛らわしい箇所が2つあります。
① Version Skew の節は「ナビゲーション」の話
Self-Hosting ガイドの Version Skew には、症状として Server Function mismatches が挙がっていて、それを deploymentId が detect and handle すると書いてあります。「設定すれば直る」と読めます。
でも同じ節の対処はこうです。
Client-side navigation requests include an
x-deployment-idheader
If a mismatch is detected, Next.js triggers a hard navigation (full page reload)
検知するのは画面遷移のときで、対処はフルリロードです。 フォームの保存は画面遷移ではないので乗りません。そもそもフォームを開いたまま遷移していないタブは検知の機会がない。
そして仮に検知できても、冒頭に書いたとおりフルリロードなので入力は消えます。 避けたいことがそのまま起きる。
② 当該エラーの公式ページに deploymentId が載っていない
決定的なのはこれです。Failed to find Server Action の公式解説の「Possible Ways to Fix It」に挙がっているのは3つだけ。
- クライアントとサーバーを同じデプロイに揃える
NEXT_SERVER_ACTIONS_ENCRYPTION_KEYを揃える- Vercel なら Skew Protection を使う
deploymentId は一言も出てきません。 つまり Version Skew の節は「アセット404とナビゲーション失敗」の解であって、フォーム送信の失敗の解ではない、ということでした。
ちなみに ② の説明も、そのままでは今回の話に見えない
NEXT_SERVER_ACTIONS_ENCRYPTION_KEY は「複数インスタンスを動かすとき、インスタンス間で鍵が食い違うと復号できない」という文脈で説明されています。1つのイメージを全タスクに配る構成なら、その条件は最初から満たしているので「自分には関係ない」と読み飛ばしてしまいます。
実際に効くのは、ドキュメントに明記されていない「ビルドをまたいで ID が安定する」という副作用の方です。
実際にやったこと
1. 鍵を Secrets Manager に置く
openssl rand -base64 32 の値を登録します。アプリごと・環境ごとに別の値にしてください。
2. CI でビルド時に注入する
# buildspec.yml
phases:
pre_build:
commands:
- SECRET_JSON=$(aws secretsmanager get-secret-value --secret-id /myapp/${ENVIRONMENT}/app --query SecretString --output text)
- export NEXT_SERVER_ACTIONS_ENCRYPTION_KEY=$(echo $SECRET_JSON | jq -r '.NEXT_SERVER_ACTIONS_ENCRYPTION_KEY')
# jq は存在しないキーに文字列 "null" を返すので、それが鍵として焼き込まれるのを防ぐ
- |
if [ -z "$NEXT_SERVER_ACTIONS_ENCRYPTION_KEY" ] || [ "$NEXT_SERVER_ACTIONS_ENCRYPTION_KEY" = "null" ]; then
echo "ERROR: NEXT_SERVER_ACTIONS_ENCRYPTION_KEY is missing"
exit 1
fi
build:
commands:
- >
docker build -t ${IMAGE_NAME}:latest -f ./ecs/Dockerfile
--build-arg NEXT_SERVER_ACTIONS_ENCRYPTION_KEY=${NEXT_SERVER_ACTIONS_ENCRYPTION_KEY}
.
このガードはおすすめです。 登録を忘れると "null" という固定キーで動いてしまい、動くだけに気づきません。
3. Dockerfile の builder ステージに置く
冒頭のコードのとおりです。runner ステージに置いても効きません。
RELEASE_SHA(ログ用の commit hash)を runner だけに持っていたので、うっかりその隣に足しそうになりました。用途で置き場所が変わります。
- 実行時に読む値 → runner
- ビルド成果物に焼き込まれる値(今回の鍵) → builder
結果
デプロイ前に開いた編集画面から、デプロイ後に保存 → 成功しました。 データもちゃんと永続化されていました。
効いているか自分で確認する方法
必ず各自で検証・確認を行ってください。
next build すると、Action ID の一覧がこのファイルに出ます。
.next/server/server-reference-manifest.json
{
"node": {
"00379808fd3fd42845e9c3ef30a9c7d110bba73390": {
"filename": "src/features/account-mfa/actions/reset-mfa.ts",
"exportedName": "resetMfa"
}
},
"encryptionKey": "..."
}
ID とファイルパスの対応が全部書いてあるので、2回ビルドして diff すれば一発で分かります。
import json
def idmap(path):
d = json.load(open(path))
return {(v["filename"], v["exportedName"]): k for k, v in d["node"].items()}
a, b = idmap("manifest-A.json"), idmap("manifest-B.json")
common = set(a) & set(b)
diff = [k for k in common if a[k] != b[k]]
print(f"共通 {len(common)} → 据え置き {len(common)-len(diff)} / 変化 {len(diff)}")
鍵を固定した状態と、していない状態で、それぞれ2回ビルドして比べてみてください。実測(173 actions)はこうなりました。
| 条件 | 結果 |
|---|---|
| 鍵を固定 + 無関係なファイルを変更 | 173/173 据え置き |
| 鍵を固定 + Action 本体を変更 | 173/173 据え置き |
| 鍵を固定 + Action のファイル名を変更 | 172 据え置き / 改名した1件のみ変化 |
| 鍵を設定しない(デフォルト) | 0/173 一致(全部変わる) |
検証するときは
git worktreeを切って、そこで回すのがおすすめです。開発用の dev サーバが.nextを掴んでいるので、本体でnext buildすると壊れます。
本番で確認する場合
デプロイを2回またぐ必要があります。
- デプロイ済み環境で編集画面を開き、入力して保存せず放置
- 別のリリースをデプロイ
- 放置していたタブで保存を押す → 成功すれば OK
検証に使うリリースは「Action ファイルの移動・改名を含まないもの」を選んでください。 含まれているとその画面だけは仕様どおり失敗して、結果を誤読します。
注意点
「ファイルの移動・改名」には注意
上の表のとおり、Action の中身をどれだけ書き換えても ID は変わりません。 変わるのはファイルパスと export 名が変わったときだけです。
これは Laravel などで URL を改名したときと同じ性質のリスクです。ただし URL は誰も気軽に変えないのに対し、Action のファイルパスは「内部実装」としてリファクタで自由に動かされがちなので、踏む頻度が桁違いになり得ます。
「Action ファイルの移動・改名は URL の変更と同じ重みで扱う」を運用ルールにしました。
鍵を変えると、その1回だけ同じ障害が起きる
鍵をローテーションすると全 ID が入れ替わります。対策したはずの障害が、ローテーション時に再発します。 各環境の初回導入時も同じです(それ以前がランダム鍵なので)。本番は低トラフィック帯を選ぶのが無難です。
「静かに成功する」方向のリスクが増える
これまでは ID が変わることで失敗が保証されていました。固定すると、古い画面から新しいコードの Action を呼べます。引数の形を変えたリリースでは、古い形のデータが新しいコードに渡ります。
"use server" の関数引数は実行時に型で守られません。 入口で Zod などの検証を通しておいてください。
"use server";
export async function saveSomething(input: unknown) {
const session = await requireSession(); // 認証
const parsed = saveSchema.safeParse(input); // 入力検証(ここ)
if (!parsed.success) return { error: "invalid" };
// ...
}
deploymentId は別問題に効くので、併せて入れる価値はある
今回の本題は解決しませんが、アセット404(ChunkLoadError)とナビゲーション失敗には効きます。
// next.config.ts
deploymentId: process.env.NEXT_DEPLOYMENT_ID, // CI から commit hash を渡す
効いていれば <html data-dpl-id="..."> とアセットURLの ?dpl=...、RSC ナビゲーションの x-nextjs-deployment-id ヘッダーで確認できます。
なお output: "standalone" の場合、この値は required-server-files.json にスナップショットされるので、実行時に環境変数を渡す必要はありません(実測で確認済み)。
まとめ
| やること | 効果 |
|---|---|
NEXT_SERVER_ACTIONS_ENCRYPTION_KEY をビルド時に固定 |
本命。 デプロイまたぎの失敗が解消 |
deploymentId を設定 |
アセット404・ナビゲーション失敗(別問題)に効く |
| Action 入口の Zod 検証 | 固定後に顕在化するリスクの防止 |
同じところで沼っている人の役に立てば。