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

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

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.

Diagrama: Qué hace un navegador entre el clic en enviar y la siguiente página

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.

| Atributo | Por defecto | Qué hace | Sobrescritura en el botón |
| --- | --- | --- | --- |
| `action` | La propia URL de la página | La URL que recibe los datos. Los valores relativos se resuelven respecto a la página. Consulta [el atributo action](https://ikiform.com/es/guides/html-form-submission/action-attribute.md) | `formaction` |
| `method` | `get` | `get` pone los campos en la query string de la URL. `post` los envía como cuerpo. `dialog` cierra un `<dialog>` sin enviar nada | `formmethod` |
| `enctype` | `application/x-www-form-urlencoded` | Cómo se codifica el cuerpo de un `post`. `multipart/form-data` es necesario para archivos. `text/plain` está pensado para depurar | `formenctype` |
| `novalidate` | Desactivada | Se salta la comprobación de restricciones del navegador al enviar | `formnovalidate` |
| `target` | `_self` | Dónde se carga la respuesta. `_blank` abre una pestaña nueva, un iframe con nombre deja la página en su sitio | `formtarget` |

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

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

```http
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`:

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

| Enfoque | Punto fuerte | Inconvenientes |
| --- | --- | --- |
| `method="post"` normal con una redirección | Funciona sin JavaScript, en cualquier creador. El navegador gestiona el estado de carga, el botón atrás y la recarga | Una 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 `FormData` | Sin recarga. Tú decides cómo se ven el éxito y cada error de campo | El 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](https://ikiform.com/es/guides/html-form-submission/without-page-reload.md) |
| Form actions de framework, como las server actions de Next.js o las form actions de SvelteKit | Tu servidor ve el envío primero, y el mismo formulario funciona antes y después de la hidratación | Necesitas 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 compartir | Los 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.

Diagrama: Responder a un POST con una página comparado con responder con una redirección 303

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.

| Estado | Qué hace después el navegador | Úsalo para |
| --- | --- | --- |
| `303 See Other` | GET de `Location`, se descarta el cuerpo | Tras cualquier envío de formulario correcto |
| `302 Found` | En la práctica los navegadores cambian a GET, pero la especificación permite mantener el método | Servidores antiguos. Mejor `303` |
| `307` y `308` | Repite el mismo POST con el mismo cuerpo en la nueva URL | Mover un endpoint, nunca una página de agradecimiento |
| `200` con HTML | Muestra la página; recargar ofrece volver a enviar | Volver a mostrar el formulario con errores |
| `4xx` con HTML | Muestra la página de error | Errores que el visitante puede arreglar volviendo atrás |

`Post/Redirect/Get`:

```http
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ón | Respuesta |
| --- | --- |
| `Accept` incluye `application/json` | JSON |
| El cuerpo es JSON | JSON |
| `Sec-Fetch-Mode: navigate`, que es todo envío de formulario normal | Redirección o página alojada |
| Cualquier otro `Sec-Fetch-Mode`, como `cors` desde `fetch` | JSON |
| Sin `Sec-Fetch-Mode`, y `Accept` incluye `text/html` | Redirección o página alojada |
| Cualquier otra cosa, como curl | JSON |

`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](https://ikiform.com/es/docs/how-it-works.md#response-modes).

Diagrama: El mismo endpoint de Ikiform respondiendo a un envío de formulario normal y a una llamada fetch

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](https://ikiform.com/es/guides/html-form-submission/redirect-after-submit.md) trata las páginas de agradecimiento a fondo, y las reglas completas están en [Formularios HTML](https://ikiform.com/es/docs/html.md#redirect-rules).

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

`422 en modo JSON`:

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

Formulario: Report a broken page

- **Tu email** (`email`, email, obligatorio)
- **Dirección de la página** (`page`, url, obligatorio)
- **Gravedad** (`severity`, select, obligatorio)
- **Qué pasó** (`details`, textarea, obligatorio)

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](https://ikiform.com/es/docs/fields.md).

**HTML**

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

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

**Next.js**

Una server action recibe el `FormData`, lo reenvía como JSON con tu clave secreta y devuelve los errores de campo de Ikiform. `useActionState` pone cada mensaje bajo su input y conserva lo que escribió el visitante.

`app/report/actions.ts`:

```ts
"use server";

import { headers } from "next/headers";

export interface ReportState {
  sent: boolean;
  fields: Record<string, string[]>;
  message: string;
  values: Record<string, string>;
}

const NAMES = ["email", "page", "severity", "details"] as const;

export async function sendReport(_previous: ReportState, formData: FormData): Promise<ReportState> {
  const values = Object.fromEntries(NAMES.map((name) => [name, String(formData.get(name) ?? "").trim()]));
  const origin = (await headers()).get("origin");

  const response = await fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", {
    method: "POST",
    headers: {
      Accept: "application/json",
      "Content-Type": "application/json",
      Authorization: `Bearer ${process.env.IKIFORM_SECRET_KEY}`,
      ...(origin ? { Origin: origin } : {}),
    },
    body: JSON.stringify({ ...values, _gotcha: String(formData.get("_gotcha") ?? "") }),
  });
  const result = await response.json().catch(() => null);
  if (result?.ok) return { sent: true, fields: {}, message: "Thanks. We'll look at that page today.", values: {} };
  return {
    sent: false,
    fields: result?.error?.fields ?? {},
    message: result?.error?.message ?? "The report didn't go through. Try again.",
    values,
  };
}
```

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

```tsx
"use client";

import { useActionState } from "react";
import { type ReportState, sendReport } from "./actions";

const initial: ReportState = { sent: false, fields: {}, message: "", values: {} };

function FieldError({ id, messages }: { id: string; messages?: string[] }) {
  return messages?.length ? <p id={`${id}-error`}>{messages[0]}</p> : null;
}

export function ReportForm() {
  const [state, action, pending] = useActionState(sendReport, initial);

  if (state.sent) return <p role="status">{state.message}</p>;

  return (
    <form action={action}>
      <label htmlFor="email">Your email</label>
      <input id="email" name="email" type="email" required defaultValue={state.values.email}
        aria-invalid={Boolean(state.fields.email)} aria-describedby="email-error" />
      <FieldError id="email" messages={state.fields.email} />

      <label htmlFor="page">Page address</label>
      <input id="page" name="page" type="url" required defaultValue={state.values.page}
        aria-invalid={Boolean(state.fields.page)} aria-describedby="page-error" />
      <FieldError id="page" messages={state.fields.page} />

      <label htmlFor="severity">How bad is it</label>
      <select id="severity" name="severity" defaultValue={state.values.severity ?? "broken"}>
        <option value="cosmetic">Looks wrong</option>
        <option value="broken">Something doesn't work</option>
        <option value="blocking">I can't continue</option>
      </select>

      <label htmlFor="details">What happened</label>
      <textarea id="details" name="details" rows={5} required minLength={20} defaultValue={state.values.details}
        aria-invalid={Boolean(state.fields.details)} aria-describedby="details-error" />
      <FieldError id="details" messages={state.fields.details} />

      <input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
      {state.message ? <p role="alert">{state.message}</p> : null}
      <button disabled={pending}>{pending ? "Sending" : "Send report"}</button>
    </form>
  );
}
```

**Astro**

Con `accept: "form"`, Zod rechaza un email o una URL incorrectos antes de que nada salga de tu servidor, e `isInputError` expone esos mensajes por campo. Los errores de campo `422` de Ikiform vuelven como datos de la action con la misma forma.

`src/actions/index.ts`:

```ts
import { defineAction } from "astro:actions";
import { getSecret } from "astro:env/server";
import { z } from "astro/zod";

export const server = {
  reportPage: defineAction({
    accept: "form",
    input: z.object({
      email: z.email(),
      page: z.url(),
      severity: z.enum(["cosmetic", "broken", "blocking"]),
      details: z.string().min(20, "Tell us a little more, at least 20 characters."),
      _gotcha: z.string().nullish(),
    }),
    handler: async (input, context) => {
      const origin = context.request.headers.get("origin");
      const response = await fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", {
        method: "POST",
        headers: {
          Accept: "application/json",
          "Content-Type": "application/json",
          Authorization: `Bearer ${getSecret("IKIFORM_SECRET_KEY")}`,
          ...(origin ? { Origin: origin } : {}),
        },
        body: JSON.stringify(input),
      });
      const result = await response.json();
      if (result.ok) return { sent: true, fields: {} as Record<string, string[]> };
      return { sent: false, fields: (result.error.fields ?? {}) as Record<string, string[]> };
    },
  }),
};
```

`src/pages/report.astro`:

```astro
---
import { actions, isInputError } from "astro:actions";

export const prerender = false;

const result = Astro.getActionResult(actions.reportPage);
const fields = result?.error && isInputError(result.error) ? result.error.fields : (result?.data?.fields ?? {});
---

{result?.data?.sent ? (
  <p role="status">Thanks. We'll look at that page today.</p>
) : (
  <form method="POST" action={actions.reportPage}>
    <label for="email">Your email</label>
    <input id="email" name="email" type="email" required />
    {fields.email && <p>{fields.email[0]}</p>}

    <label for="page">Page address</label>
    <input id="page" name="page" type="url" required />
    {fields.page && <p>{fields.page[0]}</p>}

    <label for="severity">How bad is it</label>
    <select id="severity" name="severity">
      <option value="cosmetic">Looks wrong</option>
      <option value="broken" selected>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" required></textarea>
    {fields.details && <p>{fields.details[0]}</p>}

    <input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
    <button>Send report</button>
  </form>
)}
```

**TanStack Start**

Una server function POST lee el `FormData` en `.validator()` y devuelve los errores de campo como datos normales, así que el componente puede mostrarlos sin capturar nada.

`src/routes/report.tsx`:

```tsx
import { createFileRoute } from "@tanstack/react-router";
import { createServerFn, useServerFn } from "@tanstack/react-start";
import { getRequestHeader } from "@tanstack/react-start/server";
import { type FormEvent, useState } from "react";

type Outcome = { sent: boolean; fields: Record<string, string[]>; message: string };

const sendReport = createServerFn({ method: "POST" })
  .validator((data: unknown) => {
    if (!(data instanceof FormData)) throw new Error("Expected form data");
    const read = (name: string) => String(data.get(name) ?? "").trim();
    return {
      email: read("email"),
      page: read("page"),
      severity: read("severity"),
      details: read("details"),
      _gotcha: read("_gotcha"),
    };
  })
  .handler(async ({ data }): Promise<Outcome> => {
    const origin = getRequestHeader("origin");
    const response = await fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", {
      method: "POST",
      headers: {
        Accept: "application/json",
        "Content-Type": "application/json",
        Authorization: `Bearer ${process.env.IKIFORM_SECRET_KEY}`,
        ...(origin ? { Origin: origin } : {}),
      },
      body: JSON.stringify(data),
    });
    const result = await response.json();
    if (result.ok) return { sent: true, fields: {}, message: "Thanks. We'll look at that page today." };
    return { sent: false, fields: result.error.fields ?? {}, message: String(result.error.message) };
  });

export const Route = createFileRoute("/report")({ component: Report });

function Report() {
  const send = useServerFn(sendReport);
  const [outcome, setOutcome] = useState<Outcome | null>(null);
  const [pending, setPending] = useState(false);

  async function onSubmit(event: FormEvent<HTMLFormElement>) {
    event.preventDefault();
    setPending(true);
    try {
      setOutcome(await send({ data: new FormData(event.currentTarget) }));
    } catch {
      setOutcome({ sent: false, fields: {}, message: "The report didn't go through. Try again." });
    } finally {
      setPending(false);
    }
  }

  if (outcome?.sent) return <p role="status">{outcome.message}</p>;
  const fields = outcome?.fields ?? {};

  return (
    <form method="post" onSubmit={onSubmit}>
      <label htmlFor="email">Your email</label>
      <input id="email" name="email" type="email" required aria-invalid={Boolean(fields.email)} />
      {fields.email ? <p>{fields.email[0]}</p> : null}
      <label htmlFor="page">Page address</label>
      <input id="page" name="page" type="url" required aria-invalid={Boolean(fields.page)} />
      {fields.page ? <p>{fields.page[0]}</p> : null}
      <label htmlFor="severity">How bad is it</label>
      <select id="severity" name="severity" defaultValue="broken">
        <option value="cosmetic">Looks wrong</option>
        <option value="broken">Something doesn't work</option>
        <option value="blocking">I can't continue</option>
      </select>
      <label htmlFor="details">What happened</label>
      <textarea id="details" name="details" rows={5} minLength={20} required aria-invalid={Boolean(fields.details)} />
      {fields.details ? <p>{fields.details[0]}</p> : null}
      <input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
      {outcome?.message ? <p role="alert">{outcome.message}</p> : null}
      <button disabled={pending}>{pending ? "Sending" : "Send report"}</button>
    </form>
  );
}
```

**React Router**

La `action` de la ruta es el destino real del formulario, así que `<Form>` también funciona antes de la hidratación. `data()` pasa el estado `422` de Ikiform junto con los errores de campo y los valores escritos.

`app/routes/report.tsx`:

```tsx
import { data, Form, useNavigation } from "react-router";
import type { Route } from "./+types/report";

export async function action({ request }: Route.ActionArgs) {
  const form = await request.formData();
  const values = {
    email: String(form.get("email") ?? "").trim(),
    page: String(form.get("page") ?? "").trim(),
    severity: String(form.get("severity") ?? ""),
    details: String(form.get("details") ?? "").trim(),
  };
  const origin = request.headers.get("origin");

  const response = await fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", {
    method: "POST",
    headers: {
      Accept: "application/json",
      "Content-Type": "application/json",
      Authorization: `Bearer ${process.env.IKIFORM_SECRET_KEY}`,
      ...(origin ? { Origin: origin } : {}),
    },
    body: JSON.stringify({ ...values, _gotcha: String(form.get("_gotcha") ?? "") }),
  });
  const result = await response.json();
  if (result.ok) return { sent: true as const };
  return data(
    {
      sent: false as const,
      fields: (result.error.fields ?? {}) as Record<string, string[]>,
      message: String(result.error.message),
      values,
    },
    { status: response.status },
  );
}

export default function Report({ actionData }: Route.ComponentProps) {
  const sending = useNavigation().state === "submitting";

  if (actionData?.sent) return <p role="status">Thanks. We'll look at that page today.</p>;
  const fields = actionData?.fields ?? {};
  const values = actionData?.values;

  return (
    <Form method="post">
      <label htmlFor="email">Your email</label>
      <input id="email" name="email" type="email" required defaultValue={values?.email} />
      {fields.email ? <p>{fields.email[0]}</p> : null}
      <label htmlFor="page">Page address</label>
      <input id="page" name="page" type="url" required defaultValue={values?.page} />
      {fields.page ? <p>{fields.page[0]}</p> : null}
      <label htmlFor="severity">How bad is it</label>
      <select id="severity" name="severity" defaultValue={values?.severity ?? "broken"}>
        <option value="cosmetic">Looks wrong</option>
        <option value="broken">Something doesn't work</option>
        <option value="blocking">I can't continue</option>
      </select>
      <label htmlFor="details">What happened</label>
      <textarea id="details" name="details" rows={5} minLength={20} required defaultValue={values?.details} />
      {fields.details ? <p>{fields.details[0]}</p> : null}
      <input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
      {actionData && !Object.keys(fields).length ? <p role="alert">{actionData.message}</p> : null}
      <button disabled={sending}>{sending ? "Sending" : "Send report"}</button>
    </Form>
  );
}
```

**SvelteKit**

La form action por defecto devuelve `fail()` con el estado y los errores de campo de Ikiform. Sin JavaScript, la página se vuelve a renderizar con ellos; con `use:enhance` se actualiza en su sitio. Declara `IKIFORM_SECRET_KEY` en `src/env.ts` con `defineEnvVars` para que `$app/env/private` pueda leerla.

`src/routes/report/+page.server.ts`:

```ts
import { fail } from "@sveltejs/kit";
import { IKIFORM_SECRET_KEY } from "$app/env/private";
import type { Actions } from "./$types";

export const actions = {
  default: async ({ request, fetch, url }) => {
    const form = await request.formData();
    const values = {
      email: String(form.get("email") ?? "").trim(),
      page: String(form.get("page") ?? "").trim(),
      severity: String(form.get("severity") ?? ""),
      details: String(form.get("details") ?? "").trim(),
    };

    const response = await fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", {
      method: "POST",
      headers: {
        Accept: "application/json",
        "Content-Type": "application/json",
        Authorization: `Bearer ${IKIFORM_SECRET_KEY}`,
        Origin: request.headers.get("origin") ?? url.origin,
      },
      body: JSON.stringify({ ...values, _gotcha: String(form.get("_gotcha") ?? "") }),
    });
    const result = await response.json();
    if (!result.ok) {
      return fail(response.status, { values, fields: (result.error.fields ?? {}) as Record<string, string[]> });
    }
    return { sent: true };
  },
} satisfies Actions;
```

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

```svelte
<script lang="ts">
  import { enhance } from "$app/forms";
  import type { PageProps } from "./$types";

  let { form }: PageProps = $props();
  let fields = $derived(form?.fields ?? {});

  const SEVERITIES = [
    ["cosmetic", "Looks wrong"],
    ["broken", "Something doesn't work"],
    ["blocking", "I can't continue"],
  ];
</script>

{#if form?.sent}
  <p role="status">Thanks. We'll look at that page today.</p>
{:else}
  <form method="POST" use:enhance>
    <label for="email">Your email</label>
    <input id="email" name="email" type="email" required value={form?.values?.email ?? ""} />
    {#if fields.email}<p>{fields.email[0]}</p>{/if}

    <label for="page">Page address</label>
    <input id="page" name="page" type="url" required value={form?.values?.page ?? ""} />
    {#if fields.page}<p>{fields.page[0]}</p>{/if}

    <label for="severity">How bad is it</label>
    <select id="severity" name="severity">
      {#each SEVERITIES as [value, label]}
        <option {value} selected={(form?.values?.severity ?? "broken") === value}>{label}</option>
      {/each}
    </select>

    <label for="details">What happened</label>
    <textarea id="details" name="details" rows="5" minlength="20" required value={form?.values?.details ?? ""}></textarea>
    {#if fields.details}<p>{fields.details[0]}</p>{/if}

    <input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
    <button>Send report</button>
  </form>
{/if}
```

**Nuxt**

Una ruta de servidor reenvía el aviso con `runtimeConfig.ikiformSecretKey`, configurada desde `NUXT_IKIFORM_SECRET_KEY`. Lanza `createError` con los errores de campo en `data`, que `$fetch` devuelve en el cliente.

`server/api/report.post.ts`:

```ts
export default defineEventHandler(async (event) => {
  const body = await readBody<Record<string, string>>(event);
  const { ikiformSecretKey } = useRuntimeConfig(event);
  const origin = getRequestHeader(event, "origin");

  const response = await fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", {
    method: "POST",
    headers: {
      Accept: "application/json",
      "Content-Type": "application/json",
      Authorization: `Bearer ${ikiformSecretKey}`,
      ...(origin ? { Origin: origin } : {}),
    },
    body: JSON.stringify({
      email: body.email ?? "",
      page: body.page ?? "",
      severity: body.severity ?? "",
      details: body.details ?? "",
      _gotcha: body._gotcha ?? "",
    }),
  });
  const result = await response.json();
  if (!result.ok) {
    throw createError({
      status: response.status,
      message: String(result.error.message),
      data: { fields: result.error.fields ?? {} },
    });
  }
  return { sent: true };
});
```

`app/components/ReportForm.vue`:

```vue
<script setup lang="ts">
const sent = ref(false);
const sending = ref(false);
const fields = ref<Record<string, string[]>>({});

async function send(event: Event) {
  const form = event.target as HTMLFormElement;
  sending.value = true;
  fields.value = {};
  try {
    await $fetch("/api/report", { method: "POST", body: Object.fromEntries(new FormData(form)) });
    sent.value = true;
  } catch (failure) {
    const reply = failure as { data?: { data?: { fields?: Record<string, string[]> } } };
    fields.value = reply.data?.data?.fields ?? { details: ["The report didn't go through. Try again."] };
  } finally {
    sending.value = false;
  }
}
</script>

<template>
  <p v-if="sent" role="status">Thanks. We'll look at that page today.</p>
  <form v-else method="post" action="/api/report" @submit.prevent="send">
    <label for="email">Your email</label>
    <input id="email" name="email" type="email" required />
    <p v-if="fields.email">{{ fields.email[0] }}</p>
    <label for="page">Page address</label>
    <input id="page" name="page" type="url" required />
    <p v-if="fields.page">{{ fields.page[0] }}</p>
    <label for="severity">How bad is it</label>
    <select id="severity" name="severity">
      <option value="cosmetic">Looks wrong</option>
      <option value="broken" selected>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" required />
    <p v-if="fields.details">{{ fields.details[0] }}</p>
    <input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
    <button :disabled="sending">{{ sending ? "Sending" : "Send report" }}</button>
  </form>
</template>
```

**React**

Una app de una sola página envía desde el navegador, así que aquí no hay clave secreta; **Allowed domains** protege el formulario. `Accept: application/json` obtiene una respuesta JSON en lugar de una redirección.

`src/ReportForm.tsx`:

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

const ENDPOINT = "https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx";
const SEVERITIES = [
  ["cosmetic", "Looks wrong"],
  ["broken", "Something doesn't work"],
  ["blocking", "I can't continue"],
];

export function ReportForm() {
  const [sent, setSent] = useState(false);
  const [sending, setSending] = useState(false);
  const [fields, setFields] = useState<Record<string, string[]>>({});
  const [problem, setProblem] = useState("");

  async function onSubmit(event: FormEvent<HTMLFormElement>) {
    event.preventDefault();
    setSending(true);
    setFields({});
    setProblem("");
    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) setSent(true);
      else if (result.error.fields) setFields(result.error.fields);
      else setProblem(result.error.message);
    } catch {
      setProblem("Couldn't reach the server. Check your connection and try again.");
    } finally {
      setSending(false);
    }
  }

  if (sent) return <p role="status">Thanks. We'll look at that page today.</p>;

  return (
    <form action={ENDPOINT} method="post" onSubmit={onSubmit}>
      <label htmlFor="email">Your email</label>
      <input id="email" name="email" type="email" required aria-invalid={Boolean(fields.email)} />
      {fields.email ? <p>{fields.email[0]}</p> : null}
      <label htmlFor="page">Page address</label>
      <input id="page" name="page" type="url" required aria-invalid={Boolean(fields.page)} />
      {fields.page ? <p>{fields.page[0]}</p> : null}
      <label htmlFor="severity">How bad is it</label>
      <select id="severity" name="severity" defaultValue="broken">
        {SEVERITIES.map(([value, label]) => (
          <option key={value} value={value}>{label}</option>
        ))}
      </select>
      <label htmlFor="details">What happened</label>
      <textarea id="details" name="details" rows={5} minLength={20} required aria-invalid={Boolean(fields.details)} />
      {fields.details ? <p>{fields.details[0]}</p> : null}
      <input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
      {problem ? <p role="alert">{problem}</p> : null}
      <button disabled={sending}>{sending ? "Sending" : "Send report"}</button>
    </form>
  );
}
```

**Vue**

El mismo envío desde el navegador en un componente de Vue. El formulario mantiene un `action` y un `method` reales, así que sigue enviándose de la forma normal si el script no carga.

`src/components/ReportForm.vue`:

```vue
<script setup lang="ts">
import { ref } from "vue";

