# Añade un formulario de contacto en Netlify sin Netlify Forms

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

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

Quita `data-netlify` del formulario y apunta su `action` a tu endpoint de Ikiform; así Netlify sirve la página y nunca toca el envío. Los formularios de React y Vue no necesitan una copia oculta en HTML y pueden enviar JSON. Define otro id de formulario para las deploy previews en `netlify.toml`, porque los hosts de preview no coinciden con tu sitio en **Allowed domains**.

## Qué cambia en Netlify

Netlify es el único gran host estático con una función de formularios integrada. Netlify Forms lee tu HTML desplegado después de cada build, encuentra los formularios marcados con `data-netlify` o `netlify` y acepta envíos a tu propio sitio para esos formularios. Cualquier otro envío a una página estática falla, y en Netlify falla con `404`, no con `405`:

`Terminal`:

```bash
curl -i -X POST -d "message=hello" https://your-site.netlify.app/contact/
```

`Respuesta`:

```http
HTTP/2 404
server: Netlify
cache-status: "Netlify Edge"; fwd=method; fwd-status=404
content-length: 0
```

Ese `404` hace que la gente busque una página que falta y que no falta. Significa que ningún formulario detectado coincidió con el envío. Si usar Netlify Forms o no es otra cuestión, y la [comparativa entre Ikiform y Netlify Forms](https://ikiform.com/alternatives/netlify-forms) la responde con honestidad: si tu sitio va a vivir siempre en Netlify y el formulario es HTML plano, Netlify Forms da menos trabajo. Esta página es para los demás casos.

| Tu situación | Por qué estorba Netlify Forms |
| --- | --- |
| El formulario lo renderiza React, Vue o Svelte | El build analiza HTML estático, así que un formulario renderizado en el cliente necesita un duplicado oculto para que Netlify lo encuentre |
| Quieres enviar JSON desde `fetch` | Netlify Forms solo acepta cuerpos urlencoded y multipart |
| Puede que el sitio se mude a otro host | El formulario envía a tu propio dominio, así que solo funciona mientras Netlify sirva ese dominio |
| Los mensajes deben llegar a un webhook que verificas | Ikiform firma cada entrega con cabeceras de Standard Webhooks y la reintenta |

Diagrama: Netlify Forms e Ikiform lado a lado en el mismo sitio de Netlify

1. Con Netlify Forms, Netlify analiza el HTML de cada despliegue en busca de formularios marcados con data-netlify.
2. Un formulario que encontró acepta envíos a tu propio sitio, y Netlify los guarda.
3. Un formulario que no encontró, como uno renderizado con JavaScript sin copia oculta, recibe 404 en cada envío.
4. Con Ikiform, no hay nada que encontrar en el build.
5. El formulario envía directamente a ikiform.com, como HTML o JSON, e Ikiform lo guarda y te envía un email.

Ambos pueden convivir en un sitio. Un formulario con data-netlify va a Netlify; un formulario cuyo action es un endpoint de Ikiform va a Ikiform.

## Saca un formulario de Netlify Forms

- Quita del formulario `data-netlify` o `netlify`, `netlify-honeypot` y `data-netlify-recaptcha`, y cualquier input oculto `form-name`. Si lo dejas, `form-name` se guarda como un campo más.
- Borra la copia estática oculta de un formulario de JavaScript, como la de abajo. Netlify solo la necesitaba para detectar el formulario.
- Pon tu endpoint de Ikiform en el `action`, o en la URL del `fetch`. El antiguo `action="/thanks"` pasa al ajuste **Redirect after submit** del formulario.
- Descarga primero los envíos anteriores desde la página **Forms** de Netlify. No se mueven solos.

`index.html, borra esto`:

```html
<form name="contact" netlify hidden>
  <input name="name" />
  <input name="email" />
  <textarea name="message"></textarea>
</form>
```

Deja **form detection** activado si otros formularios del sitio siguen usando Netlify Forms. La documentación de Netlify dice que desactivar la detección es solo para sitios que no usan Netlify Forms. Cuando ya nada lo use, desactivarlo te ahorra un análisis en cada despliegue.

### Qué sustituye a los ajustes antispam de Netlify

Netlify pasa Akismet por los envíos y ofrece `netlify-honeypot` y reCAPTCHA. En Ikiform, el honeypot sigue funcionando si pones en **Honeypot field** del formulario el nombre que ya usas, como `bot-field`. La puntuación de spam se aplica a cada envío sin configurar nada, y **Spam check** en **Ikiform** sustituye reCAPTCHA por Cloudflare Turnstile sin claves. Quita el `div` de reCAPTCHA al cambiar.

## Un formulario de React que envía JSON

Sin Netlify Forms de por medio, un formulario renderizado en el cliente no necesita duplicado y puede enviar JSON, lo que conserva los tipos y te permite limpiar los valores antes. Este además registra qué contexto de Netlify generó la página:

`src/ContactForm.tsx`:

```tsx
import { type FormEvent, useState } from "react";

const ENDPOINT = `https://ikiform.com/f/${import.meta.env.VITE_IKIFORM_FORM_ID}`;

interface Message {
  name: string;
  email: string;
  message: string;
  context: string;
}

export function ContactForm() {
  const [sent, setSent] = useState(false);
  const [error, setError] = useState("");

  async function onSubmit(event: FormEvent<HTMLFormElement>) {
    event.preventDefault();
    const data = new FormData(event.currentTarget);
    const body: Message & Record<string, string> = {
      name: String(data.get("name") ?? "").trim(),
      email: String(data.get("email") ?? "").trim(),
      message: String(data.get("message") ?? "").trim(),
      context: import.meta.env.VITE_CONTEXT ?? "local",
      _gotcha: String(data.get("_gotcha") ?? ""),
    };
    setError("");
    const response = await fetch(ENDPOINT, {
      method: "POST",
      headers: { "Content-Type": "application/json", Accept: "application/json" },
      body: JSON.stringify(body),
    }).catch(() => null);
    const result = await response?.json().catch(() => null);
    if (result?.ok) return setSent(true);
    const fields: Record<string, string[]> = result?.error?.fields ?? {};
    setError(Object.values(fields)[0]?.[0] ?? result?.error?.message ?? "Couldn't send. Try again.");
  }

  if (sent) return <p role="status">Got it. I'll answer from my own inbox, usually the same day.</p>;

  return (
    <form 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 />
      {error ? <p role="alert">{error}</p> : null}
      <button>Send</button>
    </form>
  );
}
```

- `Content-Type: application/json` le dice a Ikiform que lea JSON; `Accept: application/json` pide una respuesta en JSON.
- `VITE_CONTEXT` es un nombre de ejemplo. Netlify define `CONTEXT` durante el build; exponlo a Vite en tu comando de build, o quita el campo.
- El honeypot se envía como una clave JSON. Mantenlo en el cuerpo aunque el input esté oculto, para que los bots que lo rellenan se sigan delatando.

## Las deploy previews tienen su propio formulario

Las deploy previews viven en hosts como `deploy-preview-12--your-site.netlify.app`. Eso no es un subdominio de `your-site.netlify.app`, así que no coincide en **Allowed domains**, y los envíos desde previews acaban en spam. Dale a las previews y a los branch deploys su propio formulario en `netlify.toml`, y define la cabecera Content-Security-Policy en el mismo archivo:

`netlify.toml`:

```toml
[build]
  command = "npm run build"
  publish = "dist"

[context.production.environment]
  VITE_IKIFORM_FORM_ID = "frm_xxxxxxxxxxxxxxxx"

[context.deploy-preview.environment]
  VITE_IKIFORM_FORM_ID = "frm_yyyyyyyyyyyyyyyy"

[context.branch-deploy.environment]
  VITE_IKIFORM_FORM_ID = "frm_yyyyyyyyyyyyyyyy"

[[headers]]
  for = "/*"
  [headers.values]
    Content-Security-Policy = "default-src 'self'; form-action 'self' https://ikiform.com; connect-src 'self' https://ikiform.com"
```

Deja vacío **Allowed domains** del formulario de preview, o añade el host de preview exacto mientras pruebas. El formulario de producción lleva tu dominio y `your-site.netlify.app`. Las cabeceras de `netlify.toml` se aplican a todos los contextos de despliegue; si prefieres un archivo `_headers`, tiene que acabar en el directorio de publicación.

> Nunca añadas `netlify.app` por sí solo a **Allowed domains**. Coincidiría cualquier sitio de Netlify, incluidas las copias de tu formulario en sitios de otras personas.

## Comprueba que funciona

1. Abre una deploy preview, envía y confirma que el mensaje llega al formulario de preview.
2. Haz merge y envía en producción. Confirma que llega al formulario de producción y que la página **Forms** de Netlify no muestra nada nuevo.
3. 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`.
4. 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.
5. ¿No llegó nada? Cambia el filtro de estado del panel a **Spam**. El spam recibe la misma respuesta de éxito que un envío real.

## Problemas habituales

| Problema | Qué hacer |
| --- | --- |
| `404` al enviar | El envío fue a tu propio sitio. Puede que un build en caché aún tenga el `action` antiguo, o que una llamada `fetch` siga enviando a `/`. Busca `form-name` en el directorio de publicación. |
| Los envíos siguen apareciendo en Netlify | Algún formulario de la página sigue enviando a tu propio sitio con `data-netlify` o `netlify`, así que Netlify sigue detectándolo y guardándolo. Quita el atributo, pon Ikiform en el `action` y vuelve a desplegar. |
| `404 not_found` de Ikiform solo en las previews | El contexto de deploy preview no tiene `VITE_IKIFORM_FORM_ID`, así que la URL termina en `undefined`. Añádelo en `[context.deploy-preview.environment]`. |
| `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. |
| `422 files_not_supported` | El formulario tiene un `<input type="file">` con un archivo elegido. La subida de archivos aún no está disponible; quita el input o pide un enlace. |

## Preguntas

### ¿Puedo usar otro backend de formularios mientras mi sitio sigue en Netlify?

Sí. Netlify solo gestiona los formularios que detectó en el build. Un formulario sin data-netlify cuyo action apunta a otro endpoint lo sirve Netlify como cualquier página, y envía directamente a ese endpoint.

### ¿Por qué mi formulario recibe 404 en Netlify?

Netlify responde con 404 a un POST a una página estática salvo que Netlify Forms haya detectado ese formulario en el HTML desplegado. Marca el formulario para Netlify Forms o apunta su action a otro backend.

### ¿Puedo mantener Netlify Forms para un formulario y usar Ikiform para otro?

Sí. La detección es por formulario, según el atributo. Mantén data-netlify en los formularios que deba gestionar Netlify y deja activada la detección de formularios.

### ¿Pierdo mis envíos antiguos de Netlify al cambiar?

Se quedan en Netlify, no en Ikiform. Descárgalos desde la página Forms del sitio antes de desactivar la detección de formularios, y guarda el archivo con tus registros.

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

## Más guías

- [Formulario de contacto en un sitio estático](https://ikiform.com/es/guides/contact-form-static-site.md): 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.
- [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.
- [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.
- [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.
- [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.
