# Enviar un formulario HTML sin recargar la página

> Envía un formulario con fetch y FormData para que el visitante se quede en la página, con errores de campo, estado de carga, reintentos y un plan B cuando los scripts fallan.

Publicado el 2026-10-25 · https://ikiform.com/es/guides/html-form-submission/without-page-reload

Escucha el evento `submit` del formulario, llama a `preventDefault()` y envía `new FormData(form)` con `fetch` y una cabecera `Accept: application/json`. Ikiform responde entonces con JSON en lugar de una redirección: `201` si todo va bien, o errores de campo que muestras junto a cada input. Mantén un `action` y un `method` reales en el formulario para que siga enviándose de la forma normal si tu script nunca se ejecuta.

El ejemplo aquí es una caja **Was this page helpful?** al final de una página de documentación. Es el caso típico para quedarse en la página: el lector está a mitad de una tarea, y mandarlo a una página de agradecimiento para registrar un solo clic sería absurdo. El mismo patrón sirve para cualquier formulario.

## Qué cambia cuando tomas el control del envío

Un envío de formulario normal te da mucho gratis: el navegador muestra un indicador de carga, hace que los dobles clics importen poco, gestiona el botón atrás y muestra la página que vuelva. En cuanto llamas a `preventDefault()`, todo eso pasa a ser cosa tuya. La llamada fetch en sí son tres líneas; el resto de esta página es el trabajo que antes hacía el navegador.

Diagrama: Qué hace un script cuando envía un formulario con fetch en lugar de dejar que el navegador navegue

1. El navegador ejecuta primero su comprobación de restricciones. El evento submit solo se dispara cuando el formulario es válido.
2. Tu listener llama a preventDefault, así que la pestaña no navega.
3. Envía los datos del formulario con fetch y pide JSON.
4. Con un 201, sustituye el formulario por un mensaje de estado y mueve el foco a él.
5. Con un 422 o un fallo de red, muestra el mensaje junto al campo, le da el foco y vuelve a habilitar el envío.

La validación del navegador sigue ejecutándose antes de tu código. Todo lo que hay después de preventDefault lo gestionas tú.

| El navegador hacía esto | Ahora tu script debe |
| --- | --- |
| Mostraba un estado de carga en la pestaña | Mostrar uno y anunciarlo a los lectores de pantalla |
| Hacía que los dobles clics fueran casi inofensivos | Ignorar un segundo envío mientras hay uno en curso |
| Mostraba la página de error | Asociar `error.fields` a cada input |
| Mostraba una página de error de red | Capturar peticiones fallidas y tiempos de espera agotados |
| Movía el foco a la nueva página | Mover el foco al resultado o al primer campo incorrecto |

## El formulario

Tres campos: un sí o no obligatorio, un comentario opcional y la dirección de la página, que rellena el script. Prueba la definición de abajo; es lo que Ikiform aplica en el servidor, así que un comentario de más de 2.000 caracteres también recibe aquí un `422`.

Formulario: Was this page helpful

- **¿Te ha sido útil esta página?** (`helpful`, radio, obligatorio)
- **¿Qué faltaba?** (`comment`, textarea)
- **Página** (`page`, url)

Pega esto en la pestaña **Fields** del formulario. Con campos definidos, Ikiform también rechaza cualquier campo que no conozca.

`feedback.html`:

```html
<form id="page-feedback" action="https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx" method="post">
  <fieldset>
    <legend>Was this page helpful?</legend>
    <label><input type="radio" name="helpful" value="yes" required /> Yes</label>
    <label><input type="radio" name="helpful" value="no" /> No</label>
  </fieldset>

  <label for="comment">What was missing?</label>
  <textarea id="comment" name="comment" rows="3" maxlength="2000" aria-describedby="comment-error"></textarea>
  <p id="comment-error" hidden></p>

  <input type="hidden" name="page" />
  <input type="hidden" name="_redirect" value="https://docs.example.com/feedback-sent" />
  <input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
  <button>Send</button>
  <p id="feedback-status" role="status" tabindex="-1"></p>
</form>

<script type="module" src="/page-feedback.js"></script>
```

El formulario está completo sin el script. Con JavaScript desactivado o bloqueado, se envía de la forma normal, Ikiform responde `303` y el lector llega a la página `_redirect`. El script solo lo mejora.

## El script

`page-feedback.js`:

