# Validación de formularios HTML en el navegador y en el servidor

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

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

Usa las restricciones integradas de HTML, como `required`, `type="email"`, `min`, `max` y `pattern`, para que el navegador bloquee la entrada incorrecta antes de enviar nada. Después aplica las mismas reglas en el servidor, porque los bots y los scripts se saltan el navegador por completo. Con Ikiform, define los campos del formulario y se comprueba cada envío: uno incorrecto recibe `422 validation_failed` con un mensaje por campo, que puedes mostrar con `setCustomValidity`.

El ejemplo es un formulario de reserva de un taller. Tiene el tipo de campos donde la validación se gana el sueldo: un tamaño de equipo con límites, una fecha que no puede estar en el pasado, un teléfono en un formato y un código de reserva con una forma fija.

## Qué cambia aquí

Hay dos validadores, y no ven las mismas peticiones. El navegador solo comprueba a las personas que usan tu página, con scripts o sin ellos. El servidor lo comprueba todo, incluidos los bots que envían directamente al endpoint y tu propio código llamando a `form.submit()`, que se salta las comprobaciones del navegador. El trabajo consiste en que los dos digan lo mismo.

Diagrama: Un envío que pasa por la validación del navegador y luego por la validación de campos de Ikiform

1. Una persona envía el formulario y el navegador comprueba las restricciones del HTML. Un campo no válido recibe un mensaje y no se envía nada.
2. Un envío válido llega a Ikiform, que lo comprueba con la definición de campos del formulario y guarda valores tipados.
3. Un bot o un script envía directamente al endpoint, así que no se ejecuta ninguna comprobación del navegador.
4. Ikiform lo comprueba con la misma definición y lo rechaza con 422 antes de guardar nada.

La capa del navegador es para las personas. La capa del servidor es la que se cumple.

## La capa del navegador

Formulario: Book a workshop

- **Tu nombre** (`name`, text, obligatorio)
- **Email del trabajo** (`email`, email, obligatorio)
- **Asistentes** (`team_size`, number, obligatorio)
- **Fecha preferida** (`date`, date, obligatorio)
- **Teléfono** (`phone`, phone)
- **Código de reserva** (`code`, text)

La definición que aplica Ikiform. Prueba un tamaño de equipo de 25, una fecha anterior al 2 de noviembre o un código como `ws-12`.

`workshop.html`:

```html
<form id="workshop" action="https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx" method="post">
  <label for="name">Your name</label>
  <input id="name" name="name" autocomplete="name" maxlength="120" required />

  <label for="email">Work email</label>
  <input id="email" name="email" type="email" autocomplete="email" required />

  <label for="team_size">People attending</label>
  <input id="team_size" name="team_size" type="number" min="2" max="20" step="1" required />

  <label for="date">Preferred date</label>
  <input id="date" name="date" type="date" min="2026-11-02" required />

  <label for="phone">Phone</label>
  <input id="phone" name="phone" type="tel" autocomplete="tel" placeholder="+14155550123"
    pattern="\+[1-9][0-9]{6,14}" title="International format, like +14155550123" />

  <label for="code">Booking code</label>
  <input id="code" name="code" pattern="WS-[0-9]{4}" title="Codes look like WS-1234" />

  <input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
  <button>Request the workshop</button>
</form>
```

Cada atributo corresponde a un estado de validez, que puedes leer desde `input.validity` en la consola:

| Atributo | Estado de validez | Notas |
| --- | --- | --- |
| `required` | `valueMissing` | En un grupo de radios, un radio con `required` cubre todo el grupo |
| `type="email"`, `type="url"` | `typeMismatch` | Permisivo: `a@b` pasa como email en los navegadores |
| `pattern` | `patternMismatch` | Debe coincidir con el valor completo. Se ignora en un campo opcional vacío |
| `minlength`, `maxlength` | `tooShort`, `tooLong` | Solo se comprueba después de que la persona edite el valor |
| `min`, `max` | `rangeUnderflow`, `rangeOverflow` | Números, fechas y horas |
| `step` | `stepMismatch` | `step="1"` rechaza decimales |
| Entrada que no se puede interpretar | `badInput` | Letras escritas en un campo numérico en algunos navegadores |
| `setCustomValidity()` | `customError` | Cualquier mensaje que pongas, hasta que lo vuelvas a dejar vacío |

