Guias

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.

Preet SutharFundador, Ikiform

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.

O que o navegador faz entre o clique em enviar e a próxima páginano navegadorpela redeenviarbotão, teclaEnter ourequestSubmit()checagemderestriçõesrequired,type, pattern,min e maxform datasetsó controlescom name ehabilitadoscodificarurlencoded oumultipartmensagemno camponada éenviadoPOST parao actionSec-Fetch-Mode:navigateservidorresponde303 SeeOtherGET noLocationa página deagradecimentoválidoinválidosegueO que o navegador faz entre o clique em enviar e a próxima páginano navegadorpela redeenviarbotão, tecla Enterou requestSubmit()checagem derestriçõesrequired, type,pattern, min e maxform data setsó controles comname e habilitadoscodificarurlencoded oumultipartmensagemno camponada éenviadoPOST para oactionSec-Fetch-Mode:navigateservidorresponde303 See OtherGET noLocationa página deagradecimentoválidosegueinválido
  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.

AtributoPadrãoO que fazSobrescrita no botão
actionA URL da própria páginaA URL que recebe os dados. Valores relativos são resolvidos a partir da página. Veja o atributo actionformaction
methodgetget coloca os campos na query string da URL. post os envia como corpo. dialog fecha um <dialog> sem enviar nadaformmethod
enctypeapplication/x-www-form-urlencodedComo um corpo post é codificado. multipart/form-data é necessário para arquivos. text/plain serve para depuraçãoformenctype
novalidateDesligadaPula a checagem de restrições do navegador no envioformnovalidate
target_selfOnde a resposta é carregada. _blank abre uma nova aba, um iframe com nome mantém a página no lugarformtarget

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

AbordagemBom emContras
method="post" simples com redirecionamentoFunciona sem JavaScript, em qualquer criador de sites. O navegador cuida do estado de carregamento, do voltar e do refreshUm 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 FormDataSem recarregar. Você decide como ficam o sucesso e cada erro de campoO 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 SvelteKitSeu servidor vê o envio primeiro, e o mesmo formulário funciona antes e depois da hidrataçãoVocê 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ávelOs 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.

Responder a um POST com uma página comparado a responder com um redirecionamento 303responder com uma página (200)responder com redirecionamento (303)POST/report200 comHTML deagradecimentoa página veiode um POSTrefresh"Confirmarreenvio doformulário"POST denovoum duplicadoPOST/report303 SeeOtherLocation:/report-sentGET/report-sentrefreshrecarrega só oGETResponder a um POST com uma página comparado a responder com um redirecionamento 303responder com uma página (200)responder com redirecionamento (303)POST /report200 com HTML deagradecimentoa página veio de um POSTrefresh"Confirmar reenvio doformulário"POST de novoum duplicadoPOST /report303 See OtherLocation: /report-sentGET /report-sentrefreshrecarrega só o GET
  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.
StatusO que o navegador faz em seguidaUse para
303 See OtherGET no Location, corpo descartadoDepois de qualquer envio de formulário bem-sucedido
302 FoundNa prática os navegadores mudam para GET, mas a especificação permite manter o métodoServidores antigos. Prefira 303
307 e 308Repete o mesmo POST com o mesmo corpo na nova URLMover um endpoint, nunca uma página de agradecimento
200 com HTMLMostra a página; o refresh oferece enviar de novoMostrar o formulário de novo com erros
4xx com HTMLMostra a página de erroErros que o visitante corrige voltando
Post/Redirect/Get
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çãoResposta
Accept inclui application/jsonJSON
O corpo é JSONJSON
Sec-Fetch-Mode: navigate, que é todo envio simples de formulárioRedirecionamento ou página hospedada
Qualquer outro Sec-Fetch-Mode, como cors vindo de fetchJSON
Sem Sec-Fetch-Mode, e Accept inclui text/htmlRedirecionamento ou página hospedada
Qualquer outra coisa, como curlJSON

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.

O mesmo endpoint do Ikiform respondendo a um envio simples de formulário e a uma chamada fetchenvio de formulário simplesfetch, ou Accept: application/jsonnavegador navegaSec-Fetch-Mode:navigateIkiformmodo página303 para suapáginaou a página deagradecimento hospedadapágina de errohospedadaproblemas por rótulo docampo, status realscript enviaSec-Fetch-Mode: corsIkiformmodo JSON201 com o id{ ok: true, id }erro em JSONerror.fields por id docamporecusadorecusadoO mesmo endpoint do Ikiform respondendo a um envio simples de formulário e a uma chamada fetchenvio de formulário simplesfetch, ou Accept: application/jsonnavegadornavegaSec-Fetch-Mode:navigateIkiformmodo página303 para suapáginaou a página deagradecimentohospedadapáginade errohospedadaproblemaspor rótulodo campo,status realscript enviaSec-Fetch-Mode:corsIkiformmodo JSON201 com o id{ ok: true, id }erro emJSONerror.fieldspor id docamporecusadorecusado
  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 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.

422 em modo 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.

Prévia

Roda o validador do Ikiform no seu navegador. Nada é enviado.

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.

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

RegraNo navegadorNa definição de campos do Ikiform
Obrigatóriorequired"required": true
E-mailtype="email""type": "email", com allowed_domains opcional
URLtype="url""type": "url", com https_only opcional
Tamanhominlength, maxlengthvalidation.min_length, max_length
Só as opções listadasUm <select>, que um bot ignoraoptions; qualquer outra coisa é recusada
Sem campos extrasNão é possívelQualquer 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-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. 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.

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.

  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

ProblemaO que fazer
405 Method Not Allowed depois de enviarO 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_typeO 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_failedO 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 suaO _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 envioEle 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árioA 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 spamO 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

Travou em algum passo ou quer um guia para o seu caso? Envie um e-mail para hello@ikiform.com.