```js
const form = document.querySelector("#page-feedback");
const status = form.querySelector("#feedback-status");
const button = form.querySelector("button");
const pageInput = form.elements.namedItem("page");
const idempotencyKey = crypto.randomUUID();
let sending = false;

pageInput.value = location.href;

function showFieldErrors(fields) {
  for (const input of form.querySelectorAll("[aria-invalid]")) input.removeAttribute("aria-invalid");
  for (const hint of form.querySelectorAll("[id$='-error']")) hint.hidden = true;
  let first = null;
  for (const [name, messages] of Object.entries(fields)) {
    const control = form.elements.namedItem(name);
    const hint = form.querySelector(`#${name}-error`);
    if (control instanceof HTMLElement) control.setAttribute("aria-invalid", "true");
    if (hint) {
      hint.textContent = messages[0];
      hint.hidden = false;
    }
    first ??= control instanceof RadioNodeList ? control[0] : control;
  }
  first?.focus();
}

form.addEventListener("submit", async (event) => {
  event.preventDefault();
  if (sending) return;
  const body = new FormData(form, event.submitter);
  body.set("_idempotency", idempotencyKey);
  sending = true;
  button.setAttribute("aria-disabled", "true");
  status.textContent = "Sending...";

  try {
    const response = await fetch(form.action, {
      method: "POST",
      headers: { Accept: "application/json" },
      body,
      signal: AbortSignal.timeout(15000),
    });
    const result = await response.json();

    if (result.ok) {
      form.replaceChildren(status);
      status.textContent = "Thanks, that helps.";
      status.focus();
      return;
    }
    if (result.error.code === "challenge_required") {
      form.submit();
      return;
    }
    if (result.error.fields) {
      status.textContent = "";
      showFieldErrors(result.error.fields);
    } else if (response.status === 429 && response.headers.has("Retry-After")) {
      const wait = response.headers.get("Retry-After");
      status.textContent = `Too many tries. Wait ${wait} seconds and send again.`;
    } else {
      status.textContent = result.error.message;
    }
  } catch {
    status.textContent = "Couldn't reach the server. Check your connection and send again.";
  } finally {
    sending = false;
    button.removeAttribute("aria-disabled");
  }
});
```

- `new FormData(form, event.submitter)` lee cada control con nombre, más el `name` y el `value` del botón pulsado si los tiene. Créalo antes de cambiar nada en el formulario.
- El botón recibe `aria-disabled`, no `disabled`. La variable `sending` hace el bloqueo real, y el botón sigue pudiendo recibir el foco para que los usuarios de lectores de pantalla no pierdan su posición.
- Sin cabecera `Content-Type`. Con un cuerpo `FormData`, el navegador pone `multipart/form-data` con el separador correcto.
- `Accept: application/json` hace que Ikiform responda en JSON. Una llamada `fetch` envía `Sec-Fetch-Mode: cors`, que elegiría JSON de todos modos, pero la cabecera deja clara la intención.
- Una clave de idempotencia por carga de página, enviada como campo `_idempotency`. Si el lector envía, la red pierde la respuesta y vuelve a enviar, Ikiform repite la primera respuesta en lugar de guardar un segundo envío. Un envío rechazado nunca consume la clave, así que un reenvío corregido sí entra. La cabecera `Idempotency-Key` hace lo mismo, pero una cabecera personalizada hace que el navegador envíe antes una petición de preflight. [JavaScript y fetch](https://ikiform.com/es/docs/frameworks.md#idempotency) tiene las reglas exactas.
- `AbortSignal.timeout(15000)` convierte una petición colgada en la rama catch en lugar de un **Sending...** interminable.
- La línea de estado tiene `role="status"`, así que su texto se lee sin mover el foco. Si todo va bien, el foco se mueve a ella porque los campos que la rodeaban ya no están.

## Cuando el formulario tiene verificación humana

Si el **Spam check** del formulario está en **Ikiform**, un envío con `fetch` sin token de Turnstile recibe `403 challenge_required`. El script de arriba lo gestiona llamando a `form.submit()`. Ese método se salta el listener de submit y envía de la forma normal, y para un envío normal Ikiform muestra su propia página de verificación corta y luego continúa hasta tu página `_redirect`. El lector sale de la página solo cuando la verificación está activada.

Para quedarte en la página incluso entonces, obtén primero un token con `challenge(formId)` del [SDK](https://ikiform.com/es/docs/sdk.md) y pásalo a `submit()`. La [documentación de spam](https://ikiform.com/es/docs/spam.md#human-check) muestra el flujo.

## La alternativa sin JavaScript

Antes de `fetch`, el truco era apuntar a un iframe oculto, para que la respuesta se cargue ahí y la página no se mueva:

`Truco antiguo`:

```html
<iframe name="feedback-sink" hidden></iframe>
<form action="https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx" method="post" target="feedback-sink">
  ...