### Patrones en dos motores

Los navegadores compilan `pattern` con el flag `v`, que es más estricto que las expresiones regulares que escribe la mayoría: un `-` literal dentro de una clase de caracteres debe escaparse, como en `[a-z\-]`. Ikiform compila el `pattern` de un campo con el flag `u` y también lo compara con el valor completo. Escribe patrones válidos en ambos, como el del código de reserva de arriba, y prueba cada uno en la vista previa del formulario antes de publicarlo.

### Dar estilo a los errores

`:invalid` coincide con un campo obligatorio vacío en cuanto carga la página, así que todos los campos se ven en rojo antes de que nadie escriba. `:user-invalid` solo coincide después de que la persona haya interactuado con el campo o intentado enviar, que es casi siempre lo que quieres:

`workshop.html`:

```html
<style>
  #workshop :user-invalid {
    border-color: #b42318;
  }
</style>
```

## La capa del servidor

Pega la definición de arriba en la pestaña **Fields** del formulario. A partir de entonces, Ikiform valida cada envío con ella, venga de donde venga, y guarda valores convertidos: `team_size` como número, `phone` sin separadores, `date` como `YYYY-MM-DD`. Un envío que incumple una regla se rechaza, no se guarda nada y la respuesta dice qué campo falló:

| Campo | Enviado | Mensaje |
| --- | --- | --- |
| `team_size` | `25` | `Must be at most 20` |
| `team_size` | `2.5` | `Enter a whole number` |
| `date` | `2026-10-30` | `Must be on or after 2026-11-02` |
| `phone` | `415 555 0123` | `Use international format, e.g. +14155550123` |
| `code` | `ws-12` | `Codes look like WS-1234` |
| `website` | Cualquier valor | `Unknown field "website"` |

