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

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

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.

Diagrama: A dónde va el envío de un formulario de contacto, antes y después de la solución

1. Sin action, o con un action que apunta a una ruta de tu propio sitio, el navegador envía al host estático.
2. El host no tiene código que ejecutar para un POST, así que responde 405 o 404 y el mensaje se pierde.
3. Con el action apuntando a tu endpoint de Ikiform, el envío llega a un servidor que lo acepta.
4. Ikiform revisa el mensaje en busca de spam, lo guarda y te envía un email.
5. Ikiform responde con una redirección 303 y el navegador carga la página de agradecimiento de tu propio sitio.

Solo cambia el action del formulario. La página, el host y el despliegue se quedan como están.

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

Diagrama: Qué hace Ikiform con un mensaje del formulario de contacto

1. Un visitante rellena el formulario de contacto y este se envía a tu endpoint de Ikiform.
2. Ikiform comprueba el honeypot, el límite de frecuencia y la puntuación de spam, y luego guarda el mensaje.
3. Cada destinatario de notificaciones recibe un email, con la dirección de respuesta configurada como la del remitente.
4. El mensaje aparece en el panel, donde puedes buscarlo, recuperar spam y exportar a CSV.
5. Si añades un webhook, Ikiform envía una copia firmada a Slack, Discord, una herramienta de automatización o tu propio código.

El email basta para la mayoría de los formularios de contacto. Añade un webhook cuando los mensajes deban llegar a donde ya trabajas.

## 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](https://ikiform.com/alternatives/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](https://ikiform.com/alternatives/google-forms), [Tally](https://ikiform.com/alternatives/tally) o [Typeform](https://ikiform.com/alternatives/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](https://ikiform.com/alternatives/formspree), [Basin](https://ikiform.com/alternatives/basin) o [Web3Forms](https://ikiform.com/alternatives/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](https://ikiform.com/alternatives) 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](https://ikiform.com/es/docs/guides/jekyll.md), [Hugo](https://ikiform.com/es/docs/guides/hugo.md), [Eleventy](https://ikiform.com/es/docs/guides/eleventy.md) y [Gatsby](https://ikiform.com/es/docs/guides/gatsby.md).

**HTML**

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.

`contact.html`:

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

`contact.js`:

```js
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;
});
```

**Next.js**

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.

`next.config.ts`:

```ts
import type { NextConfig } from "next";

const nextConfig: NextConfig = {
  output: "export",
};

export default nextConfig;
```

`app/contact/contact-form.tsx`:

```tsx
"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**

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.

`src/pages/contact.astro`:

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

**TanStack Start**

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.

`vite.config.ts`:

```ts
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(),
  ],
});
```

`src/routes/contact.tsx`:

```tsx
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>
  );
}
```

**React Router**

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.

`react-router.config.ts`:

```ts
import type { Config } from "@react-router/dev/config";

export default {
  ssr: false,
} satisfies Config;
```

`app/routes/contact.tsx`:

```tsx
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>
  );
}
```

**SvelteKit**

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

`src/routes/+layout.ts`:

```ts
export const prerender = true;
```

`src/routes/contact/+page.svelte`:

```svelte
<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**

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

`app/pages/contact.vue`:

```vue
<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>
```

**React**

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.

`.env.production`:

```text
VITE_IKIFORM_ENDPOINT=https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx
```

`src/ContactForm.tsx`:

```tsx
import { 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>
  );
}
```

**Vue**

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.

`src/components/ContactForm.vue`:

