# Soluciona el error 405 en un formulario de contacto alojado en Vercel

> Una página estática en Vercel responde al POST de un formulario con 405 Method Not Allowed; aquí verás por qué y cómo enviar el formulario a un backend.

Publicado el 2026-10-25 · https://ikiform.com/es/guides/contact-form-static-site/vercel

Vercel sirve tu build estático como archivos, y un `POST` a un archivo recibe `405` con el cuerpo vacío porque ninguna función atiende esa ruta. Apunta el `action` del formulario a tu endpoint de Ikiform, o escribe una Vercel Function que acepte el envío. Con Ikiform, define el id del formulario por entorno de Vercel para que las previews envíen a un formulario de prueba y producción al real.

## Por qué Vercel responde 405

Vercel dirige cada petición a un archivo estático o a una función. Un sitio estático, un build de Vite o una app de Next.js con `output: "export"` solo tiene archivos. Un `GET` a `/contact` devuelve el archivo; un `POST` a la misma ruta no tiene a dónde ir, así que Vercel lo rechaza antes de que pueda ejecutarse nada de tu código:

`Terminal`:

```bash
curl -i -X POST -d "message=hello" https://your-project.vercel.app/contact
```

`Respuesta`:

```http
HTTP/2 405
server: Vercel
content-disposition: inline
x-vercel-cache: HIT
content-length: 0
```

La respuesta no tiene cuerpo, así que el navegador muestra una página en blanco o su propia pantalla de error. Tres configuraciones llevan a esto:

| Tu código | Por qué da 405 en Vercel |
| --- | --- |
| `<form method="post">` sin `action` | El formulario envía a la página en la que está, que es un archivo estático |
| `action="/api/contact"` en un proyecto de Vite o de HTML plano | No hay ningún archivo en una carpeta `api/` en la raíz del proyecto, así que no existe ninguna función para esa ruta |
| Una Server Action o un Route Handler de Next.js en una exportación estática | `output: "export"` no admite Server Actions ni Route Handlers que leen la petición. `next dev` informa del error, y el build no tiene servidor para ejecutarlos |

Diagrama: Cómo decide Vercel qué hacer con el POST de un formulario

1. El formulario envía a una ruta de tu despliegue en Vercel.
2. Vercel busca una función que atienda esa ruta.
3. Si escribiste una, se ejecuta. En un build estático no hay ninguna, así que Vercel responde 405 con el cuerpo vacío.
4. Con el action apuntando a Ikiform, el envío nunca llega a Vercel.
5. Ikiform guarda el mensaje, te envía un email y redirige el navegador de vuelta a una página estática de tu sitio en Vercel.

Vercel no está roto. Un despliegue estático no tiene código para un POST, y una función es la única forma de añadirlo en el lado de Vercel.

Así que o añades una función o mandas el envío a otro sitio. Una Vercel Function en `api/contact.ts` funciona, pero entonces necesitas una API de email, filtrado de spam y límites de frecuencia, y mantenerlos tú. Mandar el envío a Ikiform mantiene el despliegue estático. La solución en el HTML es un atributo:

`Antes`:

```html
<form method="post">
  <input name="email" type="email" required />
  <textarea name="message" required></textarea>
  <button>Send</button>
</form>
```

`Después`:

```html
<form action="https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx" method="post">
```

### Si prefieres escribir la función

Una función es una opción razonable cuando el mensaje tiene que ir a algún lugar al que solo llega tu código. Calcula lo que implica antes de empezar:

- Una cuenta en un proveedor de email, un dominio de envío verificado y una clave de API en las variables de entorno de Vercel.
- Validación en el servidor, porque los atributos `required` del navegador son una comodidad, no una comprobación.
- Filtrado de spam y límites de frecuencia, o un formulario de contacto que te reenvía cada bot de internet.
- Un lugar donde guardar los mensajes cuando el proveedor de email está caído, y una forma de reenviarlos.

