TL;DR Every next build gives each Server Action a new id unless NEXT_SERVER_ACTIONS_ENCRYPTION_KEY is set, so a page left open across a deploy posts a stale id, gets a 409 and shows Next's error screen. Set the key where next build runs and the id stays the same across builds. For login, sign-up and checkout, a route handler form has no id to go stale.
You deploy, someone who had the page open since before the deploy submits a form, and nothing they typed arrives. Your logs say Error: Failed to find Server Action "60d71...". This request might be from an older or newer deployment. The cause is how Next.js names Server Actions. Every build gives each action a fresh id unless you pin it with NEXT_SERVER_ACTIONS_ENCRYPTION_KEY at build time. A form that posts to a route handler doesn't have the problem at all.
I sell login form components that people wire to their own auth, so a sign-in form that breaks on every deploy is my problem too. I reproduced this in a fresh Next.js 16.4.2 app with React 19.3.1: build, start, open the page, swap in a second build on the same port, then submit. Every file below went through strict tsc and next build, and headless Chromium did the clicking, with and without JavaScript.
On one build it works. Then I loaded the page from build A, stopped the server, started build B from the same code, typed an email and pressed Subscribe:
The POST came back 409 Conflict with the header x-nextjs-action-not-found: 1.
The browser threw Server Action "60d7140b..." was not found on the server.
The page was replaced by Next's built-in error screen, "This page couldn't load. Reload to try again, or go back." The email was gone.
With JavaScript turned off, the browser landed on a bare text page that said "Server Action unavailable."
After a reload the form worked again, because the reloaded page carries build B's id. So the bug only hits people who had the page open across a deploy. On a login or checkout page that's exactly the person you least want to lose.
Pull-Cord Login running live. Pull the cord, the lamp turns on, the login form appears.
Each Server Action gets an id that the client sends with the form. Next derives it from a per-build secret. If you don't set one, every next build generates a new random key, and every action id changes with it. I built the same code twice with nothing edited, and the id went from 60d7140b... to 60803f7d.... A Docker or Coolify deploy is always a clean build, so every deploy does this.
Then set it as NEXT_SERVER_ACTIONS_ENCRYPTION_KEY in the environment that runs next build. Here's what I measured with the key set:
Two clean builds of the same code gave the same id.
A third build after I edited the home page and changed the error message inside subscribe still gave the same id.
The swap test from above passed: a page from build C submitted to build D and showed "Subscribed ada@example.com".
A key made with the openssl command above behaved the same way.
Three things to know before you rely on it:
It has to be there at build time. The id is baked into the build output. My test servers ran without the variable at all and still accepted the old ids, because the build had already written the key into .next/server/server-reference-manifest.json. If your host only injects env vars at runtime, the fix does nothing. In Docker that means passing it to the build step as a build arg or secret.
Renaming the action still breaks old pages. When I renamed subscribe to subscribeEmail the id changed, key or no key. Editing what the function does is fine. Changing its name or moving it to another file is a new action as far as the client knows.
Treat it as a secret. According to the Next.js docs, the same key also encrypts the values that Server Actions close over. I checked the build output: it shows up in .next/server and not in .next/static, so browsers never get it. Keep it out of your repo and don't paste it into issues or chats.
A route handler lives at a fixed URL, so there's no id to go stale. Here's app/api/subscribe/route.ts. It answers JSON to fetch and does a normal redirect for a plain form post:
ts
function sameOrigin(request: Request) { const origin = request.headers.get("origin"); const host = request.headers.get("x-forwarded-host") ?? request.headers.get("host"); return origin !== null && host !== null && new URL(origin).host === host;}export async function POST(request: Request) { if (!sameOrigin(request)) { return new Response("Forbidden", { status: 403 }); } const form = await request.formData(); const email = String(form.get("email") ?? "").trim(); const ok = email.includes("@"); // save the email here (database, Resend audience, etc.) if (request.headers.get("accept")?.includes("application/json")) { return Response.json( { ok, message: ok ? `Subscribed ${email}` : "That email doesn't look right." }, { status: ok ? 200 : 400 }, ); } // no JavaScript: a normal form post, so send the browser back to the page return new Response(null, { status: 303, headers: { Location: ok ? "/newsletter?subscribed=1" : "/newsletter?error=email" }, });}
The client form submits with fetch when JavaScript is running. The action and method attributes are the fallback for when it isn't. app/newsletter/newsletter-form.tsx:
Same swap test, build F to build G with no key set:
With JavaScript: 200 and "Subscribed ada@example.com" on the page.
Without JavaScript: 303 to /newsletter?subscribed=1 and "Subscribed." on the page.
The sameOrigin check matters. The Next.js docs say Server Actions compare the Origin header with the host for you. Route handlers don't do that. With the check, a POST with Origin: https://evil.example got a 403, and so did a POST with no Origin header at all. A same-origin form post went through. If you're behind a proxy that rewrites the host, make sure it forwards x-forwarded-host.
For most apps, set the key and keep your Server Actions. It's one environment variable and your code stays the same. Move a form to a route handler when it must survive anything: sign-in, sign-up, checkout, contact. You'd also move it when you plan to rename actions, or when the form gets posted from somewhere that isn't your Next.js page.
What didn't help: deploymentId in next.config.ts. I built twice with different deploymentId values and no key, and the swapped page still got the 409 and the error screen. I tested self-hosted next start only. Vercel has its own skew protection, which I didn't test.
qodesign's Pull-Cord Login is a dark room with a pendant lamp. You pull the cord, the light comes on and the glass login card fades in. It takes an onSubmit that returns a promise, so you can point it at a route handler like the one above, a Server Action, or your auth library's sign-in call. The Firebase recipe shows that last one. If you'd rather have a spotlight than a cord, Spotlight Lamp Login works the same way. For the accessibility side of an animated login form, see this post on accessible animated login forms. Free pieces with full source are at /free.
The browser sent a Server Action id that the running server doesn't know. Almost always the page was loaded from an older build. Without NEXT_SERVER_ACTIONS_ENCRYPTION_KEY, every build gives each action a new id, so any page left open across a deploy sends a stale one and gets a 409.
Generate a key with openssl rand -base64 32 and set it as NEXT_SERVER_ACTIONS_ENCRYPTION_KEY where next build runs. In my tests the action id then stayed the same across clean builds, even after editing the action's body. Renaming the action or moving it to another file still changes the id.
The id is decided at build time, and the key was written into the server build output. My servers accepted old ids with the variable unset at runtime. Setting it in both places does no harm, as long as it's the same value.
For forms that must never fail, like login, sign-up or checkout, a route handler is safer because its URL never changes between builds. Add your own same-origin check, since route handlers don't get the one Server Actions have, and give the form action and method attributes so it still works without JavaScript.