Guías

Guías17 min de lectura

Cómo funciona el envío de un formulario HTML

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.

Preet SutharFundador, Ikiform

Cuando alguien pulsa enviar, el navegador comprueba las restricciones del formulario, recoge todos los controles con nombre y habilitados, los codifica con el enctype del formulario y los envía a la URL del action con el method del formulario. El servidor responde como en cualquier carga de página, y uno que se porte bien responde a un POST correcto con una redirección 303, para que recargar no envíe el formulario dos veces. Apunta action a un backend de formularios como Ikiform y tendrás esa redirección, la validación de campos y el filtrado de spam sin servidor propio.

Los formularios HTML son más antiguos que JavaScript y siguen siendo la forma más fiable de enviar datos desde una página web. Funcionan con los scripts desactivados, en móviles lentos y en cualquier creador de sitios que te deje pegar markup. La mayoría de los fallos de formularios vienen de no saber lo que hace el navegador por su cuenta, así que esta guía recorre un envío paso a paso y luego muestra cómo encaja un backend de formularios.

Qué pasa cuando pulsas enviar

Un envío es una navegación normal con un cuerpo adjunto. El navegador hace cuatro cosas antes de que nada salga de la página, luego pasa la petición a la red y carga lo que vuelva.

Qué hace un navegador entre el clic en enviar y la siguiente páginaen el navegadorpor la redenvíobotón, teclaEnter orequestSubmit()comprobaciónderestriccionesrequired,type, pattern,min y maxconjuntode datossolocontroles connombre yhabilitadoscodificarurlencoded omultipartmensaje enel campono se envíanadaPOST alactionSec-Fetch-Mode:navigateelservidorresponde303 SeeOtherGET deLocationla página deagradecimientoválidono válidoseguirQué hace un navegador entre el clic en enviar y la siguiente páginaen el navegadorpor la redenvíobotón, tecla Enter orequestSubmit()comprobación derestriccionesrequired, type,pattern, min y maxconjunto dedatossolo controles connombre yhabilitadoscodificarurlencoded omultipartmensajeen elcampono se envíanadaPOST al actionSec-Fetch-Mode:navigateel servidorresponde303 See OtherGET deLocationla página deagradecimientoválidoseguirno válido
  1. Un botón de envío, la tecla Enter en un campo de texto o form.requestSubmit() inicia el envío y dispara un evento submit.
  2. Salvo que el formulario tenga novalidate, el navegador comprueba las restricciones de cada control. El primero no válido recibe un mensaje y no se envía nada.
  3. El navegador construye el conjunto de datos del formulario a partir de cada control que tiene nombre y no está desactivado.
  4. Codifica ese conjunto con el enctype del formulario, urlencoded salvo que indiques otra cosa.
  5. Navega a la URL del action con el method del formulario, enviando el cuerpo.
  6. El servidor responde. Un 303 le dice al navegador que cargue otra URL con GET, que es la página en la que acaba el visitante.
Todo lo que está por encima de la línea ocurre sin red. Un listener del evento submit puede detener el flujo en el primer paso y tomar el control con fetch.

Los atributos que deciden la petición

Cinco atributos de <form> deciden adónde van los datos y cómo. Cada uno se puede sobrescribir para un solo botón con el atributo form* correspondiente, que es como un formulario puede tener botones Send y Save draft que se comportan distinto.

AtributoPor defectoQué haceSobrescritura en el botón
actionLa propia URL de la páginaLa URL que recibe los datos. Los valores relativos se resuelven respecto a la página. Consulta el atributo actionformaction
methodgetget pone los campos en la query string de la URL. post los envía como cuerpo. dialog cierra un <dialog> sin enviar nadaformmethod
enctypeapplication/x-www-form-urlencodedCómo se codifica el cuerpo de un post. multipart/form-data es necesario para archivos. text/plain está pensado para depurarformenctype
novalidateDesactivadaSe salta la comprobación de restricciones del navegador al enviarformnovalidate
target_selfDónde se carga la respuesta. _blank abre una pestaña nueva, un iframe con nombre deja la página en su sitioformtarget

