Guías17 min de lectura
Cómo añadir un formulario de contacto a un sitio estático sin backend
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.
Un host estático sirve archivos y rechaza el POST de un formulario, normalmente con 405 o 404, así que el formulario tiene que enviar a otro sitio. Apunta el action del formulario a un backend de formularios como Ikiform, que guarda cada mensaje, filtra el spam y te envía un email con el remitente como dirección de respuesta. Tu sitio sigue siendo estático: sin código de servidor, sin claves secretas en la página y sin cambios en cómo despliegas.
Un sitio estático es una carpeta de HTML, CSS y JavaScript que un host entrega como archivos. Por eso es rápido, barato y difícil de romper. Y también por eso el formulario de contacto es lo primero que deja de funcionar: el envío de un formulario es una petición que algo tiene que recibir, y en un host estático no hay nada escuchando.
Por qué falla el formulario en un host estático
Un <form method="post"> sin action envía a la misma página en la que está. En un host estático eso es un archivo, y los archivos solo responden a GET y HEAD. El 2026-10-25 enviamos un POST a una página estática publicada en cada uno de los hosts principales. Ninguno guardó nada:
| Host | Qué recibe un POST a una página estática |
|---|---|
| GitHub Pages | 405 Not Allowed, como una pequeña página HTML |
| Vercel | 405 con el cuerpo vacío |
| Netlify | 404, con la página 404 del sitio si tiene una, salvo que Netlify Forms haya detectado ese formulario al desplegar |
| Cloudflare Pages | 405 con el cuerpo vacío |
| Endpoint de sitio web de Amazon S3 | 405 Method Not Allowed, con el código MethodNotAllowed |
La solución es la misma en todas partes: manda el envío a una URL que lo acepte. Esa URL puede ser código que escribes y alojas tú, una función de tu host o un backend de formularios. El resto de esta guía usa un backend de formularios, porque así el sitio sigue siendo estático.
- Sin action, o con un action que apunta a una ruta de tu propio sitio, el navegador envía al host estático.
- El host no tiene código que ejecutar para un POST, así que responde 405 o 404 y el mensaje se pierde.
- Con el action apuntando a tu endpoint de Ikiform, el envío llega a un servidor que lo acepta.
- Ikiform revisa el mensaje en busca de spam, lo guarda y te envía un email.
- Ikiform responde con una redirección 303 y el navegador carga la página de agradecimiento de tu propio sitio.
Qué pasa con cada mensaje
Ikiform es el endpoint al que envía el formulario. No aloja tu página ni renderiza tu formulario; ambos siguen siendo tuyos. Con cada envío ejecuta las comprobaciones antispam, guarda el mensaje y lo reparte a los sitios donde lo lees.
- Un visitante rellena el formulario de contacto y este se envía a tu endpoint de Ikiform.
- Ikiform comprueba el honeypot, el límite de frecuencia y la puntuación de spam, y luego guarda el mensaje.
- Cada destinatario de notificaciones recibe un email, con la dirección de respuesta configurada como la del remitente.
- El mensaje aparece en el panel, donde puedes buscarlo, recuperar spam y exportar a CSV.
- Si añades un webhook, Ikiform envía una copia firmada a Slack, Discord, una herramienta de automatización o tu propio código.
Cinco formas de recibir el formulario de un sitio estático
| Enfoque | Destaca en | Contras |
|---|---|---|
Un enlace mailto: o action="mailto:..." | Cero configuración. Sirve para una página personal con poco tráfico. | Abre la app de correo del visitante, si tiene una configurada. No se guarda ni se filtra nada, y tu dirección queda en el HTML al alcance de los scrapers. |
| La función de formularios de tu host, como Netlify Forms | Un atributo, ninguna otra cuenta y normalmente incluido en el plan de hosting. | Solo funciona en ese host. Cambiar de host implica rehacer el formulario, y los formularios renderizados con JavaScript necesitan apaños. |
| Una función serverless que escribes tú | Control total sobre la validación, el almacenamiento y el email que envías. | Ahora tienes un backend que mantener: una clave de API de email, filtrado de spam, límites de frecuencia, reintentos y logs. El formato de las funciones cambia en cada host. |
| Un formulario alojado incrustado, como Google Forms, Tally o Typeform | Sin código. Bueno para formularios largos o de varios pasos. | Un iframe o un enlace a otro sitio, con su aspecto y sus scripts en tu página. |
| Un backend de formularios, como Ikiform, Formspree, Basin o Web3Forms | Tu propio HTML en cualquier host. Almacenamiento, filtrado de spam y email sin servidor. | Otra cuenta y otra factura, y los datos del formulario viven en ese servicio. |
Si estás en Netlify y no piensas irte nunca, Netlify Forms es difícil de superar en esfuerzo. Si quieres escribir el backend, una función es un buen proyecto de fin de semana. Si quieres que el formulario siga funcionando cuando cambies de host o de framework, usa un backend de formularios. Las comparativas ponen los backends uno al lado del otro.
El formulario
Todos los ejemplos de abajo envían desde el navegador, porque un despliegue estático no tiene servidor desde el que enviar. Elige tu stack; la elección se mantiene en todos los ejemplos de código de estas guías. Los generadores de sitios estáticos tienen sus propias páginas de configuración: Jekyll, Hugo, Eleventy y Gatsby.
Esta es toda la integración para un sitio escrito a mano. El formulario envía de la forma clásica, así que funciona con JavaScript desactivado, y el navegador llega a tu thanks.html tras un envío correcto.
<form id="contact" action="https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx" method="post">
<label for="name">Name</label>
<input id="name" name="name" autocomplete="name" maxlength="120" required />
<p class="field-error" data-error-for="name" hidden></p>
<label for="email">Email</label>
<input id="email" name="email" type="email" autocomplete="email" required />
<p class="field-error" data-error-for="email" hidden></p>
<label for="message">Message</label>
<textarea id="message" name="message" rows="6" minlength="10" maxlength="5000" required></textarea>
<p class="field-error" data-error-for="message" hidden></p>
<input type="hidden" name="_redirect" value="https://example.com/thanks.html" />
<input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
<button type="submit">Send message</button>
<p id="contact-status" role="status"></p>
</form>
<p id="contact-sent" role="status" hidden>Thanks. Your message is on its way, and I'll reply by email.</p>
<script src="/contact.js" defer></script>El script es opcional. Mantiene al visitante en la página, pone cada error de campo debajo de su input y conserva el mensaje escrito cuando algo falla.
const form = document.querySelector("#contact");
const status = document.querySelector("#contact-status");
const done = document.querySelector("#contact-sent");
const button = form.querySelector("button");
function showErrors(fields = {}) {
for (const slot of form.querySelectorAll("[data-error-for]")) {
const name = slot.dataset.errorFor;
const message = fields[name]?.[0] ?? "";
slot.textContent = message;
slot.hidden = !message;
form.elements[name]?.setAttribute("aria-invalid", message ? "true" : "false");
}
}
form.addEventListener("submit", async (event) => {
event.preventDefault();
button.disabled = true;
status.textContent = "Sending";
try {
const response = await fetch(form.action, {
method: "POST",
headers: { Accept: "application/json" },
body: new FormData(form),
});
const result = await response.json();
if (result.ok) {
form.hidden = true;
done.hidden = false;
return;
}
showErrors(result.error.fields);
status.textContent = result.error.message;
} catch {
status.textContent = "Couldn't send. Check your connection and try again.";
}
button.disabled = false;
});Con output: "export", next build escribe archivos planos en out/. Las Server Actions y los Route Handlers que leen la petición no funcionan en una exportación estática, así que el formulario envía desde el navegador.
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
output: "export",
};
export default nextConfig;"use client";
import { type FormEvent, useState } from "react";
const ENDPOINT = "https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx";
type Field = "name" | "email" | "message";
type Errors = Partial<Record<Field, string>>;
export function ContactForm() {
const [sentTo, setSentTo] = useState("");
const [sending, setSending] = useState(false);
const [errors, setErrors] = useState<Errors>({});
const [notice, setNotice] = useState("");
async function onSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const body = new FormData(event.currentTarget);
setSending(true);
setNotice("");
try {
const response = await fetch(ENDPOINT, { method: "POST", headers: { Accept: "application/json" }, body });
const result = await response.json();
if (result.ok) {
setSentTo(String(body.get("email")));
return;
}
const fields = result.error.fields ?? {};
setErrors({ name: fields.name?.[0], email: fields.email?.[0], message: fields.message?.[0] });
setNotice(result.error.message);
} catch {
setNotice("Couldn't send. Check your connection and try again.");
} finally {
setSending(false);
}
}
if (sentTo) return <p role="status">Thanks. I'll reply to {sentTo}.</p>;
return (
<form action={ENDPOINT} method="post" onSubmit={onSubmit}>
<label htmlFor="name">Name</label>
<input id="name" name="name" autoComplete="name" maxLength={120} required aria-invalid={!!errors.name} />
{errors.name ? <p>{errors.name}</p> : null}
<label htmlFor="email">Email</label>
<input id="email" name="email" type="email" autoComplete="email" required aria-invalid={!!errors.email} />
{errors.email ? <p>{errors.email}</p> : null}
<label htmlFor="message">Message</label>
<textarea id="message" name="message" rows={6} minLength={10} required aria-invalid={!!errors.message} />
{errors.message ? <p>{errors.message}</p> : null}
<input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
{notice ? <p role="alert">{notice}</p> : null}
<button disabled={sending}>{sending ? "Sending" : "Send message"}</button>
</form>
);
}Renderiza <ContactForm /> desde app/contact/page.tsx. El formulario conserva su action, así que sigue enviando aunque el bundle de JavaScript no haya cargado.
Astro prerenderiza todas las páginas por defecto, que es justo lo que quiere un host estático. No recurras a Astro Actions aquí: enviar un formulario a una action necesita renderizado bajo demanda y un adaptador. Envía a Ikiform y deja que el <script> incluido mantenga a los visitantes en la página.
---
import Layout from "../layouts/Layout.astro";
const endpoint = "https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx";
---
<Layout title="Contact">
<form id="contact" action={endpoint} 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="_redirect" value={new URL("/thanks/", Astro.site).href} />
<input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
<button>Send message</button>
<output id="contact-status" role="status"></output>
</form>
</Layout>
<script>
const form = document.querySelector<HTMLFormElement>("#contact")!;
const status = document.querySelector<HTMLOutputElement>("#contact-status")!;
form.addEventListener("submit", async (event) => {
event.preventDefault();
status.value = "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: "Thanks. Your message is on its way, and I'll reply by email." }));
return;
}
const fields: Record<string, string[]> = result?.error?.fields ?? {};
const first = Object.entries(fields)[0];
status.value = first ? `${first[0]}: ${first[1][0]}` : (result?.error?.message ?? "Couldn't send. Check your connection and try again.");
});
</script>Astro.site sale de site en astro.config.mjs. Ponle tu dirección https: para que el valor de _redirect sea una URL completa que Ikiform pueda seguir.
El modo SPA genera un shell renderizado en el cliente que cualquier host estático puede servir. Las server functions siguen necesitando un servidor, así que un despliegue estático no puede ejecutarlas; la ruta envía desde el navegador y pasa a /thanks si todo va bien.
import { tanstackStart } from "@tanstack/react-start/plugin/vite";
import viteReact from "@vitejs/plugin-react";
import { defineConfig } from "vite";
export default defineConfig({
plugins: [
tanstackStart({
spa: {
enabled: true,
},
}),
viteReact(),
],
});import { createFileRoute, useNavigate } from "@tanstack/react-router";
import { type FormEvent, useState } from "react";
const ENDPOINT = "https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx";
export const Route = createFileRoute("/contact")({ component: Contact });
function Contact() {
const navigate = useNavigate();
const [problems, setProblems] = useState<string[]>([]);
const [sending, setSending] = useState(false);
async function onSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setSending(true);
try {
const response = await fetch(ENDPOINT, {
method: "POST",
headers: { Accept: "application/json" },
body: new FormData(event.currentTarget),
});
const result = await response.json();
if (result.ok) {
await navigate({ to: "/thanks" });
return;
}
const fields: Record<string, string[]> = result.error.fields ?? {};
setProblems(Object.keys(fields).length ? Object.values(fields).map((list) => list[0]) : [result.error.message]);
} catch {
setProblems(["Couldn't send. Check your connection and try again."]);
}
setSending(false);
}
return (
<form action={ENDPOINT} method="post" onSubmit={onSubmit}>
{problems.length ? (
<ul role="alert">
{problems.map((problem) => (
<li key={problem}>{problem}</li>
))}
</ul>
) : null}
<label htmlFor="name">Name</label>
<input id="name" name="name" autoComplete="name" required />
<label htmlFor="email">Email</label>
<input id="email" name="email" type="email" autoComplete="email" required />
<label htmlFor="message">Message</label>
<textarea id="message" name="message" rows={6} minLength={10} required />
<input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
<button disabled={sending}>{sending ? "Sending" : "Send message"}</button>
</form>
);
}Con ssr: false no hay build de servidor, así que una ruta no puede tener un action de servidor. En su lugar, un clientAction se ejecuta en el navegador: reenvía el envío del <Form> a Ikiform y redirige si todo va bien.
import type { Config } from "@react-router/dev/config";
export default {
ssr: false,
} satisfies Config;import { Form, redirect, useNavigation } from "react-router";
import type { Route } from "./+types/contact";
const ENDPOINT = "https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx";
export async function clientAction({ request }: Route.ClientActionArgs) {
const response = await fetch(ENDPOINT, {
method: "POST",
headers: { Accept: "application/json" },
body: await request.formData(),
});
const result = await response.json();
if (result.ok) return redirect("/thanks");
return {
fields: (result.error.fields ?? {}) as Record<string, string[]>,
message: String(result.error.message),
};
}
export default function Contact({ actionData }: Route.ComponentProps) {
const sending = useNavigation().state === "submitting";
const error = (name: string) => actionData?.fields[name]?.[0];
return (
<Form method="post">
<label htmlFor="name">Name</label>
<input id="name" name="name" autoComplete="name" required />
{error("name") ? <p>{error("name")}</p> : null}
<label htmlFor="email">Email</label>
<input id="email" name="email" type="email" autoComplete="email" required />
{error("email") ? <p>{error("email")}</p> : null}
<label htmlFor="message">Message</label>
<textarea id="message" name="message" rows={6} minLength={10} required />
{error("message") ? <p>{error("message")}</p> : null}
<input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
{actionData?.message ? <p role="alert">{actionData.message}</p> : null}
<button disabled={sending}>{sending ? "Sending" : "Send message"}</button>
</Form>
);
}Con adapter-static y prerender = true, cada página es un archivo. Las form actions de SvelteKit quedan descartadas: las páginas con actions no se pueden prerenderizar, porque un servidor tiene que atender su POST. El componente envía con fetch.
export const prerender = true;<script lang="ts">
const ENDPOINT = "https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx";
let phase = $state<"idle" | "sending" | "sent">("idle");
let errors = $state<Record<string, string[]>>({});
let notice = $state("");
async function send(event: SubmitEvent) {
event.preventDefault();
phase = "sending";
notice = "";
try {
const response = await fetch(ENDPOINT, {
method: "POST",
headers: { Accept: "application/json" },
body: new FormData(event.currentTarget as HTMLFormElement),
});
const result = await response.json();
if (result.ok) {
phase = "sent";
return;
}
errors = result.error.fields ?? {};
notice = result.error.message;
} catch {
notice = "Couldn't send. Check your connection and try again.";
}
phase = "idle";
}
</script>
{#if phase === "sent"}
<p role="status">Thanks. Your message is on its way, and I'll reply by email.</p>
{:else}
<form action={ENDPOINT} method="post" onsubmit={send}>
<label for="name">Name</label>
<input id="name" name="name" autocomplete="name" required aria-invalid={!!errors.name} />
{#if errors.name}<p>{errors.name[0]}</p>{/if}
<label for="email">Email</label>
<input id="email" name="email" type="email" autocomplete="email" required aria-invalid={!!errors.email} />
{#if errors.email}<p>{errors.email[0]}</p>{/if}
<label for="message">Message</label>
<textarea id="message" name="message" rows="6" minlength="10" required aria-invalid={!!errors.message}></textarea>
{#if errors.message}<p>{errors.message[0]}</p>{/if}
<input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
{#if notice}<p role="alert">{notice}</p>{/if}
<button disabled={phase === "sending"}>{phase === "sending" ? "Sending" : "Send message"}</button>
</form>
{/if}nuxt generate escribe las páginas prerenderizadas en .output/public. Las rutas de server/api no existen en un host estático, así que el componente llama a Ikiform directamente con $fetch y pasa a /thanks si todo va bien.
<script setup lang="ts">
const ENDPOINT = "https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx";
const form = reactive({ name: "", email: "", message: "", _gotcha: "" });
const errors = ref<Record<string, string[]>>({});
const notice = ref("");
const sending = ref(false);
async function send() {
sending.value = true;
notice.value = "";
try {
await $fetch(ENDPOINT, { method: "POST", headers: { Accept: "application/json" }, body: form });
await navigateTo("/thanks");
} catch (failure) {
const data = (failure as { data?: { error?: { message?: string; fields?: Record<string, string[]> } } }).data;
errors.value = data?.error?.fields ?? {};
notice.value = data?.error?.message ?? "Couldn't send. Check your connection and try again.";
} finally {
sending.value = false;
}
}
</script>
<template>
<form :action="ENDPOINT" method="post" @submit.prevent="send">
<label for="name">Name</label>
<input id="name" v-model="form.name" name="name" autocomplete="name" required />
<p v-if="errors.name">{{ errors.name[0] }}</p>
<label for="email">Email</label>
<input id="email" v-model="form.email" name="email" type="email" autocomplete="email" required />
<p v-if="errors.email">{{ errors.email[0] }}</p>
<label for="message">Message</label>
<textarea id="message" v-model="form.message" name="message" rows="6" minlength="10" required />
<p v-if="errors.message">{{ errors.message[0] }}</p>
<input v-model="form._gotcha" name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
<p v-if="notice" role="alert">{{ notice }}</p>
<button :disabled="sending">{{ sending ? "Sending" : "Send message" }}</button>
</form>
</template>Una single-page app de Vite en un host estático. El endpoint está en una variable VITE_ para que los builds de preview y de producción puedan apuntar a formularios distintos; acaba en el bundle de todos modos, y no pasa nada, porque no es un secreto.
VITE_IKIFORM_ENDPOINT=https://ikiform.com/f/frm_xxxxxxxxxxxxxxxximport { type FormEvent, useState } from "react";
const ENDPOINT = import.meta.env.VITE_IKIFORM_ENDPOINT;
type Status = { kind: "idle" } | { kind: "sending" } | { kind: "sent" } | { kind: "failed"; lines: string[] };
export function ContactForm() {
const [status, setStatus] = useState<Status>({ kind: "idle" });
async function onSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const body = new FormData(event.currentTarget);
setStatus({ kind: "sending" });
try {
const response = await fetch(ENDPOINT, { method: "POST", headers: { Accept: "application/json" }, body });
const result = await response.json();
if (result.ok) return setStatus({ kind: "sent" });
const fields: Record<string, string[]> = result.error.fields ?? {};
const lines = Object.entries(fields).map(([name, list]) => `${name}: ${list[0]}`);
setStatus({ kind: "failed", lines: lines.length ? lines : [result.error.message] });
} catch {
setStatus({ kind: "failed", lines: ["Couldn't send. Check your connection and try again."] });
}
}
if (status.kind === "sent") return <p role="status">Thanks. Your message is on its way, and I'll reply by email.</p>;
return (
<form action={ENDPOINT} method="post" onSubmit={onSubmit}>
<label htmlFor="name">Name</label>
<input id="name" name="name" autoComplete="name" required />
<label htmlFor="email">Email</label>
<input id="email" name="email" type="email" autoComplete="email" required />
<label htmlFor="message">Message</label>
<textarea id="message" name="message" rows={6} minLength={10} required />
<input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
{status.kind === "failed" ? (
<div role="alert">
{status.lines.map((line) => (
<p key={line}>{line}</p>
))}
</div>
) : null}
<button disabled={status.kind === "sending"}>{status.kind === "sending" ? "Sending" : "Send message"}</button>
</form>
);
}El mismo envío desde el navegador en un componente de un solo archivo de Vue, leyendo el endpoint de la misma variable VITE_. Solo se muestra el primer error, junto al botón.
<script setup lang="ts">
import { computed, ref } from "vue";
const ENDPOINT = import.meta.env.VITE_IKIFORM_ENDPOINT;
const sending = ref(false);
const sent = ref(false);
const fields = ref<Record<string, string[]>>({});
const notice = ref("");
const firstError = computed(() => Object.values(fields.value)[0]?.[0] ?? notice.value);
async function send(event: SubmitEvent) {
sending.value = true;
try {
const response = await fetch(ENDPOINT, {
method: "POST",
headers: { Accept: "application/json" },
body: new FormData(event.target as HTMLFormElement),
});
const result = await response.json();
sent.value = result.ok;
fields.value = result.ok ? {} : (result.error.fields ?? {});
notice.value = result.ok ? "" : result.error.message;
} catch {
notice.value = "Couldn't send. Check your connection and try again.";
} finally {
sending.value = false;
}
}
</script>
<template>
<p v-if="sent" role="status">Thanks. Your message is on its way, and I'll reply by email.</p>
<form v-else :action="ENDPOINT" method="post" @submit.prevent="send">
<label for="name">Name</label>
<input id="name" name="name" autocomplete="name" required :aria-invalid="!!fields.name" />
<label for="email">Email</label>
<input id="email" name="email" type="email" autocomplete="email" required :aria-invalid="!!fields.email" />
<label for="message">Message</label>
<textarea id="message" name="message" rows="6" minlength="10" required :aria-invalid="!!fields.message" />
<input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
<p v-if="firstError" role="alert">{{ firstError }}</p>
<button :disabled="sending">{{ sending ? "Sending" : "Send message" }}</button>
</form>
</template>- Sustituye
frm_xxxxxxxxxxxxxxxxpor el id de tu formulario que aparece en el panel. El id es público por diseño: está en tu HTML desde el momento en que despliegas. Nunca pongas una clave de API secreta en un sitio estático. - Mantén
emailcomo nombre del campo de email. Ikiform lo usa como dirección de respuesta de los emails de notificación y lo compara con los dominios de email desechables. - El input oculto
_gotchaes el honeypot. Las personas nunca lo ven; los bots que rellenan todos los inputs se marcan solos como spam. - Las versiones con JavaScript envían
Accept: application/json, así que Ikiform responde201si todo va bien, o422con un objetoerror.fieldsque nombra cada campo y su problema.
Si tu framework sí se ejecuta en un servidor en producción, puedes enviar desde ahí y dejar el endpoint fuera de la página. Las guías de Next.js, SvelteKit y Nuxt muestran las versiones de servidor, y Desde un servidor explica las claves secretas.
IKIFORM_SECRET_KEY en variables de entorno solo de servidor.Campos de un formulario de contacto
Nombre, email y mensaje cubren casi cualquier formulario de contacto. Merece la pena añadir un select de tema cuando distintas personas responden distintos tipos de mensaje. Prueba el formulario de abajo: ejecuta el validador de Ikiform en tu navegador y muestra lo que se guardaría, o el 422 que recibirías.
Vista previa
Ejecuta el validador de Ikiform en tu navegador. No se envía nada.
| Campo | Por qué | Ten en cuenta |
|---|---|---|
name | Cómo lo saludas en la respuesta | Un enlace en un campo de nombre es una señal fuerte de spam, así que mantén el id name |
email | A dónde va la respuesta | Obligatorio. type="email" muestra el teclado adecuado en el móvil |
topic | Manda ventas y soporte a la persona adecuada | Un select solo acepta los valores de la lista, así que es seguro filtrar por él |
message | La razón de ser del formulario | Una longitud mínima de 10 frena los mensajes de una sola palabra |
Deja fuera los números de teléfono, el tamaño de la empresa y todo lo que no vayas a usar en tu primera respuesta. Cada campo que añades te cuesta mensajes. Sin una definición de campos, Ikiform guarda cualquier input con nombre que envíe el formulario; con una, rechaza los envíos que no encajan.
422 files_not_supported.Responde desde tu bandeja de entrada
Los emails de notificación van a hasta 10 destinatarios confirmados por formulario. Cuando el formulario tiene un campo email, la dirección de respuesta del email es la del remitente, así que Reply en tu app de correo le escribe directamente. Nunca tienes que copiar una dirección desde un panel.
Las notificaciones tienen un máximo de 50 emails por formulario por hora y luego un resumen por hora, así que una oleada de spam no puede enterrar tu bandeja de entrada. Los mensajes marcados como spam no envían ningún email. _subject no cambia el asunto de los emails de notificación. Se pasa a los webhooks y a la API como meta.subject.
¿Quieres los mensajes en un chat de equipo en lugar del email, o además de él? Añade un webhook y sigue la guía de Slack, Discord o Telegram.
Qué ve el visitante después de enviar
Un envío HTML clásico termina en una redirección. Ikiform responde a un envío correcto del navegador con 303 See Other, y el navegador carga el destino con un GET, que tu host estático sirve sin problema.
- El navegador envía los campos del formulario a tu endpoint de Ikiform.
- Ikiform guarda el mensaje y responde 303 See Other con una cabecera Location.
- El navegador sigue la redirección con un GET a tu página de agradecimiento, que el host estático sirve como cualquier otro archivo.
Ikiform elige el destino en este orden:
- Redirect after submit en los ajustes del formulario. Funciona cualquier URL
http:ohttps:, porque solo tu equipo puede configurarlo. Es la opción más sencilla para un solo sitio. - Un campo
_redirecten el formulario, si es una URLhttps:en uno de los Allowed domains del formulario. Úsalo cuando un formulario sirve a varios sitios o idiomas. - Si no, la página de agradecimiento alojada de Ikiform, que enlaza de vuelta a tu sitio cuando el envío vino de una página
https:permitida.
Haz que la página de agradecimiento sea un archivo real y mantenla fuera de los resultados de búsqueda. Di cuándo vas a responder; te ahorra un segundo email de la misma persona.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="robots" content="noindex" />
<title>Message sent</title>
</head>
<body>
<h1>Thanks, your message is on its way</h1>
<p>I read every message and reply within two working days, from the address on this site.</p>
<p><a href="/">Back to the home page</a></p>
</body>
</html>Las versiones con JavaScript de arriba se saltan la redirección. Un envío con Accept: application/json recibe JSON, y la propia página muestra el mensaje de éxito.
Spam en un formulario estático
Tu endpoint está en el código fuente de tu página, y los bots leen el código fuente. Cuenta con recibir spam a los pocos días de publicar. Ikiform apila sus defensas para que puedas empezar sin configurar nada:
| Defensa | Qué frena | Configuración |
|---|---|---|
| Honeypot | Bots que rellenan todos los inputs | El input oculto _gotcha |
| Puntuación de spam | Enlaces en el nombre (+3), muchos enlaces (+2), frases de spam conocidas (+2) y direcciones desechables. Con 5, el mensaje se guarda como spam | Ninguna |
| Límites de frecuencia | Scripts que envían en bucle | Activo por defecto: Standard, 5 por minuto por visitante |
| Dominios permitidos | Copias de tu formulario que envían desde otros sitios | Añade tus dominios en los ajustes del formulario |
| Verificación humana | Bots que manejan un navegador real | Pon Spam check en Ikiform para usar Cloudflare Turnstile sin claves |
El spam recibe la misma respuesta de éxito que un mensaje real, así que un bot no aprende nada, y no envía ningún email ni webhook. Un formulario de contacto rara vez necesita más de un envío por persona en pocos minutos, así que Strict, 2 cada 10 minutos por visitante, le va bien cuando ya estás en producción. La documentación de spam explica cada capa, y la verificación humana necesita una llamada extra en las versiones con JavaScript: challenge() del SDK.
Dominios permitidos y despliegues de preview
Allowed domains acepta un dominio y sus subdominios. Los hosts estáticos te dan más dominios de los que crees: el dominio de producción, el subdominio propio del host y una dirección de preview por rama o pull request. Algunas previews son subdominios de la dirección de tu sitio en el host y ya coinciden; otras no, y necesitan su propia entrada.
| Host | Añade | Previews |
|---|---|---|
| GitHub Pages | username.github.io y tu dominio personalizado | Sin previews por defecto |
| Vercel | your-project.vercel.app y tu dominio | Las URL de rama no coinciden; añade el host exacto mientras pruebas |
| Netlify | your-site.netlify.app y tu dominio | deploy-preview-12--your-site.netlify.app no coincide |
| Cloudflare Pages | your-project.pages.dev y tu dominio | abc123.your-project.pages.dev ya coincide |
Nunca añadas un host compartido por sí solo, como github.io, vercel.app o netlify.app. Coincidiría cualquier sitio de ese host, incluidos los de otras personas.
Si tu sitio envía una Content-Security-Policy
Muchas plantillas estáticas traen una Content-Security-Policy estricta. Dos directivas deciden si el formulario funciona: form-action para el envío HTML clásico y connect-src para las versiones con fetch. Si falta cualquiera de las dos, el navegador bloquea la petición antes de que salga de la página, y lo único que verás es un mensaje en la consola.
Content-Security-Policy: default-src 'self'; form-action 'self' https://ikiform.com; connect-src 'self' https://ikiform.comMantén 'self' en form-action: algunos navegadores también comprueban con ella la redirección de vuelta a tu página de agradecimiento. Dónde va la cabecera depende del host: _headers en Netlify y Cloudflare Pages, vercel.json en Vercel, una etiqueta <meta> en GitHub Pages y una política de cabeceras de respuesta de CloudFront delante de S3. Las páginas de cada host, más abajo, muestran cada caso.
Prueba antes de desplegar
Abre la página a través de un servidor local, no haciendo doble clic en el archivo. Una página abierta como file:// envía Origin: null, que no coincide con ningún dominio permitido y no cuenta como prueba local. Cualquiera de estos sirve la carpeta actual:
npx serve .
python3 -m http.server 8000Añade localhost a Allowed domains mientras pruebas. Los envíos desde localhost cuentan como pruebas: un espacio de trabajo sin plan tiene 50 al mes, así que puedes montar todo el formulario antes de pagar.
Elige tu caso
El formulario de arriba funciona en cualquier host estático. Cada host tiene su propia forma de fallar, y sus propios dominios de preview y archivos de cabeceras. Estas páginas los cubren.
- GitHub PagesGitHub 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.
- VercelUna 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.
- NetlifyUsa 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 PagesAñ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.
- Amazon S3Los 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 portfolioUn 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.
Comprueba que funciona
Haz este recorrido una vez en tu dominio en producción y otra en una preview, si tu host las genera. Ikiform no tiene plan gratuito. Probar es gratis: un espacio de trabajo sin plan acepta 50 envíos de prueba al mes desde localhost. Para pasar a producción, elige Pro desde 6 $/mes por 500 envíos, o paga una sola vez: 10.000 envíos por 49 $ o 50.000 envíos por 149, $ con créditos que no caducan nunca. Consulta los precios.
- En Allowed domains del formulario, añade el dominio de tu sitio, más
localhostmientras desarrollas. Déjalo vacío para aceptar envíos desde cualquier sitio. - Envía desde tu servidor local y confirma que el mensaje aparece en Submissions.
- Despliega y envía desde el sitio en producción con JavaScript desactivado. Deberías llegar a tu propia página de agradecimiento.
- Vuelve a activar JavaScript y envía con el mensaje vacío. El error debería aparecer en la página, no como una página de error alojada.
- 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. - Envía con el honeypot relleno,
_gotcha=bot. Sigue recibiendo una respuesta de éxito, pero el envío se guarda como spam y no manda ningún email ni webhook. - ¿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.
Solución de problemas
| Problema | Qué hacer |
|---|---|
405 Method Not Allowed o 404 justo después de pulsar Enviar | El formulario envió a tu propio sitio. Revisa el atributo action en el HTML desplegado, no en tu código fuente; un paso de build o un componente <Form> del framework puede haberlo reemplazado. |
No pasa nada y la consola menciona Content-Security-Policy | Añade https://ikiform.com a form-action para los envíos clásicos y a connect-src para fetch. |
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. |
Las pruebas locales se rechazan con 402 plan_required | La página se abrió como una URL file://, así que el envío no tenía un origen válido y contó como real. Sirve la carpeta con un servidor local y prueba desde localhost. |
| 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. |
422 validation_failed | El envío no encaja con la definición de campos del formulario. error.fields nombra cada campo y el motivo. Haz que el name del input coincida con el id del campo. |
| El envío está en el panel pero no llegó ningún email ni webhook | Se guardó como spam. El spam no envía nada. Ábrelo con el filtro Spam para ver los motivos y márcalo como no spam para que envíe su email y sus webhooks. |
429 rate_limited | Un visitante envió más de lo que permite el límite de frecuencia del formulario. Standard permite 5 por minuto por visitante. Espera lo que indique Retry-After, o elige Relaxed o Custom para formularios que envían desde una IP compartida. |
Preguntas
Sí. La página sigue siendo estática; solo el action del formulario apunta a un servicio que acepta envíos. Un backend de formularios guarda cada mensaje, filtra el spam y te envía un email, así que nunca tienes que mantener un servidor.
Sí. Un id de formulario está pensado para ser público, igual que una dirección de email en una página de contacto. Protege el formulario con dominios permitidos, el honeypot, los límites de frecuencia y la verificación humana. Lo que nunca debe estar en un sitio estático es una clave de API secreta.
Solo funciona si el visitante tiene una app de correo configurada, no guarda nada y pone tu dirección en la página al alcance de los scrapers. Un formulario mantiene al visitante en tu sitio y te deja un registro de cada mensaje.
No. Los hosts estáticos no ejecutan PHP ni ningún otro código de servidor para tus páginas. Un script de correo en PHP necesita un servidor, que es justo lo que un sitio estático evita.
Todavía no. Un envío que contiene un archivo se rechaza con 422 files_not_supported. Pide en su lugar un enlace al archivo, como la URL de una carpeta compartida.
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.
¿Te atascaste en un paso o quieres una guía para tu caso? Escribe a hello@ikiform.com.