const ENDPOINT = "https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx";

const sent = ref(false);
const sending = ref(false);
const fields = ref<Record<string, string[]>>({});
const problem = ref("");

async function send(event: SubmitEvent) {
  sending.value = true;
  fields.value = {};
  problem.value = "";
  try {
    const response = await fetch(ENDPOINT, {
      method: "POST",
      headers: { Accept: "application/json" },
      body: new FormData(event.target as HTMLFormElement),
    });
    const result = await response.json();
    if (result.ok) sent.value = true;
    else if (result.error.fields) fields.value = result.error.fields;
    else problem.value = result.error.message;
  } catch {
    problem.value = "Couldn't reach the server. Check your connection and try again.";
  } finally {
    sending.value = false;
  }
}
</script>

<template>
  <p v-if="sent" role="status">Thanks. We'll look at that page today.</p>
  <form v-else :action="ENDPOINT" method="post" @submit.prevent="send">
    <label for="email">Your email</label>
    <input id="email" name="email" type="email" required :aria-invalid="Boolean(fields.email)" />
    <p v-if="fields.email">{{ fields.email[0] }}</p>
    <label for="page">Page address</label>
    <input id="page" name="page" type="url" required :aria-invalid="Boolean(fields.page)" />
    <p v-if="fields.page">{{ fields.page[0] }}</p>
    <label for="severity">How bad is it</label>
    <select id="severity" name="severity">
      <option value="cosmetic">Looks wrong</option>
      <option value="broken" selected>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" required :aria-invalid="Boolean(fields.details)" />
    <p v-if="fields.details">{{ fields.details[0] }}</p>
    <input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
    <p v-if="problem" role="alert">{{ problem }}</p>
    <button :disabled="sending">{{ sending ? "Sending" : "Send report" }}</button>
  </form>