```vue
<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_xxxxxxxxxxxxxxxx` por 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 `email` como 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 `_gotcha` es 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 responde `201` si todo va bien, o `422` con un objeto `error.fields` que 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](https://ikiform.com/es/docs/guides/nextjs.md), [SvelteKit](https://ikiform.com/es/docs/guides/sveltekit.md) y [Nuxt](https://ikiform.com/es/docs/guides/nuxt.md) muestran las versiones de servidor, y [Desde un servidor](https://ikiform.com/es/docs/guides/server.md) explica las claves secretas.

> Las versiones de servidor envían con una clave secreta, así que Ikiform se salta **Allowed domains** (dominios permitidos) y su verificación humana. Todos los envíos vienen entonces de la IP de tu servidor, y el límite Standard de 5 por minuto por visitante cuenta a todos tus visitantes juntos. Elige el [límite de frecuencia](https://ikiform.com/es/docs/rate-limits.md) Relaxed o Custom para un formulario que envías a través de un servidor, y guarda `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.

Formulario: Contact

- **Nombre** (`name`, text, obligatorio)
- **Email** (`email`, email, obligatorio)
- **Tema** (`topic`, select)
- **Mensaje** (`message`, textarea, obligatorio)

La misma definición que la [plantilla de formulario de contacto](https://ikiform.com/forms/contact-form). Pégala en la pestaña **Fields** del formulario e Ikiform la aplica en el servidor, diga lo que diga el HTML.

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

> La subida de archivos aún no está disponible. Un envío con un archivo recibe `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`.

> Ikiform no envía emails a la persona que rellena el formulario. Los emails de notificación van solo a ti y a tu equipo, así que no hay respuesta automática, email de confirmación ni doble opt-in. Envíalos desde tu herramienta de email, activada por un [webhook](https://ikiform.com/es/docs/webhooks.md).

¿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](https://ikiform.com/blog/form-submissions-to-slack.md), [Discord](https://ikiform.com/blog/form-submissions-to-discord.md) o [Telegram](https://ikiform.com/blog/form-submissions-to-telegram.md).

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

Diagrama: Las tres peticiones detrás de un envío de formulario HTML clásico

1. El navegador envía los campos del formulario a tu endpoint de Ikiform.
2. Ikiform guarda el mensaje y responde 303 See Other con una cabecera Location.
3. 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.

Sin un destino de redirección, el paso 3 carga la página de agradecimiento alojada de Ikiform en lugar de la tuya.

Ikiform elige el destino en este orden:

- **Redirect after submit** en los ajustes del formulario. Funciona cualquier URL `http:` o `https:`, porque solo tu equipo puede configurarlo. Es la opción más sencilla para un solo sitio.
- Un campo `_redirect` en el formulario, si es una URL `https:` 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.

`thanks.html`:

```html
<!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](https://ikiform.com/es/docs/spam.md) explica cada capa, y la verificación humana necesita una llamada extra en las versiones con JavaScript: `challenge()` del [SDK](https://ikiform.com/es/docs/sdk.md).

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

`Cabecera de respuesta`:

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

Manté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:

`Terminal`:

```bash
npx serve .

python3 -m http.server 8000
```

Añ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 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.
- [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.
- [Cloudflare Pages](https://ikiform.com/es/guides/contact-form-static-site/cloudflare-pages.md): 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.
- [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.

## 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](https://ikiform.com/es/pricing).

1. En **Allowed domains** del formulario, añade el dominio de tu sitio, más `localhost` mientras desarrollas. Déjalo vacío para aceptar envíos desde cualquier sitio.
2. Envía desde tu servidor local y confirma que el mensaje aparece en **Submissions**.
3. Despliega y envía desde el sitio en producción con JavaScript desactivado. Deberías llegar a tu propia página de agradecimiento.
4. 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.
5. 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`.
6. 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.
7. ¿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

### ¿Puede un sitio web estático tener un formulario de contacto que funcione?

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.

### ¿Es seguro poner el endpoint del formulario en HTML público?

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.

### ¿Por qué no usar un enlace mailto:?

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.

### ¿Puedo usar mail() de PHP en GitHub Pages o Netlify?

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.

### ¿Puede el formulario aceptar subida de archivos?

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.

### ¿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

- [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.
- [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.
- [Cloudflare Pages](https://ikiform.com/es/guides/contact-form-static-site/cloudflare-pages.md): 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.
- [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.
- [Envía un formulario a tu email](https://ikiform.com/es/guides/send-form-to-email.md): Recibe cada envío de formulario en tu bandeja sin servidor de correo: por qué fallan mailto: y mail() de PHP, y una configuración que filtra spam y te deja responder.
- [Frena el spam en formularios de contacto](https://ikiform.com/es/guides/spam-protection.md): Frena el spam en tu formulario de contacto con capas que no cuestan nada a las personas reales: honeypot, tiempo, puntuación del contenido, límites de frecuencia y verificación humana solo donde haga falta.
- [Más allá de los formularios del creador](https://ikiform.com/es/guides/website-builder-forms.md): Cuando los formularios de Webflow, Netlify, Carrd o Framer chocan con un tope o les falta algo, conserva el formulario que diseñaste y apúntalo a un backend de formularios.
