Formulario de contacto en un sitio estático

Formulario de contacto en un sitio estático7 min de lectura

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.

Preet SutharFundador, Ikiform

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/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 PagesQué significa para el formulario
Las URL de preview son subdominios, como abc123.your-project.pages.devUn 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 buildHasta 100 reglas, 2.000 caracteres por línea. No se aplican a las respuestas de Pages Functions
Turnstile es un producto de CloudflarePuedes usar la verificación gestionada de Ikiform sin claves, o tu propio widget desde el panel de Cloudflare
Un formulario de contacto en Cloudflare Pages con tus propias claves de Turnstilepáginaenpages.devservidacon_headerswidgetdeTurnstiletu sitekeyIkiformverificacon tusecretoguardadoyenviado403captcha_failedreiniciael widgettokenPOST + tokentoken inválido o usadoUn formulario de contacto en Cloudflare Pages con tus propias claves de Turnstilepágina enpages.devservida con _headerswidget deTurnstiletu site keyIkiformverifica con tusecretoguardado yenviado403captcha_failedreinicia elwidgettokenPOST + tokentoken inválido o usado
  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
<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
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; consulta la verificación humana.

Tus claves o la verificación de Ikiform

ItemVerificación de IkiformTus propias claves de Turnstile
Claves que gestionarNingunaUna site key y un secreto de tu panel de Cloudflare
Cambio en el formulario HTMLNinguno. La verificación se hace en una página de ikiform.comAñadir el widget y su script
Versión con fetchLlamar a challenge() del SDKEnviar el cf-turnstile-response del widget
Content-Security-PolicySolo form-action y connect-src para IkiformTambién challenges.cloudflare.com en script-src y frame-src
Analíticas de TurnstileNo en tu cuentaEn 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
/*
  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

ProblemaQué hacer
405 con el cuerpo vacíoEl 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 nuncaLa 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íosLa 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 spamEl 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

¿Te atascaste en un paso o quieres una guía para tu caso? Escribe a hello@ikiform.com.