What happened
Our contact form sends a brief through a Server Action: a server function that Next.js lets a form call directly. On October 5, one send answered with a 500 and this line in the server's log: "Failed to find Server Action. This request might be from an older or newer deployment".
Nothing was wrong with the code. The tab had loaded the page before we deployed, so it called the action by the ID the previous build gave it. The new server had never heard of that ID. The person saw an error and lost what they had written.
The rule we took from it
A deploy shouldn't cost a visitor what they wrote. A form is the one page where a hard failure loses someone's work, not just their time.
Why the action's ID changes
In the browser, a Server Action is only a reference: an ID that the page POSTs back to the server. Next's guide to Server Actions says the ID is part of the build, that new deployments typically generate new IDs, and that Next rotates them at most every 14 days even when the source hasn't changed (as of October 9, 2026). A tab that stays open across a deploy keeps the old one.
- the longest Next.js keeps an action ID, even with no code change
- 14 days
- ways in for a brief: the Server Action and /api/contact
- 2
- file with the checks both ways share
- 1
The same guide lists three ways to soften it: prefer rolling deployments, keep the NEXT_SERVER_ACTIONS_ENCRYPTION_KEY stable across instances, and show the error as a retry path instead of a hard failure. Vercel also has a feature for this, Skew Protection, which keeps the previous deployment's actions available. We run on Cloud Run, so that wasn't an option, and the first two don't help a tab that was opened an hour before the deploy. The third one is what we built, with a twist: instead of asking the visitor to refresh and type again, the form retries for them.
A second way in
The fix gives the brief two doors into the same room. The Server Action stays the first one. When its call throws, the form sends the same FormData to /api/contact, a route handler whose address doesn't change between builds. Both doors lead to one function that does the real work.
- 01We moved the submission out of the Server Action into
lib/lead-submit.ts: the honeypot, the validation, the call to our API and the error messages in the form's language. - 02The Server Action became three lines that call it.
- 03We added
app/api/contact/route.ts, which reads the same form data and calls the same function. - 04The form calls the action first, and the route only when the action throws.
The code
On the client, one helper wraps the action. If both doors fail, it keeps every value the visitor typed and shows a message that gives our email address, so the brief can still reach us.
export async function sendBrief(action, previous, data, serverError) {
try {
return await action(previous, data);
} catch {
try {
const response = await fetch("/api/contact", { method: "POST", body: data });
if (response.ok) return (await response.json()) as ContactState;
} catch {}
const values = Object.fromEntries(
[...data.entries()].filter((entry): entry is [string, string] => typeof entry[1] === "string"),
);
return { status: "error", message: serverError, values };
}
}On the server, the route checks one thing before it reads the form: that the request comes from one of our own pages. Next does the same for Server Actions, comparing the browser's Origin with the Host. Without it, the route would be an open door any other site could post to.
export async function POST(request: Request) {
if (!sameOrigin(request)) return new Response(null, { status: 403 });
let data: FormData;
try {
data = await request.formData();
} catch {
return new Response(null, { status: 400 });
}
return Response.json(await sendLead(data), {
headers: { "Cache-Control": "no-store", "X-Robots-Tag": "noindex, nofollow" },
});
}
function sameOrigin(request: Request): boolean {
const origin = request.headers.get("origin");
const host = request.headers.get("host");
if (!origin || !host) return false;
try {
return new URL(origin).host === host;
} catch {
return false;
}
}The route answers with the same result the action returns, the ContactState the form already knows how to show: a success, or an error per field with the values typed. So the visitor sees the same confirmation or the same field errors whichever door the brief used, and never learns that a deploy happened in between.
What the fallback doesn't skip

A second way in is only safe if it is no easier than the first. Ours runs the same checks, because it calls the same function:
- the honeypot field people never see, which bots fill in;
- the reCAPTCHA token, which our API verifies;
- the API's rate limit, which counts every brief the same way whichever door it came through;
- the same-origin check, which answers 403 to a request without an
Originor from another site.
The route's answer is never cached and asks search engines not to index it. And because the checks live in one file, a change to them is made once. The same helper also sends the briefs from See what fits, our short guided form, so it got the fallback for free.
How we checked it
We ran the site against the API in Docker and sent a brief normally: it went through the action. Then we rewrote the action ID in the page to one the server didn't know. The server logged "Failed to find Server Action", the form fell back to /api/contact, showed the confirmation, and the brief was stored. A request to the route without an Origin, or with another site's, got a 403.
A deploy shouldn't cost a visitor what they wrote.
- If a form uses a Server Action, assume some visitor will send it from a tab older than your last deploy.
- Keep the work in a plain server function, so a route handler can call it too.
- Retry for the visitor instead of asking them to refresh, and keep their values if the retry fails.
- Give the second way in every check the first one has, and check the origin yourself.
If your product has a form or a flow that breaks on deploys, see what fits or read how we build web platforms.


