Qué pasó
Nuestro formulario de contacto envía el brief con una Server Action: una función del servidor que Next.js deja llamar directamente desde un formulario. El 5 de octubre, un envío respondió con un 500 y esta línea en el log del servidor: "Failed to find Server Action. This request might be from an older or newer deployment".
El código no tenía nada mal. La pestaña había cargado la página antes del despliegue, así que llamó a la acción con el ID que le dio el build anterior. El servidor nuevo nunca había visto ese ID. La persona vio un error y perdió lo que había escrito.
La regla que sacamos
Un despliegue no debería costarle a un visitante lo que escribió. Un formulario es la página donde un error duro le hace perder trabajo a alguien, no solo tiempo.
Por qué cambia el ID de la acción
En el navegador, una Server Action es solo una referencia: un ID que la página envía de vuelta al servidor con un POST. La guía de Server Actions de Next (en inglés) dice que el ID es parte del build, que los despliegues nuevos suelen generar IDs nuevos y que Next los rota como máximo cada 14 días aunque el código no cambie (consultada el 9 de octubre de 2026). Una pestaña que sigue abierta durante un despliegue se queda con el viejo.
- lo máximo que Next.js conserva un ID de acción, aunque el código no cambie
- 14 días
- entradas para un brief: la Server Action y /api/contact
- 2
- archivo con los controles que comparten las dos
- 1
La misma guía propone tres maneras de suavizarlo: preferir despliegues graduales, mantener estable la NEXT_SERVER_ACTIONS_ENCRYPTION_KEY entre instancias, y mostrar el error como un camino para reintentar en vez de un fallo duro. Vercel además tiene una función para esto, Skew Protection, que mantiene disponibles las acciones del despliegue anterior. Nosotros corremos en Cloud Run, así que no era opción, y las dos primeras no ayudan a una pestaña abierta una hora antes del despliegue. La tercera es la que construimos, con un giro: en vez de pedirle al visitante que recargue y vuelva a escribir, el formulario reintenta por él.
Una segunda entrada
El arreglo le da al brief dos puertas a la misma sala. La Server Action sigue siendo la primera. Cuando su llamada falla, el formulario envía el mismo FormData a /api/contact, un route handler cuya dirección no cambia entre builds. Las dos puertas llevan a una sola función que hace el trabajo de verdad.
- 01Sacamos el envío de la Server Action a
lib/lead-submit.ts: el honeypot, la validación, la llamada a nuestra API y los mensajes de error en el idioma del formulario. - 02La Server Action quedó en tres líneas que la llaman.
- 03Agregamos
app/api/contact/route.ts, que lee los mismos datos del formulario y llama a la misma función. - 04El formulario llama primero a la acción, y a la ruta solo cuando la acción falla.
El código
En el cliente, una función envuelve la acción. Si fallan las dos puertas, conserva todo lo que escribió el visitante y muestra un mensaje con nuestro correo, para que el brief igual pueda llegarnos.
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 };
}
}En el servidor, la ruta revisa una cosa antes de leer el formulario: que la petición venga de una de nuestras páginas. Next hace lo mismo con las Server Actions, comparando el Origin del navegador con el Host. Sin eso, la ruta sería una puerta abierta a la que cualquier otro sitio podría enviar.
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;
}
}La ruta responde con el mismo resultado que devuelve la acción, el ContactState que el formulario ya sabe mostrar: un éxito, o un error por campo con lo que se escribió. Así el visitante ve la misma confirmación o los mismos errores sin importar por qué puerta entró el brief, y nunca se entera de que hubo un despliegue en el medio.
Lo que el respaldo no se salta

Una segunda entrada solo es segura si no es más fácil que la primera. La nuestra pasa por los mismos controles, porque llama a la misma función:
- el campo honeypot que las personas no ven y los bots llenan;
- el token de reCAPTCHA, que verifica nuestra API;
- el límite de envíos de la API, que cuenta cada brief igual sin importar por qué puerta llegó;
- el control de origen, que responde 403 a una petición sin
Origino de otro sitio.
La respuesta de la ruta nunca se guarda en caché y pide a los buscadores no indexarla. Y como los controles viven en un archivo, un cambio se hace una sola vez. La misma función envía los briefs de Ver qué te sirve, nuestro formulario guiado, así que también ganó el respaldo.
Cómo lo probamos
Corrimos el sitio contra la API en Docker y enviamos un brief normal: pasó por la acción. Luego cambiamos en la página el ID de la acción por uno que el servidor no conocía. El servidor registró "Failed to find Server Action", el formulario pasó a /api/contact, mostró la confirmación y el brief quedó guardado. Una petición a la ruta sin Origin, o con el de otro sitio, recibió un 403.
Un despliegue no debería costarle a un visitante lo que escribió.
- Si un formulario usa una Server Action, asume que algún visitante lo enviará desde una pestaña más vieja que tu último despliegue.
- Deja el trabajo en una función normal del servidor, para que un route handler también pueda llamarla.
- Reintenta por el visitante en vez de pedirle que recargue, y conserva sus valores si el reintento falla.
- Dale a la segunda entrada todos los controles de la primera, y revisa tú mismo el origen.
Si tu producto tiene un formulario o un flujo que se rompe con cada despliegue, mira qué te sirve o lee cómo construimos plataformas web.


