# Añade un formulario de contacto a un sitio de Cloudflare Pages

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

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

Los assets estáticos de Cloudflare Pages responden a un `POST` con `405`, así que o escribes una Pages Function o envías el formulario a un backend de formularios. Con Ikiform no escribes ninguna Function: pon tu endpoint en el `action` del formulario, añade `your-project.pages.dev` a **Allowed domains**, que también cubre todas las URL de preview, e incluye Ikiform en la Content-Security-Policy de tu `_headers`. Si ya usas Cloudflare, puedes conectar tus propias claves de Turnstile al formulario.

## Qué cambia en Cloudflare Pages

Pages sirve el resultado de tu build desde la red de Cloudflare. Los assets estáticos responden a `GET`; un `POST` a uno recibe `405` sin cuerpo:

`Respuesta a un POST`:

```http
HTTP/2 405
server: cloudflare
content-length: 0
```

Pages puede ejecutar código, mediante Pages Functions en una carpeta `functions/`. Un `functions/contact.ts` podría aceptar el envío, pero entonces estás escribiendo un backend: lectura, validación, un proveedor de email, filtrado de spam y almacenamiento. La propia documentación de Cloudflare recomienda además empezar los proyectos nuevos en Workers en lugar de Pages. Enviar a un backend de formularios evita ambas cosas, y el mismo formulario funciona sin cambios si más adelante pasas a los static assets de Workers.

| Rasgo de Pages | Qué significa para el formulario |
| --- | --- |
| Las URL de preview son subdominios, como `abc123.your-project.pages.dev` | Un solo dominio permitido, `your-project.pages.dev`, cubre producción y todas las previews. Las previews de Vercel y Netlify necesitan sus propias entradas |
| Las cabeceras salen de un archivo `_headers` en el resultado del build | Hasta 100 reglas, 2.000 caracteres por línea. No se aplican a las respuestas de Pages Functions |
| Turnstile es un producto de Cloudflare | Puedes usar la verificación gestionada de Ikiform sin claves, o tu propio widget desde el panel de Cloudflare |

Diagrama: Un formulario de contacto en Cloudflare Pages con tus propias claves de Turnstile

1. Pages sirve la página de contacto, con la Content-Security-Policy de tu archivo _headers.
2. El widget de Turnstile se ejecuta con tu site key y pone un token en el formulario.
3. El formulario envía sus campos y el token a Ikiform.
4. Ikiform verifica el token con tu clave secreta y compara el hostname del token con tus dominios permitidos.
5. Un envío válido se guarda y se manda por email. Un token que falta, caducado o reutilizado recibe 403 captcha_failed, y la página reinicia el widget.

Con la verificación gestionada de Ikiform, en cambio, no hay widget en tu página; el formulario HTML no necesita ningún cambio.

## El formulario

Esta versión usa tu propio widget de Turnstile, lo que tiene sentido cuando ya gestionas Turnstile en tu cuenta de Cloudflare. En los ajustes del formulario, pon **Spam check** en **Your own Turnstile keys** y pega la site key y el secreto. El widget añade un campo `cf-turnstile-response` al formulario, y el select `team` te dice quién debe responder:

`public/contact.html`:

```html
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>

<form id="contact" action="https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx" 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="team">Who should read this?</label>
  <select id="team" name="team">
    <option value="support">Support</option>
    <option value="sales">Sales</option>
    <option value="press">Press</option>
  </select>

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

  <div class="cf-turnstile" data-sitekey="0x4AAAAAAAxxxxxxxxxxxxxx"></div>
  <input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
  <button type="submit">Send</button>
  <p id="contact-status" role="status"></p>
</form>

<script src="/contact.js" defer></script>
```

El script mantiene a los visitantes en la página. Un token de Turnstile es de un solo uso, así que tras cualquier envío fallido reinicia el widget para el siguiente intento:

`public/contact.js`:

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

