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

Publicado em 2026-10-25 · https://ikiform.com/pt-br/guides/html-form-submission

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.

Diagrama: O que o navegador faz entre o clique em enviar e a próxima página

1. Um botão de envio, a tecla Enter num campo de texto ou form.requestSubmit() inicia o envio e dispara um evento submit.
2. 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.
3. O navegador monta o form data set a partir de todo controle que tem name e não está desabilitado.
4. Ele codifica esse conjunto com o enctype do formulário, urlencoded a menos que você diga o contrário.
5. Ele navega até a URL do action com o method do formulário, enviando o corpo.
6. O servidor responde. Um 303 diz ao navegador para carregar outra URL com GET, que é a página onde o visitante termina.

Tudo acima da linha acontece sem nenhuma rede. Um listener do evento submit pode parar o fluxo no primeiro passo e assumir com fetch.

## 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](https://ikiform.com/pt-br/guides/html-form-submission/action-attribute.md) | `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:

`Dois botões de envio`:

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

### 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ó um `id` não envia nada.
- Controles `disabled` são ignorados. Use `readonly` se quiser um valor exibido, travado e enviado.
- Um checkbox ou radio desmarcado não envia nada, nem `false`. Um checkbox marcado sem `value` envia `on`.
- Só o botão que enviou o formulário é incluído, com seu `name` e `value`.
- `<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 atributo `form` que aponta para o `id` do 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:

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

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

> Qualquer que seja a codificação, mantenha a página em UTF-8. O Ikiform decodifica todo corpo como UTF-8 e recusa outros charsets com `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](https://ikiform.com/pt-br/guides/html-form-submission/without-page-reload.md) |
| 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.

Diagrama: Responder a um POST com uma página comparado a responder com um redirecionamento 303

1. Com uma resposta 200, o navegador envia o formulário e mostra o corpo da resposta como página.
2. Essa página está presa ao POST, então um refresh pede para reenviar e uma confirmação manda os dados de novo.
3. Com uma resposta 303, o navegador envia o formulário e recebe a instrução de carregar outra URL.
4. Ele carrega essa URL com GET. Um refresh repete só o GET, então nada é enviado duas vezes.

O Ikiform sempre responde a um envio do navegador com um 303, então seu formulário ganha esse comportamento de graça.

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

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

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

Diagrama: O mesmo endpoint do Ikiform respondendo a um envio simples de formulário e a uma chamada fetch

1. Um envio simples de formulário navega a aba, então o navegador manda Sec-Fetch-Mode: navigate.
2. 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.
3. Uma chamada fetch manda Sec-Fetch-Mode: cors, ou seu código pede JSON com o header Accept.
4. 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.

Você não configura o modo. Ele decorre de como a requisição foi feita.

### Para onde vai um envio bem-sucedido

Um envio bem-sucedido pelo navegador recebe um `303`. O Ikiform escolhe o destino nesta ordem:

1. A configuração **Redirect after submit** (redirecionar após o envio) do formulário no painel. Qualquer URL `http:` ou `https:` funciona, porque só o seu workspace pode defini-la.
2. Um campo `_redirect` no envio, ou `_next` se não houver `_redirect`, quando é uma URL `https:` sem usuário, senha ou porta personalizada, em um dos **Allowed domains** do formulário ou num subdomínio de um deles.
3. Caso contrário, a página de agradecimento hospedada em `/f/<formId>/thanks`. Quando o envio veio de uma página `https:` 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](https://ikiform.com/pt-br/guides/html-form-submission/redirect-after-submit.md) trata das páginas de agradecimento em detalhe, e as regras completas estão em [Formulários HTML](https://ikiform.com/pt-br/docs/html.md#redirect-rules).

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

`422 em 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"
}
```

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.

Formulário: Report a broken page

- **Seu e-mail** (`email`, email, obrigatório)
- **Endereço da página** (`page`, url, obrigatório)
- **Qual a gravidade** (`severity`, select, obrigatório)
- **O que aconteceu** (`details`, textarea, obrigatório)

Cole esta definição na aba **Fields** (campos) do formulário no painel e o Ikiform valida cada envio com base nela. Veja [Campos e validação](https://ikiform.com/pt-br/docs/fields.md).

**HTML**

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.

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

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.

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

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.

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

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.

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

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.

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

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.

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

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.

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

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.

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

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.

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

> As versões no servidor enviam com uma chave secreta, então o Ikiform pula **Allowed domains** e a verificação humana. Todo envio passa a vir do IP do seu servidor, e o limite Standard de 5 por minuto por visitante conta todos os seus visitantes juntos. Escolha o [limite de taxa](https://ikiform.com/pt-br/docs/rate-limits.md) Relaxed ou Custom para um formulário que você envia por um servidor, e mantenha `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 `method` real e, onde envia direto ao Ikiform, um `action` real. Com scripts desligados o formulário ainda envia e recebe o `303`.
- O `name` de cada input é o `id` do campo na definição. Uma divergência, como `url` em vez de `page`, recebe `422` com `Unknown 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.fields` embaixo 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](https://ikiform.com/pt-br/docs/guides/nextjs.md), [Astro](https://ikiform.com/pt-br/docs/guides/astro.md), [SvelteKit](https://ikiform.com/pt-br/docs/guides/sveltekit.md), [Nuxt](https://ikiform.com/pt-br/docs/guides/nuxt.md), [React](https://ikiform.com/pt-br/docs/guides/react.md), [Vue](https://ikiform.com/pt-br/docs/guides/vue.md) e [a partir de um servidor](https://ikiform.com/pt-br/docs/guides/server.md).

## 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` |
| E-mail | `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](https://ikiform.com/pt-br/guides/html-form-submission/validation.md) 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-Type` você mesmo quando o corpo é `FormData`. O navegador define `multipart/form-data` com 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 pelo `Sec-Fetch-Mode` de 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 `FormData` ou `URLSearchParams` é uma requisição cross-site simples, então não há preflight. Um corpo JSON com `Content-Type: application/json` dispara um preflight antes; o Ikiform responde, mas custa uma ida e volta.
- `form.submit()` pula a validação e o evento `submit`. `form.requestSubmit()` roda os dois, como um clique faria.
- Passe o botão clicado como segundo argumento, `new FormData(form, event.submitter)`, se o `name` e o `value` dele importam.

O padrão completo, com estado de carregamento, foco e erros de rede, está em [sem recarregar a página](https://ikiform.com/pt-br/guides/html-form-submission/without-page-reload.md). A documentação de [JavaScript e fetch](https://ikiform.com/pt-br/docs/frameworks.md) 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ágina](https://ikiform.com/pt-br/guides/html-form-submission/without-page-reload.md): Envie 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 envio](https://ikiform.com/pt-br/guides/html-form-submission/redirect-after-submit.md): Mande 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 action](https://ikiform.com/pt-br/guides/html-form-submission/action-attribute.md): O 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ários](https://ikiform.com/pt-br/guides/html-form-submission/validation.md): Valide 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](https://ikiform.com/pt-br/pricing).

1. Em **Allowed domains** do formulário, adicione o domínio do seu site, mais `localhost` enquanto desenvolve. Deixe vazio para aceitar envios de qualquer lugar.
2. 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`.
3. Com o JavaScript ligado, abra o painel Network e envie de novo. A requisição para `/f/<formId>` deve responder `201` com JSON, e a página não deve recarregar.
4. 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.
5. Na página de agradecimento, aperte refresh. Nada deve ser reenviado, e nenhum segundo envio deve aparecer.
6. Abra **Submissions** no painel. O novo envio está lá com todos os campos que você enviou.
7. 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

### O que o atributo action faz num formulário HTML?

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

### Um formulário deve usar GET ou POST?

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.

### Por que recarregar a página envia meu formulário de novo?

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.

### Um formulário HTML pode enviar JSON?

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.

### O atributo required basta para validar um formulário?

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.

### Dá para enviar um formulário sem backend?

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.

### O formulário aceita upload de arquivos?

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.

## Mais guias

- [Sem recarregar a página](https://ikiform.com/pt-br/guides/html-form-submission/without-page-reload.md): Envie 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 envio](https://ikiform.com/pt-br/guides/html-form-submission/redirect-after-submit.md): Mande 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 action](https://ikiform.com/pt-br/guides/html-form-submission/action-attribute.md): O 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ários](https://ikiform.com/pt-br/guides/html-form-submission/validation.md): Valide 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.
- [Inscrição em eventos](https://ikiform.com/pt-br/guides/event-registration.md): Receba confirmações de presença na página do seu evento: formulário, limite de convidados, contagem honesta, encerramento ao lotar e lista de check-in, sem app de ingressos.
- [Verificar assinaturas de webhook](https://ikiform.com/pt-br/guides/webhook-signature-verification.md): Confira se um webhook de formulário veio mesmo do Ikiform: o esquema HMAC-SHA256 do Standard Webhooks, um vetor de teste, corpos brutos, timestamps e rotação de segredo.
- [Lista de espera para um novo SaaS](https://ikiform.com/pt-br/guides/waitlist.md): Monte uma lista de espera pré-lançamento na sua própria landing page: o formulário, proteção contra spam, duplicados, e-mails de confirmação e uma exportação limpa no dia do lançamento.