Dos botones, dos comportamientos. El segundo se salta la validación, y el name y el value del botón pulsado se añaden a los datos, así que el servidor sabe cuál se pulsó:

Dos botones de envío
<form action="https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx" method="post">
  <input name="email" type="email" required />
  <textarea name="details" required></textarea>
  <button name="intent" value="report">Send report</button>
  <button name="intent" value="draft" formnovalidate>Save as draft</button>
</form>

Qué controles se envían

Los navegadores solo envían lo que la especificación HTML llama el conjunto de datos del formulario. La mayoría de las sorpresas en un registro de envíos vienen de estas reglas:

  • Un control necesita un name. Un id solo no envía nada.
  • Los controles disabled se saltan. Usa readonly si quieres un valor visible, bloqueado y enviado.
  • Una casilla o un radio sin marcar no envía nada en absoluto, ni siquiera false. Una casilla marcada sin value envía on.
  • Solo se incluye el botón que envió el formulario, con su name y su value.
  • <select multiple> y los grupos de casillas que comparten nombre envían un par por cada valor seleccionado.
  • Los inputs fuera del elemento <form> pueden unirse a él con un atributo form que indique el id del formulario.

Cómo se codifica el cuerpo

Con el enctype por defecto, el cuerpo es una línea de pares name=value unidos por &, con los espacios como + y todo lo que esté fuera de un conjunto seguro codificado con porcentajes. Esta es la petición real del formulario de incidencias de más abajo, tal como la envía el navegador:

application/x-www-form-urlencoded
POST /f/frm_xxxxxxxxxxxxxxxx HTTP/1.1
Host: ikiform.com
Content-Type: application/x-www-form-urlencoded
Origin: https://example.com
Sec-Fetch-Mode: navigate

email=ada%40example.com&page=https%3A%2F%2Fexample.com%2Fpricing&severity=broken&details=The+annual+toggle+does+nothing&_redirect=https%3A%2F%2Fexample.com%2Freport-sent&_gotcha=

multipart/form-data envuelve cada campo en su propia parte con una cadena separadora que elige el navegador. Es más grande, pero puede llevar archivos. Ikiform lee los dos. text/plain no tiene una forma fiable de saber dónde termina un valor, así que Ikiform lo rechaza con 415 unsupported_media_type.

multipart/form-data
POST /f/frm_xxxxxxxxxxxxxxxx HTTP/1.1
Host: ikiform.com
Content-Type: multipart/form-data; boundary=----formdata7f3a

------formdata7f3a
Content-Disposition: form-data; name="email"

ada@example.com
------formdata7f3a
Content-Disposition: form-data; name="severity"

broken
------formdata7f3a--
Sea cual sea la codificación, mantén la página en UTF-8. Ikiform decodifica cada cuerpo como UTF-8 y rechaza otros charsets con 415 unsupported_charset. La subida de archivos todavía no está disponible. Un envío con un archivo recibe 422 files_not_supported.

Cuatro formas de enviar un formulario

Un envío normal es solo una opción. Cada enfoque de abajo es una elección razonable en algún caso; lo que cambia es quién gestiona la respuesta y qué pasa con los scripts desactivados.

EnfoquePunto fuerteInconvenientes
method="post" normal con una redirecciónFunciona sin JavaScript, en cualquier creador. El navegador gestiona el estado de carga, el botón atrás y la recargaUna carga de página completa. Los errores de campo necesitan una página renderizada en el servidor, o la página de error del backend
fetch con FormDataSin recarga. Tú decides cómo se ven el éxito y cada error de campoEl estado de carga, los errores de red y el foco son cosa tuya. Si no tienes cuidado, se rompe con los scripts desactivados. Consulta sin recargar la página
Form actions de framework, como las server actions de Next.js o las form actions de SvelteKitTu servidor ve el envío primero, y el mismo formulario funciona antes y después de la hidrataciónNecesitas un runtime de servidor, y la petición al backend sale de tu servidor, lo que cambia los límites de frecuencia y las comprobaciones de origen
method="get"Formularios de búsqueda y filtro. El resultado tiene una URL que se puede compartirLos campos acaban en la URL, el historial y los logs del servidor. Nunca para datos personales ni para nada que cambie el estado