</template>
```

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

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](https://ikiform.com/es/docs/guides/nextjs.md), [Astro](https://ikiform.com/es/docs/guides/astro.md), [SvelteKit](https://ikiform.com/es/docs/guides/sveltekit.md), [Nuxt](https://ikiform.com/es/docs/guides/nuxt.md), [React](https://ikiform.com/es/docs/guides/react.md), [Vue](https://ikiform.com/es/docs/guides/vue.md) y [desde un servidor](https://ikiform.com/es/docs/guides/server.md).

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

| Regla | En el navegador | En la definición de campos de Ikiform |
| --- | --- | --- |
| Obligatorio | `required` | `"required": true` |
| Email | `type="email"` | `"type": "email"`, con `allowed_domains` opcional |
| URL | `type="url"` | `"type": "url"`, con `https_only` opcional |
| Longitud | `minlength`, `maxlength` | `validation.min_length`, `max_length` |
| Solo las opciones de la lista | Un `<select>`, que un bot ignora | `options`; cualquier otra cosa se rechaza |
| Sin campos extra | No es posible | Se 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](https://ikiform.com/es/guides/html-form-submission/validation.md) 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](https://ikiform.com/es/guides/html-form-submission/without-page-reload.md). La documentación de [JavaScript y fetch](https://ikiform.com/es/docs/frameworks.md) 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.

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

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

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

| Problema | Qué hacer |
| --- | --- |
| `405 Method Not Allowed` tras enviar | El 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_found` | Es 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_type` | El 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_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 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ío | No 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 formulario | La 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 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

### ¿Qué hace el atributo action en un formulario HTML?

Es la URL que recibe los datos del formulario. Un valor relativo se resuelve respecto a la dirección de la página, y un formulario sin action envía a la propia página. Un botón de envío puede sobrescribirlo con formaction.

### ¿Un formulario debe usar GET o POST?

POST para todo lo que envíe datos personales o cambie el estado, como formularios de contacto, registro o pedido. GET es para formularios de búsqueda y filtro, donde los campos tienen sentido en una URL que se pueda compartir.

### ¿Por qué al recargar la página se vuelve a enviar mi formulario?

La página que ves es la respuesta directa a un POST, así que el navegador ofrece repetirlo. Responde a un POST correcto con una redirección 303 a una página de agradecimiento. Ikiform lo hace con cada envío desde el navegador.

### ¿Puede un formulario HTML enviar JSON?

No por sí solo. Un formulario envía datos urlencoded o multipart. Para enviar JSON, lee el formulario con FormData en un manejador de submit y envía JSON.stringify(Object.fromEntries(data)) con fetch. Ikiform acepta los tres.

### ¿Basta con el atributo required para validar un formulario?

No. Ayuda a las personas, pero los bots envían directamente y se lo saltan. Valida también en el servidor. Con Ikiform, define los campos y cada envío se comprueba con ellos antes de guardarse.

### ¿Puedo enviar un formulario sin backend?

Necesitas algo que reciba el POST, pero no tiene por qué ser tu servidor. Apunta el action del formulario a un backend de formularios como Ikiform y la página puede seguir siendo totalmente estática.

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

- [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.
- [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.
- [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.
- [Inscripción a eventos](https://ikiform.com/es/guides/event-registration.md): Recibe RSVP en la página de tu evento: el formulario, límite de acompañantes, un recuento fiable, cierre al llenarse y lista de entrada, sin app de entradas.
- [Verifica las firmas de webhook](https://ikiform.com/es/guides/webhook-signature-verification.md): Comprueba que un webhook de formulario viene de verdad de Ikiform: el esquema HMAC-SHA256 de Standard Webhooks, un vector de prueba, cuerpos sin procesar, marcas de tiempo y rotación de secretos.
- [Lista de espera para un nuevo SaaS](https://ikiform.com/es/guides/waitlist.md): Crea una lista de espera previa al lanzamiento en tu propia landing page: el formulario, el antispam, los duplicados, los emails de confirmación y una exportación limpia el día del lanzamiento.