</form>
```

Sigue funcionando, pero el iframe contiene una página de ikiform.com, y el navegador no deja que tu script lea un frame de otro origen. No puedes distinguir un éxito de un error de validación, así que el lector tampoco recibe ninguna respuesta. Úsalo solo donde de verdad no pueda ejecutarse un script.

> Un framework de servidor puede darte envío sin recarga sin código fetch en el cliente: las server actions de Next.js, el `use:enhance` de SvelteKit y el `<Form>` de React Router envían de la forma normal antes de que la página se hidrate y sin recarga después. El [resumen](https://ikiform.com/es/guides/html-form-submission.md#implementation) muestra cada uno.

## Comprueba que funciona

1. En **Allowed domains** del formulario, añade el dominio de tu sitio, y `localhost` mientras desarrollas. Déjalo vacío para aceptar envíos desde cualquier lugar.
2. Envía **Sí** sin comentario. El formulario se convierte en **Gracias, nos ayuda.** y el panel Network muestra `201`.
3. Pega más de 2.000 caracteres en el comentario, quita `maxlength` en las herramientas de desarrollo y envía. El mensaje aparece bajo el comentario y el foco se mueve ahí.
4. Haz doble clic en **Enviar**. El panel muestra un envío, no dos.
5. Desactiva JavaScript y envía. Llegas a la página `_redirect`.
6. Pon la limitación de red en **Offline** y envía. La línea de estado dice que no pudo contactar con el servidor, y **Enviar** vuelve a funcionar.

## Errores comunes

| Problema | Causa y solución |
| --- | --- |
| Falta un campo en el envío | Estaba `disabled` cuando se creó el `FormData`. Los controles desactivados nunca se envían. Crea los datos primero, o bloquea los inputs con `readonly` |
| `400 invalid_body` o un envío vacío | El código puso `Content-Type: multipart/form-data` a mano, sin separador. Borra la cabecera |
| La página se recarga igualmente | El listener lanzó un error antes de `preventDefault()`, o el clic llegó antes de que cargara el script. Llama a `preventDefault()` primero; un clic temprano envía de la forma normal, que sigue funcionando |
| `response.json()` lanza un error | La petición fue a un sitio que respondió con HTML, normalmente por un `action` relativo. Lee `form.action` en la consola; debe ser tu URL de Ikiform |
| `403 challenge_required` | La verificación humana del formulario está activada. Recurre a `form.submit()` como arriba, u obtén un token con `challenge()` |
| `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, y `localhost` para las pruebas locales. Los subdominios de un dominio de la lista están permitidos. |

## Preguntas

### ¿Cómo evito que un formulario recargue la página al enviarse?

Añade un listener de submit y llama a event.preventDefault() en su primera línea, y luego envía tú los datos con fetch. Escucha el evento submit del formulario, no el clic del botón, para cubrir también la tecla Enter.

### ¿Tengo que poner Content-Type al enviar FormData con fetch?

No, y no deberías. El navegador pone multipart/form-data con la cadena separadora. Una cabecera que escribas tú no tiene separador, y el servidor no puede analizar el cuerpo.

### ¿El navegador sigue validando el formulario si lo envío con JavaScript?

Sí, siempre que escuches el evento submit, que solo se dispara después de que pase la comprobación de restricciones del navegador. Llamar a form.submit() desde código se salta tanto la comprobación como el evento; form.requestSubmit() ejecuta ambos.

### ¿Cómo evito envíos duplicados?

Ignora los envíos mientras haya una petición en curso, y envía una clave de idempotencia por carga de página, como campo _idempotency o como cabecera Idempotency-Key. Si un reintento llega igualmente a Ikiform, repite la primera respuesta en lugar de guardar el envío otra vez.

## Más guías

- [Envío de formularios HTML](https://ikiform.com/es/guides/html-form-submission.md): Qué hace un navegador cuando se envía un formulario: qué campos envía, cómo los codifica, qué debería responder el servidor y cómo responde Ikiform.
- [Validación de formularios](https://ikiform.com/es/guides/html-form-submission/validation.md): Valida un formulario con required, type, pattern y min, da estilo a los errores con :user-invalid y aplica las mismas reglas en el servidor con definiciones de campos.
- [Redirección tras el envío](https://ikiform.com/es/guides/html-form-submission/redirect-after-submit.md): Manda a los visitantes a tu propia página de agradecimiento cuando se envía un formulario: la redirección 303, el campo _redirect, qué dominios se permiten y qué pasa al recargar.
- [El atributo action](https://ikiform.com/es/guides/html-form-submission/action-attribute.md): Qué hace el atributo action de un formulario, cómo se resuelven los valores relativos y vacíos, cómo formaction envía un formulario a dos sitios y por qué los hostings estáticos responden 405.