Empieza con el envío normal y añade fetch encima. Así el formulario sigue funcionando cuando un script no carga, algo que pasa más a menudo de lo que muestra la mayoría de analíticas.

Qué debería responder el servidor

El navegador trata la respuesta a un POST como una página nueva. Si esa página es la propia respuesta, con estado 200, el navegador recuerda que vino de un POST. Pulsa recargar y ofrecerá enviar el formulario otra vez; pulsa atrás y adelante y algunos navegadores hacen lo mismo. Así es como la gente paga dos veces y soporte recibe el mismo mensaje tres veces.

La solución es el patrón Post/Redirect/Get. El servidor gestiona el POST y luego responde 303 See Other con una cabecera Location. El navegador carga esa URL con un GET, y la página que ve el visitante vino de un GET, así que recargar es inofensivo.

Responder a un POST con una página comparado con responder con una redirección 303responder con una página (200)responder con una redirección (303)POST/reportHTML deagradecimientocon 200la página vinode un POSTrecargar"Confirmarreenvío delformulario"POST otravezun duplicadoPOST/report303 SeeOtherLocation:/report-sentGET/report-sentrecargarrecarga solo elGETResponder a un POST con una página comparado con responder con una redirección 303responder con una página (200)responder con una redirección (303)POST /reportHTML de agradecimientocon 200la página vino de un POSTrecargar"Confirmar reenvío delformulario"POST otra vezun duplicadoPOST /report303 See OtherLocation: /report-sentGET /report-sentrecargarrecarga solo el GET
  1. Con una respuesta 200, el navegador envía el formulario y muestra el cuerpo de la respuesta como página.
  2. Esa página está ligada al POST, así que una recarga pide reenviar y una confirmación vuelve a enviar los datos.
  3. Con una respuesta 303, el navegador envía el formulario y se le dice que cargue otra URL.
  4. Carga esa URL con GET. Una recarga solo repite el GET, así que nada se envía dos veces.
Ikiform siempre responde a un envío del navegador con un 303, así que tu formulario tiene este comportamiento gratis.
EstadoQué hace después el navegadorÚsalo para
303 See OtherGET de Location, se descarta el cuerpoTras cualquier envío de formulario correcto
302 FoundEn la práctica los navegadores cambian a GET, pero la especificación permite mantener el métodoServidores antiguos. Mejor 303
307 y 308Repite el mismo POST con el mismo cuerpo en la nueva URLMover un endpoint, nunca una página de agradecimiento
200 con HTMLMuestra la página; recargar ofrece volver a enviarVolver a mostrar el formulario con errores
4xx con HTMLMuestra la página de errorErrores que el visitante puede arreglar volviendo atrás
Post/Redirect/Get
HTTP/1.1 303 See Other
Location: https://example.com/report-sent
X-Request-Id: req_01k7v2c9t3e8m5q4x6z0a1b2c3

GET /report-sent HTTP/1.1
Host: example.com

Cómo responde Ikiform

Un único endpoint, https://ikiform.com/f/<formId>, responde tanto a navegadores como a código. Decide en cada petición si responder con páginas o con JSON, a partir de cabeceras que el navegador pone por su cuenta:

PeticiónRespuesta
Accept incluye application/jsonJSON
El cuerpo es JSONJSON
Sec-Fetch-Mode: navigate, que es todo envío de formulario normalRedirección o página alojada
Cualquier otro Sec-Fetch-Mode, como cors desde fetchJSON
Sin Sec-Fetch-Mode, y Accept incluye text/htmlRedirección o página alojada
Cualquier otra cosa, como curlJSON

