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.
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.
- 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.
- 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.
- El navegador construye el conjunto de datos del formulario a partir de cada control que tiene nombre y no está desactivado.
- Codifica ese conjunto con el enctype del formulario, urlencoded salvo que indiques otra cosa.
- Navega a la URL del action con el method del formulario, enviando el cuerpo.
- 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.
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 | 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ó:
<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. Unidsolo no envía nada. - Los controles
disabledse saltan. Usareadonlysi 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 sinvalueenvíaon. - Solo se incluye el botón que envió el formulario, con su
namey suvalue. <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 atributoformque indique eliddel 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:
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.
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--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 |
| 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.
- Con una respuesta 200, el navegador envía el formulario y muestra el cuerpo de la respuesta como página.
- Esa página está ligada al POST, así que una recarga pide reenviar y una confirmación vuelve a enviar los datos.
- Con una respuesta 303, el navegador envía el formulario y se le dice que cargue otra URL.
- Carga esa URL con GET. Una recarga solo repite el GET, así que nada se envía dos veces.
| 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 |
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.comCó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.
- Un envío de formulario normal navega la pestaña, así que el navegador envía Sec-Fetch-Mode: navigate.
- 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.
- Una llamada fetch envía Sec-Fetch-Mode: cors, o tu código pide JSON con la cabecera Accept.
- 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.
Adónde va un envío correcto
Un envío correcto desde el navegador recibe un 303. Ikiform elige el destino en este orden:
- El ajuste Redirect after submit del formulario en el panel. Funciona cualquier URL
http:ohttps:, porque solo tu espacio de trabajo puede configurarlo. - Un campo
_redirecten el envío, o_nextsi no hay_redirect, cuando es una URLhttps:sin usuario, contraseña ni puerto personalizado, en uno de los Allowed domains del formulario o en un subdominio de uno. - Si no, la página de agradecimiento alojada en
/f/<formId>/thanks. Cuando el envío vino de una páginahttps: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.
{
"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.
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.
<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>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.
"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,
};
}"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>
);
}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.
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[]> };
},
}),
};---
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>
)}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.
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>
);
}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.
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>
);
}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.
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;<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}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.
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 };
});<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>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.
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>
);
}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.
<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>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
methodreal y, donde envían directamente a Ikiform, unactionreal. Con los scripts desactivados, el formulario sigue enviándose y recibe el303. - El
namede cada input es eliddel campo en la definición. Si no coinciden, comourlen lugar depage, se recibe422conUnknown field "url". - El input oculto
_gotchaes el honeypot. Los bots que lo rellenan se guardan como spam, sin avisar. - Las versiones con JavaScript muestran
error.fieldsbajo 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.
| Regla | En el navegador | En la definición de campos de Ikiform |
|---|---|---|
| Obligatorio | required | "required": true |
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 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-Typecuando el cuerpo esFormData. El navegador ponemultipart/form-datacon 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 porSec-Fetch-Modede 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
FormDataoURLSearchParamses una petición simple entre sitios, así que no hay preflight. Un cuerpo JSON conContent-Type: application/jsonprovoca primero un preflight; Ikiform lo responde, pero cuesta un viaje de ida y vuelta. form.submit()se salta la validación y el eventosubmit.form.requestSubmit()ejecuta ambos, como haría un clic.- Pasa el botón pulsado como segundo argumento,
new FormData(form, event.submitter), si sunamey suvalueimportan.
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.
- Sin recargar la páginaEnví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íoManda 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 actionQué 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 formulariosValida 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.
- En Allowed domains del formulario, añade el dominio de tu sitio, y
localhostmientras desarrollas. Déjalo vacío para aceptar envíos desde cualquier lugar. - 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. - Con JavaScript activado, abre el panel Network y envía otra vez. La petición a
/f/<formId>debería responder201con JSON, y la página no debería recargarse. - 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.
- En la página de agradecimiento, pulsa recargar. No debería reenviarse nada ni aparecer un segundo envío.
- Abre Submissions en el panel. El nuevo envío está ahí con todos los campos que enviaste.
- 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
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.
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.
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.
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.
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.
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.
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.
¿Te atascaste en un paso o quieres una guía para tu caso? Escribe a hello@ikiform.com.