Guias17 min de leitura
Como funciona o envio de formulários HTML
O que o navegador faz quando um formulário é enviado: quais campos manda, como os codifica, o que o servidor deve responder e como o Ikiform responde.
Quando alguém clica em enviar, o navegador confere as restrições do formulário, reúne todo controle com nome e habilitado, codifica tudo com o enctype do formulário e manda para a URL do action com o method do formulário. O servidor responde como em qualquer carregamento de página, e um servidor bem-comportado responde a um POST bem-sucedido com um redirecionamento 303, para que um refresh não envie o formulário duas vezes. Aponte o action para um backend de formulários como o Ikiform e você ganha esse redirecionamento, validação de campos e filtro de spam sem servidor próprio.
Formulários HTML são mais antigos que o JavaScript e continuam sendo a forma mais confiável de enviar dados de uma página web. Eles funcionam com scripts desligados, em celulares lentos e em qualquer criador de sites que deixe você colar markup. A maioria dos bugs de formulário vem de não saber o que o navegador faz sozinho, então este guia percorre um envio passo a passo e depois mostra onde um backend de formulários se encaixa.
O que acontece quando você clica em enviar
Um envio é uma navegação normal com um corpo anexado. O navegador faz quatro coisas antes de qualquer dado sair da página, depois entrega a requisição à rede e carrega o que voltar.
- Um botão de envio, a tecla Enter num campo de texto ou form.requestSubmit() inicia o envio e dispara um evento submit.
- A menos que o formulário tenha novalidate, o navegador confere as restrições de cada controle. O primeiro inválido recebe uma mensagem e nada é enviado.
- O navegador monta o form data set a partir de todo controle que tem name e não está desabilitado.
- Ele codifica esse conjunto com o enctype do formulário, urlencoded a menos que você diga o contrário.
- Ele navega até a URL do action com o method do formulário, enviando o corpo.
- O servidor responde. Um 303 diz ao navegador para carregar outra URL com GET, que é a página onde o visitante termina.
Os atributos que definem a requisição
Cinco atributos do <form> definem para onde os dados vão e como. Cada um pode ser sobrescrito num único botão com o atributo form* correspondente, e é assim que um formulário pode ter os botões Send e Save draft com comportamentos diferentes.
| Atributo | Padrão | O que faz | Sobrescrita no botão |
|---|---|---|---|
action | A URL da própria página | A URL que recebe os dados. Valores relativos são resolvidos a partir da página. Veja o atributo action | formaction |
method | get | get coloca os campos na query string da URL. post os envia como corpo. dialog fecha um <dialog> sem enviar nada | formmethod |
enctype | application/x-www-form-urlencoded | Como um corpo post é codificado. multipart/form-data é necessário para arquivos. text/plain serve para depuração | formenctype |
novalidate | Desligada | Pula a checagem de restrições do navegador no envio | formnovalidate |
target | _self | Onde a resposta é carregada. _blank abre uma nova aba, um iframe com nome mantém a página no lugar | formtarget |
Dois botões, dois comportamentos. O segundo pula a validação, e o name e o value do botão clicado são adicionados aos dados, então o servidor sabe qual foi pressionado:
<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>Quais controles são enviados
Os navegadores só enviam o que a especificação HTML chama de form data set. A maioria das surpresas num log de envios vem destas regras:
- Um controle precisa de
name. Só umidnão envia nada. - Controles
disabledsão ignorados. Usereadonlyse quiser um valor exibido, travado e enviado. - Um checkbox ou radio desmarcado não envia nada, nem
false. Um checkbox marcado semvalueenviaon. - Só o botão que enviou o formulário é incluído, com seu
nameevalue. <select multiple>e grupos de checkbox que compartilham um name enviam um par por valor selecionado.- Inputs fora do elemento
<form>podem fazer parte dele com um atributoformque aponta para oiddo formulário.
Como o corpo é codificado
Com o enctype padrão, o corpo é uma única linha de pares name=value unidos por &, com espaços como + e todo o resto fora de um conjunto seguro codificado com porcentagem. Esta é a requisição real do formulário de relato mais abaixo, do jeito que o navegador a envia:
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 coloca cada campo numa parte própria, com uma string de boundary escolhida pelo navegador. É maior, mas consegue levar arquivos. O Ikiform lê os dois. text/plain não tem uma forma confiável de saber onde um valor termina, então o Ikiform o recusa com 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. Upload de arquivos ainda não é suportado. Um envio com arquivo recebe 422 files_not_supported.Quatro formas de enviar um formulário
Um envio simples é só uma opção. Cada abordagem abaixo é uma escolha justa em algum cenário; o que muda é quem trata a resposta e o que acontece com scripts desligados.
| Abordagem | Bom em | Contras |
|---|---|---|
method="post" simples com redirecionamento | Funciona sem JavaScript, em qualquer criador de sites. O navegador cuida do estado de carregamento, do voltar e do refresh | Um carregamento de página inteiro. Erros de campo precisam de uma página renderizada no servidor, ou da página de erro do backend |
fetch com FormData | Sem recarregar. Você decide como ficam o sucesso e cada erro de campo | O estado de carregamento, os erros de rede e o foco ficam com você. Sem cuidado, quebra com scripts desligados. Veja sem recarregar a página |
| Form actions de frameworks, como server actions do Next.js ou form actions do SvelteKit | Seu servidor vê o envio primeiro, e o mesmo formulário funciona antes e depois da hidratação | Você precisa de um runtime de servidor, e a requisição ao backend sai do seu servidor, o que muda limites de taxa e checagens de origem |
method="get" | Formulários de busca e filtro. O resultado tem uma URL compartilhável | Os campos vão parar na URL, no histórico e nos logs do servidor. Nunca para dados pessoais nem para nada que mude estado |
Comece com o envio simples e adicione fetch por cima. Assim o formulário continua funcionando quando um script falha ao carregar, o que acontece mais do que a maioria dos analytics mostra.
O que o servidor deve responder
O navegador trata a resposta a um POST como uma nova página. Se essa página é a própria resposta, com status 200, o navegador lembra que ela veio de um POST. Aperte refresh e ele se oferece para enviar o formulário de novo; aperte voltar e avançar e alguns navegadores fazem o mesmo. É assim que as pessoas pagam duas vezes e o suporte recebe a mesma mensagem três vezes.
A solução é o padrão Post/Redirect/Get. O servidor trata o POST e responde 303 See Other com um header Location. O navegador carrega essa URL com um GET, e a página que o visitante vê veio de um GET, então o refresh é inofensivo.
- Com uma resposta 200, o navegador envia o formulário e mostra o corpo da resposta como página.
- Essa página está presa ao POST, então um refresh pede para reenviar e uma confirmação manda os dados de novo.
- Com uma resposta 303, o navegador envia o formulário e recebe a instrução de carregar outra URL.
- Ele carrega essa URL com GET. Um refresh repete só o GET, então nada é enviado duas vezes.
| Status | O que o navegador faz em seguida | Use para |
|---|---|---|
303 See Other | GET no Location, corpo descartado | Depois de qualquer envio de formulário bem-sucedido |
302 Found | Na prática os navegadores mudam para GET, mas a especificação permite manter o método | Servidores antigos. Prefira 303 |
307 e 308 | Repete o mesmo POST com o mesmo corpo na nova URL | Mover um endpoint, nunca uma página de agradecimento |
200 com HTML | Mostra a página; o refresh oferece enviar de novo | Mostrar o formulário de novo com erros |
4xx com HTML | Mostra a página de erro | Erros que o visitante corrige voltando |
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.comComo o Ikiform responde
Um único endpoint, https://ikiform.com/f/<formId>, responde tanto a navegadores quanto a código. Ele decide por requisição se responde com páginas ou com JSON, a partir de headers que o navegador define sozinho:
| Requisição | Resposta |
|---|---|
Accept inclui application/json | JSON |
| O corpo é JSON | JSON |
Sec-Fetch-Mode: navigate, que é todo envio simples de formulário | Redirecionamento ou página hospedada |
Qualquer outro Sec-Fetch-Mode, como cors vindo de fetch | JSON |
Sem Sec-Fetch-Mode, e Accept inclui text/html | Redirecionamento ou página hospedada |
| Qualquer outra coisa, como curl | JSON |
Sec-Fetch-Mode é um header que o navegador define e que scripts da página não conseguem mudar. Um envio de formulário que navega a aba manda navigate; uma chamada fetch manda cors numa requisição cross-site. Então o mesmo formulário recebe um redirecionamento quando envia do jeito tradicional e JSON quando seu script o envia, sem nenhuma chave do seu lado. A tabela completa está em JSON ou redirecionamento.
- Um envio simples de formulário navega a aba, então o navegador manda Sec-Fetch-Mode: navigate.
- O Ikiform responde em modo página: um redirecionamento 303 em caso de sucesso, ou uma página de erro hospedada com o status real do erro.
- Uma chamada fetch manda Sec-Fetch-Mode: cors, ou seu código pede JSON com o header Accept.
- O Ikiform responde em modo JSON: 201 com o id do envio, ou o envelope de erro com os erros de campo indexados pelo id do campo.
Para onde vai um envio bem-sucedido
Um envio bem-sucedido pelo navegador recebe um 303. O Ikiform escolhe o destino nesta ordem:
- A configuração Redirect after submit (redirecionar após o envio) do formulário no painel. Qualquer URL
http:ouhttps:funciona, porque só o seu workspace pode defini-la. - Um campo
_redirectno envio, ou_nextse não houver_redirect, quando é uma URLhttps:sem usuário, senha ou porta personalizada, em um dos Allowed domains do formulário ou num subdomínio de um deles. - Caso contrário, a página de agradecimento hospedada em
/f/<formId>/thanks. Quando o envio veio de uma páginahttps:num domínio permitido, ela tem um link de volta para lá.
Um _redirect que quebra uma regra é ignorado, nunca vira erro, e um formulário sem domínios permitidos ignora _redirect por completo. Isso impede que alguém use seu formulário para jogar visitantes num site de sua escolha. Redirecionar após o envio trata das páginas de agradecimento em detalhe, e as regras completas estão em Formulários HTML.
Quando um envio é recusado
No modo página, um envio recusado mostra uma página de erro hospedada com o status HTTP real do erro. Num erro de validação, ela lista cada problema sob o rótulo do seu campo e tem um link para a página de onde o envio veio. O botão Back (voltar) do navegador geralmente retorna ao formulário com o que o visitante digitou ainda lá; o link carrega a página do zero. No modo JSON, o mesmo erro volta como um único envelope. Todos os códigos estão em Erros.
{
"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"
}Spam é a única coisa que nunca parece um erro. Um envio classificado como spam recebe o mesmo 303 ou 201 que um real, então o bot não aprende nada, e nenhum e-mail ou webhook é enviado.
O formulário na sua stack
Aqui está um formulário real, um relato de página quebrada, em todas as stacks. Escolha a sua; a escolha vale para todos os exemplos de código destes guias. A definição de campos abaixo é a mesma que o Ikiform aplica no servidor, então experimente: deixe os detalhes curtos ou digite example como endereço e você recebe o 422 que seu formulário receberia.
Prévia
Roda o validador do Ikiform no seu navegador. Nada é enviado.
Nenhum script. O navegador confere required, type e minlength, envia os campos como texto urlencoded, segue o 303 do Ikiform até sua página de _redirect e mostra a página de erro do Ikiform se o servidor recusar o envio.
<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>Uma server action recebe o FormData, encaminha como JSON com a sua chave secreta e devolve os erros de campo do Ikiform. useActionState coloca cada mensagem embaixo do input dela e mantém o que o visitante digitou.
"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>
);
}Com accept: "form", o Zod rejeita um e-mail ou URL inválido antes de qualquer coisa sair do seu servidor, e isInputError expõe essas mensagens por campo. Os erros de campo 422 do próprio Ikiform voltam como dados da action no mesmo formato.
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>
)}Uma server function POST lê o FormData em .validator() e devolve os erros de campo como dados simples, então o componente consegue renderizá-los sem 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>
);
}A action da rota é o destino real do formulário, então <Form> funciona antes da hidratação também. data() repassa o status 422 do Ikiform com os erros de campo e os valores digitados.
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>
);
}A form action padrão devolve fail() com o status e os erros de campo do Ikiform. Sem JavaScript a página é renderizada de novo com eles; com use:enhance ela se atualiza no lugar. Declare IKIFORM_SECRET_KEY em src/env.ts com defineEnvVars para que $app/env/private consiga lê-la.
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}Uma server route encaminha o relato com runtimeConfig.ikiformSecretKey, definido a partir de NUXT_IKIFORM_SECRET_KEY. Ela lança createError com os erros de campo em data, que o $fetch devolve no 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>Uma single-page app envia a partir do navegador, então não há chave secreta aqui; quem protege o formulário é Allowed domains. Accept: application/json recebe uma resposta JSON em vez de um redirecionamento.
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>
);
}O mesmo envio pelo navegador num componente Vue. O formulário mantém um action e um method reais, então ainda envia do jeito tradicional se o script não carregar.
<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 em variáveis de ambiente só do servidor.Cada versão de servidor encaminha o header Origin do navegador quando existe, porque um fetch no servidor não manda nenhum e o Ikiform o usa para reconhecer testes gratuitos de localhost num workspace sem plano.
- Toda versão mantém um
methodreal e, onde envia direto ao Ikiform, umactionreal. Com scripts desligados o formulário ainda envia e recebe o303. - O
namede cada input é oiddo campo na definição. Uma divergência, comourlem vez depage, recebe422comUnknown field "url". - O input oculto
_gotchaé o honeypot. Bots que o preenchem são armazenados como spam, sem alarde. - As versões em JavaScript mostram
error.fieldsembaixo de cada input, usando a primeira mensagem de cada campo. A versão em HTML puro deixa isso para a página de erro do Ikiform.
A documentação vai mais fundo por framework: Next.js, Astro, SvelteKit, Nuxt, React, Vue e a partir de um servidor.
Duas camadas de validação
A validação no navegador e a validação no servidor fazem trabalhos diferentes. A checagem de restrições do navegador (required, type="email", minlength, pattern) dá feedback instantâneo, mas qualquer um pode pulá-la: um bot envia direto, e um script pode chamar form.submit(), que nunca roda a checagem. O servidor é o único lugar onde uma regra realmente vale.
| Regra | No navegador | Na definição de campos do Ikiform |
|---|---|---|
| Obrigatório | required | "required": true |
type="email" | "type": "email", com allowed_domains opcional | |
| URL | type="url" | "type": "url", com https_only opcional |
| Tamanho | minlength, maxlength | validation.min_length, max_length |
| Só as opções listadas | Um <select>, que um bot ignora | options; qualquer outra coisa é recusada |
| Sem campos extras | Não é possível | Qualquer campo fora da definição é recusado |
Sem definição, o Ikiform aceita quaisquer campos e os armazena como chegam. Publique uma e ele valida, armazena números e checkboxes como números e booleanos de verdade, e recusa o envio com 422 validation_failed antes de armazenar qualquer coisa. Validação mostra como manter as duas camadas dizendo a mesma coisa.
Enviando com JavaScript
Para manter o visitante na página, escute o submit, chame preventDefault() e envie new FormData(form) com fetch. Alguns detalhes decidem se isso funciona de primeira:
- Não defina o
Content-Typevocê mesmo quando o corpo éFormData. O navegador definemultipart/form-datacom o boundary; um header escrito à mão não tem boundary e o corpo não pode ser interpretado. - Envie
Accept: application/json. O Ikiform escolheria JSON peloSec-Fetch-Modede qualquer forma, mas o header deixa isso explícito, e é um dos headers de requisição que os navegadores podem mandar cross-site sem preflight. - Um corpo
FormDataouURLSearchParamsé uma requisição cross-site simples, então não há preflight. Um corpo JSON comContent-Type: application/jsondispara um preflight antes; o Ikiform responde, mas custa uma ida e volta. form.submit()pula a validação e o eventosubmit.form.requestSubmit()roda os dois, como um clique faria.- Passe o botão clicado como segundo argumento,
new FormData(form, event.submitter), se onamee ovaluedele importam.
O padrão completo, com estado de carregamento, foco e erros de rede, está em sem recarregar a página. A documentação de JavaScript e fetch lista todos os headers e formatos de corpo que o endpoint aceita.
Escolha o seu caso
Cada uma destas páginas pega uma parte do envio e vai além da visão geral acima, com código para aquele caso.
- Sem recarregar a páginaEnvie um formulário com fetch e FormData para o visitante continuar na página, com erros de campo, estado de carregamento, novas tentativas e um plano B quando scripts falham.
- Redirecionar após o envioMande visitantes para sua própria página de agradecimento depois do envio: o redirecionamento 303, o campo _redirect, quais domínios são permitidos e o que o refresh faz.
- O atributo actionO que o atributo action de um formulário faz, como valores relativos e vazios são resolvidos, como formaction manda um formulário a dois lugares e por que hosts estáticos respondem 405.
- Validação de formuláriosValide um formulário com required, type, pattern e min, estilize erros com :user-invalid e aplique as mesmas regras no servidor com definições de campos.
Confira se funciona
Faça isto uma vez por formulário, numa janela anônima. O Ikiform não tem plano gratuito. Testar é grátis: um workspace sem plano aceita 50 envios de teste por mês a partir de localhost. Para entrar no ar, escolha o Pro a partir de US$ 6/mês para 500 envios, ou pague uma vez: 10.000 envios por US$ 49 ou 50.000 envios por US$ 149, com créditos que nunca expiram. Veja os preços.
- Em Allowed domains do formulário, adicione o domínio do seu site, mais
localhostenquanto desenvolve. Deixe vazio para aceitar envios de qualquer lugar. - Desligue o JavaScript nas ferramentas de desenvolvedor do navegador e envie o formulário. Ele ainda deve chegar à sua página de agradecimento com um
303. - Com o JavaScript ligado, abra o painel Network e envie de novo. A requisição para
/f/<formId>deve responder201com JSON, e a página não deve recarregar. - Envie com uma mensagem curta demais. O envio simples mostra a página de erro do Ikiform com o rótulo do seu campo; o envio por script mostra a mensagem embaixo do campo.
- Na página de agradecimento, aperte refresh. Nada deve ser reenviado, e nenhum segundo envio deve aparecer.
- Abra Submissions no painel. O novo envio está lá com todos os campos que você enviou.
- Envie com o honeypot preenchido,
_gotcha=bot. Ele ainda recebe uma resposta de sucesso, mas o envio é armazenado como spam e não dispara e-mail nem webhook.
Solução de problemas
| Problema | O que fazer |
|---|---|
405 Method Not Allowed depois de enviar | O formulário enviou para o seu próprio host estático, que não aceita POST. Confira o action: ele precisa ser a URL completa https://ikiform.com/f/<formId>, não um caminho relativo nem vazio. |
Abrir o endpoint numa aba mostra 404 not_found | É o esperado. Uma aba manda GET, e o endpoint só aceita POST. Confira se o method do formulário é post; um formulário sem method manda GET com os campos na URL. |
415 unsupported_media_type | O formulário tem enctype="text/plain", ou um script enviou um tipo que o Ikiform não lê. Remova o enctype, ou use multipart/form-data. |
422 validation_failed | O envio não corresponde à definição de campos do formulário. error.fields aponta cada campo e o motivo. Faça o name do input bater com o id do campo. |
| O navegador cai na página de agradecimento do Ikiform em vez da sua | O _redirect é ignorado, a menos que o domínio dele esteja em Allowed domains e ele seja uma URL https:. Adicione o domínio, ou defina Redirect after submit nas configurações do formulário. |
| Um campo está faltando no envio | Ele não tem name, está disabled ou é um checkbox desmarcado. Dê um name a ele, use readonly em vez de disabled, ou defina um campo checkbox para que uma caixa desmarcada seja armazenada como false. |
| O refresh pede para reenviar o formulário | A página respondeu ao POST com 200. Envios direto ao Ikiform sempre recebem um 303; se o seu próprio servidor fica no meio, redirecione depois de tratar o envio. |
403 origin_not_allowed, ou todo envio cai no spam | O domínio da página não está em Allowed domains do formulário. Adicione-o, mais localhost para testes locais. Subdomínios de um domínio listado são permitidos. |
Perguntas
É a URL que recebe os dados do formulário. Um valor relativo é resolvido a partir do endereço da página, e um formulário sem action envia para a própria página. Um botão de envio pode sobrescrevê-lo com formaction.
POST para qualquer coisa que envie dados pessoais ou mude estado, como formulários de contato, inscrição ou pedido. GET é para formulários de busca e filtro, em que os campos fazem parte de uma URL que as pessoas podem compartilhar.
A página que você vê é a resposta direta a um POST, então o navegador se oferece para repeti-lo. Em vez disso, responda a um POST bem-sucedido com um redirecionamento 303 para uma página de agradecimento. O Ikiform faz isso em todo envio do navegador.
Não sozinho. Um formulário envia dados urlencoded ou multipart. Para enviar JSON, leia o formulário com FormData num handler de submit e envie JSON.stringify(Object.fromEntries(data)) com fetch. O Ikiform aceita os três.
Não. Ele ajuda as pessoas, mas bots enviam direto e o pulam. Valide no servidor também. Com o Ikiform, defina os campos e cada envio é conferido com base neles antes de ser armazenado.
Você precisa de algo que receba o POST, mas não precisa ser o seu servidor. Aponte o action do formulário para um backend de formulários como o Ikiform, e a página pode continuar totalmente estática.
Ainda não. Um envio que contém um arquivo é recusado com 422 files_not_supported. Peça um link para o arquivo, como a URL de um drive compartilhado.
Travou em algum passo ou quer um guia para o seu caso? Envie um e-mail para hello@ikiform.com.