Un backend de formularios cubre esa lista por ti. Si aun así quieres tu propio código en el circuito, deja que el formulario envíe a Ikiform y recibe un webhook firmado en una función; [tu propio servidor](https://ikiform.com/blog/form-submissions-to-your-own-server.md) muestra el handler.

## Un id de formulario por entorno de Vercel

Cada pull request en Vercel genera un despliegue de preview. Si las previews envían a tu formulario de producción, cada mensaje de prueba acaba en tu bandeja de entrada junto a los reales. Crea un segundo formulario de Ikiform para las previews y deja que las variables de entorno de Vercel elijan. En **Settings › Environment Variables**, añade el mismo nombre dos veces:

`Variables de entorno de Vercel`:

```text
VITE_IKIFORM_FORM_ID   Production   frm_xxxxxxxxxxxxxxxx
VITE_IKIFORM_FORM_ID   Preview      frm_yyyyyyyyyyyyyyyy
```

Vite sustituye los placeholders `%NAME%` de `index.html` por variables `VITE_` en el build, así que un formulario HTML plano puede usarlas sin JavaScript. Vercel define `VITE_VERCEL_ENV` en los proyectos de Vite, y registra de qué despliegue vino un mensaje:

`index.html`:

```html
<form id="contact" action="https://ikiform.com/f/%VITE_IKIFORM_FORM_ID%" method="post">
  <label for="name">Name</label>
  <input id="name" name="name" autocomplete="name" required />

  <label for="email">Email</label>
  <input id="email" name="email" type="email" autocomplete="email" required />

  <label for="message">Message</label>
  <textarea id="message" name="message" rows="6" minlength="10" required></textarea>

  <input type="hidden" name="build" value="%VITE_VERCEL_ENV%" />
  <input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
  <button type="submit">Send message</button>
</form>
```

- En un sitio estático, Vercel lee las variables de entorno en el build. Después de cambiar una, vuelve a desplegar, o el valor antiguo se queda en el HTML.
- Vite deja el placeholder tal cual cuando la variable no existe, así que un id que falta se publica como `%VITE_IKIFORM_FORM_ID%` y todos los envíos fallan con `400 Bad Request` antes de llegar al formulario. La comprobación de abajo lo detecta.
- El id del formulario es público en cualquier caso. Solo las variables `VITE_` llegan al navegador, y por eso ningún secreto debe ir en una.

`src/contact.js`:

```js
const form = document.querySelector("#contact");

if (form && form.action.includes("%VITE_")) {
  console.error("VITE_IKIFORM_FORM_ID is not set for this Vercel environment");
  form.querySelector("button").disabled = true;
}
```

¿Usas Next.js con `output: "export"`? Lee `process.env.NEXT_PUBLIC_IKIFORM_FORM_ID` en un componente de cliente; la [guía principal](https://ikiform.com/es/guides/contact-form-static-site.md#form) tiene un formulario para exportación estática. Si tu app de Next.js sí se ejecuta en los servidores de Vercel, una Server Action puede enviar con una clave secreta: consulta la [guía de Next.js](https://ikiform.com/es/docs/guides/nextjs.md#server-action).

## Cabeceras y URL limpias

Si tu sitio define una Content-Security-Policy, ponla en `vercel.json` e incluye Ikiform. `cleanUrls` sirve `thanks.html` en `/thanks`, lo que da un destino de redirección más limpio:

`vercel.json`:

```json
{
  "$schema": "https://openapi.vercel.sh/vercel.json",
  "cleanUrls": true,
  "headers": [
    {
      "source": "/(.*)",
      "headers": [
        {
          "key": "Content-Security-Policy",
          "value": "default-src 'self'; form-action 'self' https://ikiform.com; connect-src 'self' https://ikiform.com"
        }
      ]
    }
  ]
}
```

## Previews y dominios permitidos

Las URL de preview como `your-project-git-branch-team.vercel.app` no son subdominios de `your-project.vercel.app`, así que no coinciden con él en **Allowed domains**. Los envíos desde ellas acaban en spam con `origin_mismatch`. Es otra razón para tener un formulario aparte para las previews: deja vacíos sus dominios permitidos, o añade el host de preview que estés probando.

> Con Vercel Authentication activada y el alcance Standard Protection, que cubre todos los despliegues salvo los dominios de producción, las URL de preview quedan detrás de un login de Vercel. Solo tu equipo puede acceder a ellas, así que un formulario de preview sin dominios permitidos no queda expuesto al público. Revisa **Settings › Deployment Protection** antes de confiar en eso. Los envíos desde previews no son pruebas de `localhost`, así que cuentan para tu plan como cualquier envío real.

## Comprueba que funciona

1. Ejecuta el `curl` de arriba contra tu URL de producción antes de la solución y confirma el `405`. Después de la solución, el `action` del formulario en el HTML desplegado empieza por `https://ikiform.com/f/`.
2. Abre un despliegue de preview, envía y confirma que el mensaje llega al formulario de preview, no al de producción.
3. Envía desde tu dominio de producción y comprueba que el campo `build` dice `production`.
4. Revisa la bandeja de entrada de cada destinatario de notificaciones. Cada uno recibe un email con los campos, y **Reply** va a quien envió el formulario cuando este tiene un campo `email`.
5. ¿No llegó nada? Cambia el filtro de estado del panel a **Spam**. El spam recibe la misma respuesta de éxito que un envío real.

## Problemas habituales

| Problema | Qué hacer |
| --- | --- |
| Sigue dando `405` después de cambiar el formulario | El despliegue que estás probando se generó antes del cambio, o un handler de JavaScript envía a tu propia ruta con `fetch`. Busca `/api/contact` en los archivos generados. |
| `400 Bad Request` en todos los envíos, o `404 not_found` de Ikiform | El id del formulario no llegó al build, o es el id equivocado para ese entorno. Comprueba que la variable existe para ese entorno y vuelve a desplegar. |
| Las previews funcionan, pero los envíos de producción acaban en spam | Falta tu dominio de producción en **Allowed domains** del formulario de producción. Añade el dominio personalizado además de `your-project.vercel.app`. |
| El navegador llega a la página de agradecimiento de Ikiform en lugar de a la tuya | `_redirect` se ignora salvo que su dominio esté en **Allowed domains** y sea una URL `https:`. Añade el dominio, o configura **Redirect after submit** en los ajustes del formulario. |

## Preguntas

### ¿Por qué recibo 405 Method Not Allowed al enviar un formulario en Vercel?

El formulario envía a una ruta que solo tiene un archivo estático. Vercel sirve archivos para peticiones GET y, sin una función para esa ruta, rechaza el POST con 405. Envía el formulario a un backend de formularios o añade una Vercel Function.

### ¿Puedo usar una Server Action de Next.js en una exportación estática?

No. Las Server Actions no funcionan con output: export, porque no hay servidor que las ejecute. Envía desde un componente de cliente a un backend de formularios, o despliega sin la exportación estática para que Vercel ejecute la action.

### ¿Deben los despliegues de preview usar el mismo formulario que producción?

Mejor que no. Dale a las previews su propio id de formulario mediante una variable de entorno de Preview, para que los mensajes de prueba no lleguen a tu bandeja de entrada ni a tus webhooks.

### ¿Necesito vercel.json para un formulario de contacto?

No. El formulario funciona sin él. Añade vercel.json solo para definir una Content-Security-Policy que permita ikiform.com, o para activar cleanUrls y tener una URL de agradecimiento más limpia.

### ¿Ikiform es gratis?

No, no hay plan gratuito. Probar es gratis: puedes enviar 50 envíos de prueba al mes desde localhost antes de pagar. Para pasar a producción, Pro empieza en 6 $/mes por 500 envíos, o puedes pagar una sola vez 10.000 envíos por 49 $ o 50.000 envíos por 149, $ con créditos que no caducan nunca.

## Más guías

- [Formulario de contacto en un sitio estático](https://ikiform.com/es/guides/contact-form-static-site.md): Añade un formulario de contacto que funcione a un sitio estático sin servidor: a dónde va el POST, el spam, las respuestas por email y código para cada host y framework.
- [Netlify](https://ikiform.com/es/guides/contact-form-static-site/netlify.md): Usa un formulario de contacto en un sitio de Netlify sin Netlify Forms: sin detección en el build, envíos JSON desde React y un formulario aparte para las deploy previews.
- [Cloudflare Pages](https://ikiform.com/es/guides/contact-form-static-site/cloudflare-pages.md): Añade un formulario de contacto a Cloudflare Pages sin escribir una Function: envía a un backend de formularios, define cabeceras en _headers y usa tus propias claves de Turnstile.
- [GitHub Pages](https://ikiform.com/es/guides/contact-form-static-site/github-pages.md): GitHub Pages no ejecuta código de servidor, así que un POST de formulario recibe 405 Not Allowed; envía a un backend de formularios y configura bien las redirecciones del project site.
- [Amazon S3](https://ikiform.com/es/guides/contact-form-static-site/aws-s3.md): Los website endpoints de S3 solo aceptan GET y HEAD por HTTP plano, así que envía el formulario de contacto a un backend y configura bien las redirecciones con CloudFront.
- [Web de portfolio](https://ikiform.com/es/guides/contact-form-static-site/portfolio-website.md): Un formulario de contacto para portfolio que hace las preguntas de una primera llamada: proyecto, presupuesto, plazos y un enlace, sin servidor y respondiendo desde tu bandeja.
