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 que puedes usar en HTML puro, React, Next.js, Vue, Astro o Svelte, 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 4 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: Ejemplos por framework (completos, sin plantillas a medias)
React
import { useState } from "react";
export default function ContactForm() {
const [status, setStatus] = useState("idle"); // idle | sending | success | error
async function handleSubmit(e) {
e.preventDefault();
// Guarda el nodo del formulario ahora: tras el primer await, `currentTarget` es null.
const form = e.currentTarget;
setStatus("sending");
try {
const response = await fetch("https://api.formpaste.com/submit", {
method: "POST",
body: new FormData(form),
});
setStatus(response.ok ? "success" : "error");
if (response.ok) form.reset();
} catch {
setStatus("error");
}
}
return (
<form onSubmit={handleSubmit}>
<input type="hidden" name="access_key" value="YOUR_ACCESS_KEY_HERE" />
<label htmlFor="name">Nombre</label>
<input id="name" type="text" name="name" required />
<label htmlFor="email">Correo electrónico</label>
<input id="email" type="email" name="email" required />
<label htmlFor="message">Mensaje</label>
<textarea id="message" name="message" required />
<button type="submit" disabled={status === "sending"}>
{status === "sending" ? "Enviando…" : "Enviar mensaje"}
</button>
{status === "success" && <p role="status">¡Gracias! Tu mensaje se ha enviado.</p>}
{status === "error" && <p role="status">Algo ha salido mal. Inténtalo de nuevo.</p>}
</form>
);
}
Next.js (App Router)
Con el App Router, mantén el formulario como componente de cliente para poder gestionar el estado del envío. No necesitas ningún route handler, ya que el formulario publica directamente en la API.
Conviene decirlo claro: la access key vive en el código del cliente y cualquiera puede verla en el código fuente. Es así por diseño en este tipo de endpoint. La clave identifica a qué formulario pertenece un envío; no es una credencial secreta y no sirve para leer tus envíos. Lo que te protege de que alguien la reutilice en otro sitio es la lista de dominios permitidos, que vemos más abajo.
"use client";
import { useState } from "react";
export default function ContactForm() {
const [status, setStatus] = useState("idle");
async function handleSubmit(e) {
e.preventDefault();
const form = e.currentTarget; // guárdalo antes del await
setStatus("sending");
try {
const res = await fetch("https://api.formpaste.com/submit", {
method: "POST",
body: new FormData(form),
});
setStatus(res.ok ? "success" : "error");
if (res.ok) form.reset();
} catch {
setStatus("error");
}
}
return (
<form onSubmit={handleSubmit}>
<input type="hidden" name="access_key" value="YOUR_ACCESS_KEY_HERE" />
<input type="text" name="name" placeholder="Nombre" required />
<input type="email" name="email" placeholder="Correo electrónico" required />
<textarea name="message" placeholder="Mensaje" required />
<button type="submit" disabled={status === "sending"}>
{status === "sending" ? "Enviando…" : "Enviar mensaje"}
</button>
{status === "success" && <p role="status">¡Mensaje enviado!</p>}
{status === "error" && <p role="status">Inténtalo de nuevo.</p>}
</form>
);
}
Vue 3
Este ejemplo envía JSON en lugar de FormData, solo para mostrar que el endpoint acepta ambos. Como los valores vienen del estado de v-model, la access key va en el cuerpo JSON y no hay campo oculto.
<template>
<form @submit.prevent="submitForm">
<label for="name">Nombre</label>
<input id="name" v-model="form.name" type="text" name="name" required />
<label for="email">Correo electrónico</label>
<input id="email" v-model="form.email" type="email" name="email" required />
<label for="message">Mensaje</label>
<textarea id="message" v-model="form.message" name="message" required />
<button type="submit" :disabled="status === 'sending'">
{{ status === "sending" ? "Enviando…" : "Enviar mensaje" }}
</button>
<p v-if="status === 'success'" role="status">¡Gracias! Mensaje enviado.</p>
<p v-if="status === 'error'" role="status">Algo ha salido mal.</p>
</form>
</template>
<script setup>
import { reactive, ref } from "vue";
const ACCESS_KEY = "YOUR_ACCESS_KEY_HERE";
const form = reactive({ name: "", email: "", message: "" });
const status = ref("idle");
async function submitForm() {
status.value = "sending";
try {
const res = await fetch("https://api.formpaste.com/submit", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ...form, access_key: ACCESS_KEY }),
});
status.value = res.ok ? "success" : "error";
if (res.ok) Object.assign(form, { name: "", email: "", message: "" });
} catch {
status.value = "error";
}
}
</script>
Astro
Astro no envía nada de JS por defecto, así que el formulario HTML simple funciona tal cual dentro de un archivo .astro.
---
// src/components/ContactForm.astro
const ACCESS_KEY = "YOUR_ACCESS_KEY_HERE";
---
<form action="https://api.formpaste.com/submit" method="POST" id="contact-form">
<input type="hidden" name="access_key" value={ACCESS_KEY} />
<input type="hidden" name="redirect" value="/gracias" />
<label for="name">Nombre</label>
<input id="name" type="text" name="name" required />
<label for="email">Correo electrónico</label>
<input id="email" type="email" name="email" required />
<label for="message">Mensaje</label>
<textarea id="message" name="message" required></textarea>
<button type="submit">Enviar mensaje</button>
</form>
Como aquí no hay JavaScript, el campo redirect importa: sin él, el navegador acabaría en la respuesta JSON en crudo. Si prefieres respuesta dentro de la propia página, quita el campo redirect y añade un script de cliente:
<script>
const form = document.getElementById("contact-form");
form.addEventListener("submit", async (e) => {
e.preventDefault();
const res = await fetch(form.action, {
method: "POST",
body: new FormData(form),
});
form.insertAdjacentHTML(
"beforeend",
res.ok ? "<p role='status'>¡Mensaje enviado!</p>" : "<p role='status'>Inténtalo de nuevo.</p>",
);
if (res.ok) form.reset();
});
</script>
Svelte
<script>
let status = "idle";
async function handleSubmit(event) {
const form = event.currentTarget; // guárdalo antes del await
status = "sending";
try {
const res = await fetch("https://api.formpaste.com/submit", {
method: "POST",
body: new FormData(form),
});
status = res.ok ? "success" : "error";
if (res.ok) form.reset();
} catch {
status = "error";
}
}
</script>
<form on:submit|preventDefault={handleSubmit}>
<input type="hidden" name="access_key" value="YOUR_ACCESS_KEY_HERE" />
<input type="text" name="name" placeholder="Nombre" required />
<input type="email" name="email" placeholder="Correo electrónico" required />
<textarea name="message" placeholder="Mensaje" required></textarea>
<button type="submit" disabled={status === "sending"}>
{status === "sending" ? "Enviando…" : "Enviar mensaje"}
</button>
{#if status === "success"}<p role="status">¡Mensaje enviado!</p>{/if}
{#if status === "error"}<p role="status">Inténtalo de nuevo.</p>{/if}
</form>
Paso 4: 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 apoyan todos los ejemplos 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.
“e.currentTarget es null.” El clásico tropiezo con async: currentTarget se vacía en cuanto el manejador termina, así que leerlo después de un await revienta. Guárdalo antes en una variable, como hacen los ejemplos de arriba.
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, versiones funcionales para cinco frameworks, 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.