Sec-Fetch-Mode es una cabecera que pone el navegador y que los scripts de la página no pueden cambiar. Un envío de formulario que navega la pestaña envía navigate; una llamada fetch envía cors en una petición a otro sitio. Así que el mismo formulario recibe una redirección cuando envía de la forma normal y JSON cuando lo envía tu script, sin ningún interruptor por tu parte. La tabla completa está en JSON o una redirección.

El mismo endpoint de Ikiform respondiendo a un envío de formulario normal y a una llamada fetchenvío de formulario normalfetch, o Accept: application/jsonel navegadornavegaSec-Fetch-Mode:navigateIkiformmodo página303 a tu páginao la página deagradecimiento alojadapágina de erroralojadaproblemas por etiquetade campo, estado realel script envíaSec-Fetch-Mode: corsIkiformmodo JSON201 con el id{ ok: true, id }error JSONerror.fields por id decamporechazadorechazadoEl mismo endpoint de Ikiform respondiendo a un envío de formulario normal y a una llamada fetchenvío de formulario normalfetch, o Accept: application/jsonel navegadornavegaSec-Fetch-Mode:navigateIkiformmodo página303 a tu páginao la página deagradecimientoalojadapáginade erroralojadaproblemasporetiqueta decampo,estado realel script envíaSec-Fetch-Mode:corsIkiformmodo JSON201 con el id{ ok: true, id }errorJSONerror.fieldspor id decamporechazadorechazado
  1. Un envío de formulario normal navega la pestaña, así que el navegador envía Sec-Fetch-Mode: navigate.
  2. Ikiform responde en modo página: una redirección 303 si todo va bien, o una página de error alojada con el estado real del error.
  3. Una llamada fetch envía Sec-Fetch-Mode: cors, o tu código pide JSON con la cabecera Accept.
  4. Ikiform responde en modo JSON: 201 con el id del envío, o el envoltorio de error con los errores de campo indexados por id de campo.
No configuras el modo. Se deduce de cómo se hizo la petición.

Adónde va un envío correcto

Un envío correcto desde el navegador recibe un 303. Ikiform elige el destino en este orden:

  1. El ajuste Redirect after submit del formulario en el panel. Funciona cualquier URL http: o https:, porque solo tu espacio de trabajo puede configurarlo.
  2. Un campo _redirect en el envío, o _next si no hay _redirect, cuando es una URL https: sin usuario, contraseña ni puerto personalizado, en uno de los Allowed domains del formulario o en un subdominio de uno.
  3. Si no, la página de agradecimiento alojada en /f/<formId>/thanks. Cuando el envío vino de una página https: en un dominio permitido, enlaza de vuelta allí.

Un _redirect que incumple una regla se ignora, nunca da error, y un formulario sin dominios permitidos ignora _redirect por completo. Así nadie puede usar tu formulario para mandar visitantes al sitio que elija. Redirección tras el envío trata las páginas de agradecimiento a fondo, y las reglas completas están en Formularios HTML.

Cuando se rechaza un envío

En modo página, un envío rechazado muestra una página de error alojada con el estado HTTP real del error. Para un error de validación, lista cada problema bajo la etiqueta de tu campo y enlaza a la página desde la que se hizo el envío. El botón Atrás del navegador suele volver al formulario con lo que escribió el visitante todavía ahí; el enlace carga la página de nuevo. En modo JSON, el mismo error vuelve como un único envoltorio. Todos los códigos están en Errores.

422 en modo JSON
{
  "ok": false,
  "error": {
    "code": "validation_failed",
    "message": "Some fields are invalid.",
    "fields": {
      "page": ["Enter a valid URL"],
      "details": ["Must be at least 20 characters"]
    }
  },
  "request_id": "req_01k7v2c9t3e8m5q4x6z0a1b2c3"
}