La última fila importa. Con campos definidos, Ikiform rechaza cualquier campo que no esté en la definición, así que un input renombrado falla de forma visible en lugar de guardarse con el nombre equivocado. Los campos que empiezan por `_` y el honeypot no cuentan. Todos los tipos y reglas están en [Campos y validación](https://ikiform.com/es/docs/fields.md).

### Con un envío normal

No hace falta código. Un envío normal rechazado muestra la página de error alojada de Ikiform con el estado `422`, cada problema bajo la etiqueta de tu campo y un enlace a la página desde la que se hizo el envío. El botón **Atrás** del navegador suele conservar lo que escribió la persona; el enlace vuelve a cargar el formulario vacío.

### Mostrar los errores del servidor en los campos

Con un script, pasa cada mensaje del servidor a la propia interfaz de errores del navegador. `setCustomValidity` marca el campo como no válido con ese texto, y `reportValidity` lo muestra como el mensaje habitual y da el foco al primero:

`workshop.js`:

```js
const form = document.querySelector("#workshop");

for (const control of form.elements) {
  control.addEventListener("input", () => control.setCustomValidity?.(""));
}

form.addEventListener("submit", async (event) => {
  event.preventDefault();
  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.replaceWith("Thanks. We'll confirm the date by email.");
    return;
  }
  const fields = result.error.fields ?? {};
  for (const [name, messages] of Object.entries(fields)) {
    form.elements.namedItem(name)?.setCustomValidity?.(messages[0]);
  }
  if (!form.reportValidity()) return;
  alert(result.error.message);
});
```

- Borra el mensaje personalizado en `input`. Un campo con un error personalizado sigue siendo no válido hasta que lo vuelvas a dejar como cadena vacía, y mientras tanto la persona no puede enviar en absoluto.
- Las comprobaciones del navegador siguen ejecutándose antes del evento `submit`, así que el servidor solo ve envíos que las pasaron.
- Si el error no tiene `fields`, como `429 rate_limited`, `reportValidity()` devuelve `true` y el script recurre al mensaje general.

## Comprobar las reglas del servidor antes de enviar

Para detectar reglas que solo existen en el servidor, como una lista de dominios de email, antes de la petición, carga la definición pública del formulario y ejecuta `validate` del [SDK](https://ikiform.com/es/docs/sdk.md), que aplica las mismas reglas y mensajes de campo en el navegador:

`workshop.js`:

```js
import { getSchema, validate } from "@ikiform/sdk";

const form = document.querySelector("#workshop");
const result = await getSchema("frm_xxxxxxxxxxxxxxxx");
const fields = result.ok ? result.schema.fields : [];

form.addEventListener("submit", (event) => {
  const errors = validate(fields, new FormData(form));
  for (const control of form.elements) control.setCustomValidity?.(errors[control.name] ?? "");
  if (!form.reportValidity()) event.preventDefault();
});
```

> Tómalo como una comodidad. El servidor sigue siendo la fuente de verdad, y `getSchema` solo expone definiciones de campos, que de todos modos son públicas: cualquiera puede leerlas en `/f/<formId>/schema`.

## 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 el formulario vacío. El navegador lo detiene en **Tu nombre** y no aparece nada en el panel Network.
3. Quita `max` del tamaño de equipo en las herramientas de desarrollo, escribe `25` y envía. El envío normal muestra la página de error de Ikiform; el envío con script muestra **Must be at most 20** en el campo.
4. Envía con curl, saltándote el navegador: `curl -H "Accept: application/json" -H "Origin: https://example.com" -d team_size=abc https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx`, con tu propio dominio permitido como origen. La respuesta es `422` con un mensaje para `team_size` y para cada campo obligatorio que falte.
5. Abre **Submissions** en el panel. El nuevo envío está ahí con todos los campos que enviaste.

## Errores comunes

| Problema | Causa y solución |
| --- | --- |
| El formulario nunca se envía tras un error del servidor | Nunca se borró un mensaje de validez personalizado. Restablécelo con `setCustomValidity("")` en `input` |
| `minlength` no salta con valores rellenados de antemano | `tooShort` solo se aplica después de que la persona edite el campo. La regla del servidor lo detecta |
| Un `pattern` se ignora por completo | No es válido con el flag `v`, y los navegadores se saltan por completo un patrón no válido. Escapa `-` y otros caracteres especiales dentro de las clases |
| Los errores se muestran antes de que nadie escriba | El CSS usa `:invalid`. Cambia a `:user-invalid` |
| `422 validation_failed` | El 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 flujo completo de un envío está en el [resumen](https://ikiform.com/es/guides/html-form-submission.md#validation), y [Errores](https://ikiform.com/es/docs/errors.md) lista todos los códigos que puede recibir un envío.

## Preguntas

### ¿Basta la validación de formularios HTML por sí sola?

No. Solo se ejecuta en el navegador, en tu página, y los bots o scripts envían directamente al endpoint. Valida también en el servidor. Con Ikiform, define los campos del formulario y se comprueba cada envío.

### ¿Cómo muestro un mensaje de error del servidor en un input?

Llama a input.setCustomValidity(message) y luego a form.reportValidity(), que muestra el mensaje en el aviso habitual del navegador. Bórralo con setCustomValidity("") cuando la persona edite el campo.

### ¿Cuál es la diferencia entre :invalid y :user-invalid?

:invalid coincide con cualquier campo que no cumpla sus restricciones, incluidos los campos obligatorios vacíos al cargar la página. :user-invalid solo coincide después de que la persona haya interactuado con el campo o intentado enviar.

### ¿Qué devuelve Ikiform cuando un campo no pasa la validación?

422 validation_failed y no se guarda nada. Las peticiones JSON reciben error.fields, que asocia cada id de campo con sus mensajes; los envíos de formulario normales reciben una página de error alojada que los lista por etiqueta de campo.

## 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.
- [Sin recargar la página](https://ikiform.com/es/guides/html-form-submission/without-page-reload.md): 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.
- [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.
- [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.
