Los sitios estáticos son fantásticos: rápidos, baratos de alojar, seguros y facilísimos de desplegar. Entonces un cliente pregunta “¿pueden los visitantes escribirnos desde el sitio?” y de pronto te topas con lo único que un sitio estático no puede hacer por sí solo: recibir el envío de un formulario.
La buena noticia es que no necesitas un backend ni un servidor para esto. Al terminar esta guía tendrás un formulario de contacto totalmente funcional, accesible y resistente al spam, hecho con HTML puro y un poco de JavaScript, con código completo para copiar y pegar, no plantillas a medias.
Las opciones para gestionar formularios sin backend
En realidad hay cuatro enfoques habituales, y no son equivalentes.
1. Enlaces mailto:. Cero configuración. <a href="mailto:tu@example.com"> abre el cliente de correo del visitante. Pero la experiencia es tosca: depende de que haya un programa de correo configurado en el escritorio, deja tu dirección expuesta a los rastreadores y no recoge nada estructurado. Vale para una página personal, no para un contacto real.
2. Formulario de Google incrustado. Gratis y sin código, pero el iframe no se parece en nada a tu sitio, juega en contra de tu diseño y tu rendimiento, y la experiencia grita “esto es un formulario de Google”. Aceptable para un proyecto personal, flojo para cualquier cosa que deba verse profesional.
3. Gestión de formularios del propio hosting. Netlify Forms y algunos otros proveedores ofrecen captura de formularios integrada. Está muy bien, si estás en ese hosting. En cuanto te mudas a GitHub Pages, Cloudflare Pages o tu propio bucket de S3, desaparece. Quedas atado.
4. Una API de backend de formularios. Apuntas tu <form> a un endpoint; el servicio recibe los envíos, filtra el spam y te manda un correo (o dispara un webhook). Independiente del hosting, funciona con cualquier framework y tu marcado sigue siendo 100 % tuyo. Este es el enfoque que funciona en todas partes, así que es el que vamos a construir.
Paso 1: El formulario HTML (accesible desde el principio)
Aquí tienes un formulario completo y accesible. Fíjate en los elementos <label> reales y en el emparejamiento for/id, justo la parte que la mayoría de los tutoriales se saltan.
<form
action="https://api.formpaste.com/submit"
method="POST"
id="contact-form"
>
<!-- Tu access key del panel -->
<input type="hidden" name="access_key" value="YOUR_ACCESS_KEY_HERE" />
<div>
<label for="name">Nombre</label>
<input type="text" id="name" name="name" autocomplete="name" required />
</div>
<div>
<label for="email">Correo electrónico</label>
<input type="email" id="email" name="email" autocomplete="email" required />
</div>
<div>
<label for="message">Mensaje</label>
<textarea id="message" name="message" rows="5" required></textarea>
</div>
<button type="submit">Enviar mensaje</button>
</form>
Eso ya es un formulario que funciona. Sin nada de JavaScript, el envío se manda al endpoint y llega a tu bandeja de entrada. Mejora progresiva bien hecha: funciona antes de que se ejecute una sola línea de JS.
Por defecto el endpoint responde con JSON. Sin JavaScript en la página, el navegador simplemente mostrará esa respuesta JSON, que no es la experiencia que buscas, así que consulta el Paso 3 para enviar al visitante a una página de agradecimiento.
Paso 2: Mejóralo con JavaScript (estados de carga y error como es debido)
El formulario simple provoca una navegación completa de página. Para que la gente se quede en la página con respuesta inmediata, vamos a mejorarlo. Fíjate en que esto selecciona el formulario de verdad por su id, contempla el fallo de red y da respuesta de estado real, los detalles que separan un formulario funcional de una demo.
<form action="https://api.formpaste.com/submit" method="POST" id="contact-form">
<input type="hidden" name="access_key" value="YOUR_ACCESS_KEY_HERE" />
<input type="text" id="name" name="name" required />
<input type="email" id="email" name="email" required />
<textarea id="message" name="message" required></textarea>
<button type="submit">Enviar mensaje</button>
<p id="form-status" role="status" aria-live="polite"></p>
</form>
<script>
const form = document.getElementById("contact-form");
const statusEl = document.getElementById("form-status");
const button = form.querySelector('button[type="submit"]');
form.addEventListener("submit", async (e) => {
e.preventDefault();
button.disabled = true;
statusEl.textContent = "Enviando…";
try {
const response = await fetch(form.action, {
method: "POST",
body: new FormData(form),
});
if (response.ok) {
statusEl.textContent = "¡Gracias! Tu mensaje se ha enviado.";
form.reset();
} else {
// La API devuelve { success: false, code, message } cuando falla.
const data = await response.json().catch(() => null);
statusEl.textContent = data?.message ?? "Algo ha salido mal. Inténtalo de nuevo.";
}
} catch (error) {
statusEl.textContent = "Error de red. Inténtalo de nuevo.";
} finally {
button.disabled = false;
}
});
</script>
La región con aria-live="polite" hace que los lectores de pantalla anuncien el cambio de estado, una mejora de accesibilidad que sale gratis.
Una respuesta correcta es un JSON con esta forma:
{ "success": true, "message": "Submission received.", "id": "..." }
Los errores usan el mismo envoltorio con un código legible por máquina, así que puedes ramificar según data.code si quieres mensajes distintos para rate_limited y para domain_not_allowed.
Paso 3: Redirección o AJAX, elige tu flujo de éxito
Hay dos formas de confirmar un envío, y la elección la hace la propia petición, no una cabecera.
JSON (lo predeterminado). Publicas el formulario y recibes { success, message, id } con un 200. Es en lo que se apoya el ejemplo de JavaScript de arriba: interceptar el envío, hacer fetch, mostrar un mensaje en la página y no salir nunca de ella.
Redirección. Añade un campo redirect al formulario y un envío correcto responderá con un 303 See Other a esa URL, de modo que el navegador aterriza en tu página de agradecimiento. Este es el camino sin JS.
<input type="hidden" name="redirect" value="/gracias" />
Dos reglas que conviene conocer, porque ambas fallan del lado seguro:
- Una URL relativa como
/graciassiempre funciona. - Una URL absoluta como
https://example.com/graciassolo funciona si ese dominio está en la lista de permitidos de tu formulario. Con la lista vacía, las redirecciones absolutas se rechazan sin más. Es deliberado: evita que una clave robada desvíe a tus visitantes a una página de phishing.
Una redirección inválida devuelve 400 invalid_redirect y no se guarda nada, así que te enteras al momento en vez de perder envíos en silencio.
Usa la redirección para máxima sencillez y robustez; usa JSON para una sensación más fluida de página única.
Frenar el spam sin CAPTCHA
Los CAPTCHA molestan a los usuarios reales y perjudican la conversión. Puedes frenar la mayor parte del spam automatizado con tres técnicas discretas, sin necesidad de acertijos.
Campo honeypot: un input oculto que los usuarios reales nunca rellenan, pero los bots sí. Ocúltalo a las personas y a los lectores de pantalla, y rechaza cualquier envío que lo traiga relleno. El nombre del campo tiene que coincidir con el que busque tu proveedor. Formpaste usa botcheck:
<!-- Los bots rellenan esto; las personas nunca lo ven -->
<input
type="text"
name="botcheck"
style="position:absolute; left:-9999px;"
tabindex="-1"
autocomplete="off"
aria-hidden="true"
/>
Si te inventas tu propio nombre de campo, el servidor no tiene forma de saber que es un honeypot y lo guardará como un campo más del formulario. Consulta la documentación de tu proveedor para saber el nombre exacto; es un fallo silencioso que solo se manifiesta más tarde, en forma de spam en tu bandeja de entrada.
Comprobación de tiempo: los usuarios legítimos tardan unos segundos en rellenar un formulario; los bots envían en milisegundos. Registra cuándo se cargó el formulario y marca los envíos que llegan sospechosamente rápido. Formpaste lo lee de un campo _ts con la marca de tiempo de la carga de la página:
<input type="hidden" name="_ts" id="form-ts" />
<script>
document.getElementById("form-ts").value = Date.now();
</script>
Límite de frecuencia: limita los envíos por IP y por minuto para que un bot no pueda machacar tu endpoint. Este sí necesita realmente el lado servidor.
Implementar los tres por tu cuenta implica ejecutar lógica de servidor, lo que echa por tierra el objetivo de “sin backend”. Aquí es donde una API de backend de formularios se gana su sitio: las buenas ejecutan por ti el honeypot, la comprobación de tiempo, el límite de frecuencia y los controles de correo desechable y duplicados, y luego retienen lo sospechoso en una cuarentena que puedes revisar, en lugar de descartar mensajes reales en silencio.
Errores habituales (y cómo solucionarlos)
“Mi formulario envía pero no recibo ningún correo.” Revisa la carpeta de spam, confirma que has verificado tu dirección de remitente y asegúrate de que el valor de access_key es correcto y no sigue siendo el marcador de posición.
“Recibo un 403 domain_not_allowed.” Tu formulario tiene una lista de dominios permitidos y la petición llegó desde uno que no está en ella. Añade el dominio en el panel. Ojo: esto es una respuesta de error JSON normal, no un error de CORS.
“Error de CORS en la consola.” Este es un problema distinto del anterior, y merece la pena no confundirlos. Un endpoint de formularios pensado para sitios estáticos debería enviar cabeceras CORS permisivas, así que un error de CORS real suele significar que la petición nunca llegó a la API: una URL de endpoint mal escrita, un fallo de red o DNS, o un bloqueador de anuncios. Mira en la pestaña Red si llegó a haber respuesta. Y algo más que conviene saber: CORS es una política del navegador, no una frontera de seguridad. No protege tu clave, y lo que de verdad restringe quién puede enviar es la lista de dominios permitidos.
“No pasa nada al enviar.” Si usas la versión con JS, comprueba que tu getElementById/selector apunta realmente al formulario y que has llamado a e.preventDefault().
“Funciona en local pero no en producción.” Casi siempre es la lista de dominios permitidos. Añade tu dominio de producción.
Cómo elegir servicio
Un repaso rápido y honesto:
Formspree es maduro y popular, con un ecosistema generoso. Una opción segura por defecto, aunque su plan gratuito es limitado. Web3Forms funciona de verdad sin registro y se basa en access keys, ideal para formularios rápidos de usar y tirar. Formpaste (el que he usado aquí) apuesta por la sencillez de copiar y pegar, filtrado de spam sin CAPTCHA con una cuarentena que tú controlas y, algo poco habitual, un servidor MCP para que un agente de código con IA pueda montarte el formulario. Netlify Forms es excelente, pero solo si ya alojas en Netlify.
Cualquiera de ellos funciona con todos los ejemplos de código de arriba. Cambia el endpoint y la clave, y consulta la documentación de cada proveedor para sus propios nombres de campo, porque las convenciones de honeypot y de redirección varían entre servicios.
Para terminar
Añadir un formulario de contacto a un sitio HTML estático básico ya no significa levantar un servidor. Ahora tienes un formulario completo, accesible y con mejora progresiva, una estrategia antispam sin CAPTCHA y una lista de soluciones para los tropiezos habituales.
Empieza con el formulario HTML simple, mejóralo con JavaScript cuando quieras respuesta dentro de la página, y deja que una API de backend de formularios se encargue de la entrega y del spam para que puedas volver a construir.
¿Usas un framework? El mismo endpoint y los mismos campos funcionan en todas partes. Estas páginas muestran la versión idiomática de cada uno, junto con sus tropiezos específicos: React, Next.js, Vue, Astro y Svelte.