El spam es lo único que nunca parece un error. Un envío que puntúa como spam recibe el mismo 303 o 201 que uno real, así que un bot no aprende nada, y no se envía ningún email ni webhook.

El formulario en tu stack

Aquí tienes un formulario real, un aviso de página rota, en cada stack. Elige el tuyo; la elección se mantiene en todos los ejemplos de código de estas guías. La definición de campos de abajo es la misma que Ikiform aplica en el servidor, así que pruébala: deja los detalles cortos o escribe example como dirección y obtendrás el 422 que recibiría tu formulario.

Vista previa

Ejecuta el validador de Ikiform en tu navegador. No se envía nada.

Pega esta definición en la pestaña Fields del formulario en el panel e Ikiform valida cada envío con ella. Consulta Campos y validación.

Sin ningún script. El navegador comprueba required, type y minlength, envía los campos como texto urlencoded, sigue el 303 de Ikiform hasta tu página _redirect y muestra la página de error de Ikiform si el servidor rechaza el envío.

report.html
<form action="https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx" method="post">
  <label for="email">Your email</label>
  <input id="email" name="email" type="email" autocomplete="email" required />

  <label for="page">Page address</label>
  <input id="page" name="page" type="url" required placeholder="https://example.com/pricing" />

  <label for="severity">How bad is it</label>
  <select id="severity" name="severity">
    <option value="cosmetic">Looks wrong</option>
    <option value="broken">Something doesn't work</option>
    <option value="blocking">I can't continue</option>
  </select>

  <label for="details">What happened</label>
  <textarea id="details" name="details" rows="5" minlength="20" maxlength="4000" required></textarea>

  <input type="hidden" name="_redirect" value="https://example.com/report-sent" />
  <input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
  <button>Send report</button>
</form>
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 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.

Cada versión de servidor reenvía la cabecera Origin del navegador cuando la hay, porque un fetch desde el servidor no envía ninguna e Ikiform la usa para reconocer las pruebas gratuitas desde localhost en un espacio de trabajo sin plan.

  • Todas las versiones mantienen un method real y, donde envían directamente a Ikiform, un action real. Con los scripts desactivados, el formulario sigue enviándose y recibe el 303.
  • El name de cada input es el id del campo en la definición. Si no coinciden, como url en lugar de page, se recibe 422 con Unknown field "url".
  • El input oculto _gotcha es el honeypot. Los bots que lo rellenan se guardan como spam, sin avisar.
  • Las versiones con JavaScript muestran error.fields bajo cada input, usando el primer mensaje de cada campo. La versión en HTML puro deja eso a la página de error de Ikiform.

La documentación profundiza por framework: Next.js, Astro, SvelteKit, Nuxt, React, Vue y desde un servidor.

Dos capas de validación

La validación del navegador y la del servidor hacen trabajos distintos. La comprobación de restricciones del navegador (required, type="email", minlength, pattern) da respuesta inmediata, pero cualquiera puede saltársela: un bot envía directamente, y un script puede llamar a form.submit(), que nunca ejecuta la comprobación. El servidor es el único sitio donde una regla se cumple de verdad.

ReglaEn el navegadorEn la definición de campos de Ikiform
Obligatoriorequired"required": true
Emailtype="email""type": "email", con allowed_domains opcional
URLtype="url""type": "url", con https_only opcional
Longitudminlength, maxlengthvalidation.min_length, max_length
Solo las opciones de la listaUn <select>, que un bot ignoraoptions; cualquier otra cosa se rechaza
Sin campos extraNo es posibleSe rechaza cualquier campo que no esté en la definición

Sin una definición, Ikiform acepta cualquier campo y lo guarda tal como llega. Publica una y valida, guarda números y casillas como números y booleanos reales, y rechaza el envío con 422 validation_failed antes de guardar nada. Validación muestra cómo hacer que las dos capas digan lo mismo.

Enviar con JavaScript