form.addEventListener("submit", async (event) => {
  event.preventDefault();
  status.textContent = "Sending";
  const response = await fetch(form.action, {
    method: "POST",
    headers: { Accept: "application/json" },
    body: new FormData(form),
  }).catch(() => null);
  const result = await response?.json().catch(() => null);

  if (result?.ok) {
    form.replaceWith(Object.assign(document.createElement("p"), { textContent: "Sent. We'll reply by email." }));
    return;
  }

  window.turnstile?.reset();
  if (result?.error?.code === "captcha_failed") {
    status.textContent = "The human check expired. Complete it again, then send.";
    return;
  }
  const fields = result?.error?.fields ?? {};
  status.textContent = Object.values(fields)[0]?.[0] ?? result?.error?.message ?? "Couldn't send. Try again.";
});
```

- En los ajustes del widget de Turnstile en Cloudflare, indica los hostnames en los que funciona el formulario. Ikiform también compara el hostname del token con **Allowed domains** del formulario.
- Los envíos sin un token válido se rechazan con `403 captcha_failed`. Eso incluye un envío HTML clásico hecho antes de que el widget terminara.
- ¿Prefieres no tener ninguna clave? Pon **Spam check** en **Ikiform**, borra el widget y su script, y el envío HTML hace la verificación en ikiform.com. La versión con `fetch` necesita entonces `challenge()` del [SDK](https://ikiform.com/es/docs/sdk.md); consulta [la verificación humana](https://ikiform.com/es/docs/spam.md#human-check).

## Tus claves o la verificación de Ikiform

|   | Verificación de Ikiform | Tus propias claves de Turnstile |
| --- | --- | --- |
| Claves que gestionar | Ninguna | Una site key y un secreto de tu panel de Cloudflare |
| Cambio en el formulario HTML | Ninguno. La verificación se hace en una página de ikiform.com | Añadir el widget y su script |
| Versión con `fetch` | Llamar a `challenge()` del SDK | Enviar el `cf-turnstile-response` del widget |
| Content-Security-Policy | Solo `form-action` y `connect-src` para Ikiform | También `challenges.cloudflare.com` en `script-src` y `frame-src` |
| Analíticas de Turnstile | No en tu cuenta | En tu panel de Cloudflare, junto a tus otros widgets |

La verificación de Ikiform requiere menos configuración. Tus propias claves tienen sentido cuando ya sigues las analíticas de Turnstile, o quieres controlar el aspecto y la posición del widget.

### Probar el widget en local

Cloudflare publica site keys y secretos de prueba que siempre aprueban o siempre fallan, como el par que siempre aprueba `1x00000000000000000000AA` y `1x0000000000000000000000000000000AA`. Los secretos de producción rechazan los tokens generados con una clave de prueba, así que no los mezcles en un mismo formulario. Crea un segundo formulario de Ikiform para trabajar en local, dale el par de claves de prueba y cambia el `data-sitekey` y el id del formulario a la vez. Deja vacío **Allowed domains** en ese formulario: Ikiform solo comprueba el hostname de un token cuando la lista está configurada, y Cloudflare no documenta qué hostname indica un token de prueba.

## El archivo _headers

Pon `_headers` en tu directorio de assets estáticos, como `public/`, para que acabe en el resultado del build. Turnstile necesita `script-src` y `frame-src` para `https://challenges.cloudflare.com`, e Ikiform necesita `form-action` y `connect-src`:

`public/_headers`:

```text
/*
  Content-Security-Policy: default-src 'self'; script-src 'self' https://challenges.cloudflare.com; frame-src https://challenges.cloudflare.com; form-action 'self' https://ikiform.com; connect-src 'self' https://ikiform.com
```

Si más adelante añades una Pages Function al mismo proyecto, sus respuestas no reciben estas cabeceras. Defínelas en el código de la Function.

## Si te pasas a Workers

Mover el sitio a los static assets de Workers cambia cómo se despliega, no cómo funciona el formulario. El formulario sigue enviando a ikiform.com, así que nada en el lado de Cloudflare lo gestiona. Comprueba dos cosas después del cambio: que el nuevo host `workers.dev` o la ruta esté en **Allowed domains**, y que tus cabeceras sigan llegando a la página, ya que Workers tiene su propia forma de definirlas.

> Añade `your-project.pages.dev` y tu dominio personalizado en **Allowed domains**. Nunca añadas `pages.dev` por sí solo: coincidiría cualquier proyecto de Pages.

## Comprueba que funciona

1. Sube una rama y abre su URL de preview. Envía y confirma que el mensaje llega y no se marca como spam: la preview es un subdominio del host de tu proyecto.
2. Envía otra vez en producción, luego vuelve atrás y envía el mismo formulario una segunda vez sin completar el widget. Deberías ver el mensaje de reinicio, no un envío guardado.
3. Abre **Submissions** en el panel. El nuevo envío está ahí con todos los campos que enviaste.
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 |
| --- | --- |
| `405` con el cuerpo vacío | El formulario envió a una URL de Pages. Revisa el `action` desplegado, y que ninguna Function ni regla de redirección reescriba `/contact`. |
| El widget de Turnstile no aparece nunca | La Content-Security-Policy bloquea `challenges.cloudflare.com`. Añádelo a `script-src` y `frame-src`, y comprueba que `_headers` está en el resultado del build. |
| `403 captcha_failed` en todos los envíos | La site key y el secreto son de widgets distintos, el hostname no está en la lista del widget, o un secreto de prueba está emparejado con una site key real. |
| `403 origin_not_allowed`, o todos los envíos acaban en spam | El dominio de la página no está en **Allowed domains** del formulario. Añádelo, más `localhost` para las pruebas locales. Los subdominios de un dominio de la lista están permitidos. |

## Preguntas

### ¿Necesito Pages Functions para un formulario de contacto en Cloudflare Pages?

No. Una Function es una forma de aceptar el envío, pero entonces escribes tú la validación, el envío de emails y el filtrado de spam. Enviar a un backend de formularios mantiene el proyecto totalmente estático.

### ¿Los despliegues de preview necesitan su propio dominio permitido?

No. Las URL de preview de Pages son subdominios de your-project.pages.dev, así que permitir ese host las cubre todas.

### ¿Puedo usar mi widget de Turnstile actual con Ikiform?

Sí. Configura la comprobación antispam del formulario con tus propias claves de Turnstile y pega la site key y el secreto. Ikiform verifica cada token y compara su hostname con tus dominios permitidos.

### ¿Por qué mi formulario recibe 405 en Cloudflare Pages?

Envió a tu propia URL de Pages, que sirve assets estáticos que solo responden a peticiones GET. Apunta el action del formulario a un backend de formularios, o añade una Pages Function para esa ruta.

### ¿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.
- [Vercel](https://ikiform.com/es/guides/contact-form-static-site/vercel.md): 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.
- [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.
- [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.
