# Fix "Failed to find Server Action" in Next.js After a Deploy

> 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.

- URL: https://qodesign.dev/blog/nextjs-failed-to-find-server-action-after-deploy
- Author: Teodor Avadani (https://qodesign.dev/blog/author/teodor-avadani)
- Published: 2026-10-21
- Updated: 2026-10-21
- Category: Next.js fixes
- Publisher: qodesign (https://qodesign.dev), a shop of animated React + Tailwind CSS components for Next.js, each sold with its full source code and an AI prompt.

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](/components/pull-cord-login) 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.

## What the visitor sees

The test form is the standard `useActionState` setup. `app/actions.ts`:

```ts
"use server";

export type SubscribeState = { ok: boolean; message: string };

export async function subscribe(
  _prev: SubscribeState,
  formData: FormData,
): Promise<SubscribeState> {
  const email = String(formData.get("email") ?? "").trim();
  if (!email.includes("@")) {
    return { ok: false, message: "That email doesn't look right." };
  }
  // save the email here (database, Resend audience, etc.)
  return { ok: true, message: `Subscribed ${email}` };
}
```

and the client form, `app/subscribe/subscribe-form.tsx`:

```tsx
"use client";

import { useActionState } from "react";
import { subscribe, type SubscribeState } from "../actions";

const initial: SubscribeState = { ok: false, message: "" };

export function SubscribeForm() {
  const [state, formAction, pending] = useActionState(subscribe, initial);

  return (
    <form action={formAction} className="flex flex-col gap-3">
      <input
        name="email"
        type="email"
        required
        placeholder="you@example.com"
        className="rounded-lg bg-zinc-900 px-3 py-2"
      />
      <button disabled={pending} className="rounded-lg bg-lime-400 px-3 py-2 text-zinc-950">
        {pending ? "Sending..." : "Subscribe"}
      </button>
      <p id="result" aria-live="polite">{state.message}</p>
    </form>
  );
}
```

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.

## Why the id changes when the code didn't

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.

## Fix 1: pin the key at build time

Generate a key once:

```bash
openssl rand -base64 32
```

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:

1. **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.
2. **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.
3. **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.

## Fix 2: post the form to a route handler

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`:

```tsx
"use client";

import { useState, type FormEvent } from "react";

export function NewsletterForm({ initialMessage }: { initialMessage: string }) {
  const [message, setMessage] = useState(initialMessage);
  const [pending, setPending] = useState(false);

  async function onSubmit(event: FormEvent<HTMLFormElement>) {
    event.preventDefault();
    setPending(true);
    try {
      const res = await fetch("/api/subscribe", {
        method: "POST",
        headers: { Accept: "application/json" },
        body: new FormData(event.currentTarget),
      });
      const data: { message: string } = await res.json();
      setMessage(data.message);
    } catch {
      setMessage("Couldn't reach the server. Try again.");
    } finally {
      setPending(false);
    }
  }

  return (
    <form action="/api/subscribe" method="post" onSubmit={onSubmit} className="flex flex-col gap-3">
      <input
        name="email"
        type="email"
        required
        placeholder="you@example.com"
        className="rounded-lg bg-zinc-900 px-3 py-2"
      />
      <button disabled={pending} className="rounded-lg bg-lime-400 px-3 py-2 text-zinc-950">
        {pending ? "Sending..." : "Subscribe"}
      </button>
      <p id="result" aria-live="polite">{message}</p>
    </form>
  );
}
```

and the page that shows the result after a no-JS submit, `app/newsletter/page.tsx`:

```tsx
import { NewsletterForm } from "./newsletter-form";

export default async function Page({
  searchParams,
}: {
  searchParams: Promise<{ subscribed?: string; error?: string }>;
}) {
  const { subscribed, error } = await searchParams;
  const message = subscribed ? "Subscribed." : error ? "That email doesn't look right." : "";

  return (
    <main className="mx-auto max-w-sm p-8">
      <NewsletterForm initialMessage={message} />
    </main>
  );
}
```

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`.

## Which one to use

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.

## A login form that doesn't care how you post

qodesign's [Pull-Cord Login](/components/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](/react/firebase-auth-login-page) shows that last one. If you'd rather have a spotlight than a cord, [Spotlight Lamp Login](/components/lamp-login) works the same way. For the accessibility side of an animated login form, see [this post on accessible animated login forms](/blog/accessible-animated-login-form-react). Free pieces with full source are at [/free](/free).

## FAQ

### What does "Failed to find Server Action" mean in Next.js?

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.

### How do I fix Server Actions breaking after every deploy?

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.

### Does NEXT_SERVER_ACTIONS_ENCRYPTION_KEY need to be set at runtime too?

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.

### Should I use a route handler instead of a Server Action for forms?

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.