Para mantener al visitante en la página, escucha submit, llama a preventDefault() y envía new FormData(form) con fetch. Unos pocos detalles deciden si funciona a la primera:

  • No pongas tú el Content-Type cuando el cuerpo es FormData. El navegador pone multipart/form-data con el separador; una cabecera escrita a mano no tiene separador y el cuerpo no se puede analizar.
  • Envía Accept: application/json. Ikiform elegiría JSON por Sec-Fetch-Mode de todos modos, pero la cabecera lo hace explícito, y es una de las cabeceras que los navegadores pueden enviar a otro sitio sin preflight.
  • Un cuerpo FormData o URLSearchParams es una petición simple entre sitios, así que no hay preflight. Un cuerpo JSON con Content-Type: application/json provoca primero un preflight; Ikiform lo responde, pero cuesta un viaje de ida y vuelta.
  • form.submit() se salta la validación y el evento submit. form.requestSubmit() ejecuta ambos, como haría un clic.
  • Pasa el botón pulsado como segundo argumento, new FormData(form, event.submitter), si su name y su value importan.

El patrón completo, con estado de carga, foco y errores de red, está en sin recargar la página. La documentación de JavaScript y fetch lista todas las cabeceras y formatos de cuerpo que acepta el endpoint.

Elige tu caso

Cada una de estas páginas toma una parte de un envío y va más allá del resumen de arriba, con código para ese caso.

Comprueba que funciona

Haz esto una vez por formulario, en una ventana privada. 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 publicar, elige Pro desde 6 $/mes para 500 envíos, o paga una vez: 10.000 envíos por 49 $ o 50.000 envíos por 149 $, con créditos que nunca caducan. Consulta los precios.

  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. Desactiva JavaScript en las herramientas de desarrollo del navegador y envía el formulario. Debería llegar igualmente a tu página de agradecimiento con un 303.
  3. Con JavaScript activado, abre el panel Network y envía otra vez. La petición a /f/<formId> debería responder 201 con JSON, y la página no debería recargarse.
  4. Envía con un mensaje demasiado corto. El envío normal muestra la página de error de Ikiform con la etiqueta de tu campo; el envío con script muestra el mensaje bajo el campo.
  5. En la página de agradecimiento, pulsa recargar. No debería reenviarse nada ni aparecer un segundo envío.
  6. Abre Submissions en el panel. El nuevo envío está ahí con todos los campos que enviaste.
  7. Envía con el honeypot relleno, _gotcha=bot. Igualmente recibe una respuesta de éxito, pero el envío se guarda como spam y no envía email ni webhook.

Solución de problemas

ProblemaQué hacer
405 Method Not Allowed tras enviarEl formulario envió a tu propio hosting estático, que no acepta POST. Revisa action: debe ser la URL completa https://ikiform.com/f/<formId>, no una ruta relativa ni estar vacío.
Abrir el endpoint en una pestaña muestra 404 not_foundEs lo esperado. Una pestaña envía GET, y el endpoint solo acepta POST. Comprueba que el method del formulario sea post; un formulario sin method envía GET con los campos en la URL.
415 unsupported_media_typeEl formulario tiene enctype="text/plain", o un script envió un tipo que Ikiform no lee. Quita el enctype o usa multipart/form-data.
422 validation_failedEl envío no coincide con la definición de campos del formulario. error.fields indica cada campo y el motivo. Haz que el name del input coincida con el id del campo.
El navegador acaba en la página de agradecimiento de Ikiform en lugar de 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.
Falta un campo en el envíoNo tiene name, está disabled o es una casilla sin marcar. Dale un name, usa readonly en lugar de disabled o define un campo checkbox para que una casilla sin marcar se guarde como false.
Al recargar, pide reenviar el formularioLa página respondió al POST con 200. Los envíos directos a Ikiform siempre reciben un 303; si tu propio servidor está en medio, redirige después de gestionar el envío.
403 origin_not_allowed, o todos los envíos acaban en spamEl 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

¿Te atascaste en un paso o quieres una guía para tu caso? Escribe a hello@ikiform.com.