Pular para o conteúdo
Cursos

DevClub

LógicaFront-endBack-endMobile

IA Club

IA na prática
Estudar programaçãoEstudar IA
LiçãoIniciantecódigo testado

Formulário em HTML: form, label e o que o navegador envia

Como montar um formulário que funciona sem JavaScript: action, method, name, label ligado ao campo e o botão que de fato envia os dados.

Rodolfo Mori12 min de leitura

Formulário é a parte do HTML que fala de volta com o servidor. Você escreve <form>, coloca campos com name dentro e um botão de enviar: o navegador empacota tudo e manda, sem uma linha de JavaScript.

O exemplo deste artigo inteiro é o agendamento de consulta da Clínica Pata Amiga: quem agenda é a tutora Ana Prado, e a paciente é a Nina. Todo bloco de saída aqui embaixo saiu de um Chromium 151 de verdade enviando o formulário para um servidor Node que imprime o que chegou.

Os nomes técnicos que governam o envio são action, method, enctype e successful controls, os controles que realmente entram na requisição. Em palavras simples, o navegador monta um pacote com destino, forma de envio e pares de nome e valor.

O pacote que o navegador monta para você

Pense na ficha da clínica como um pacote de documentos. O action é o endereço da entrega, o method escolhe como o pacote viaja e cada name funciona como a etiqueta de uma informação. Um campo sem name pode estar preenchido na tela, mas chega ao servidor como uma folha sem identificação: o navegador não o inclui entre os controles enviados.

Antes de clicar no primeiro formulário, escreva a URL, o método e os pares nome=valor que você espera receber. Envie e compare com o espelho do servidor. Essa conferência transforma um comportamento automático num contrato HTTP que você consegue prever e depurar.

São três peças: a tag form, os campos com name e um botão.

html
<form action="/agendar" method="post">
  <label for="tutor">Nome do tutor</label>
  <input type="text" id="tutor" name="tutor">

  <label for="pet">Nome do pet</label>
  <input type="text" id="pet" name="pet">

  <label for="obs">Observações</label>
  <textarea id="obs" name="obs"></textarea>

  <button>Agendar consulta</button>
</form>

Nenhum atributo aí é decoração:

atributo fica em decide
action form para qual endereço vai
method form se os dados vão na URL ou no corpo
name cada campo o nome da chave que o servidor recebe
for / id label / campo qual texto pertence a qual campo

O espelho: um servidor que imprime o que chegou

Para enxergar o envio, o melhor investimento é um servidor de dez linhas que não faz nada além de imprimir a requisição. Ele também entrega os arquivos .html do teste, para o formulário rodar num endereço real e não em file://.

js
import { createServer } from 'node:http';
import { readFileSync } from 'node:fs';

createServer((req, res) => {
  let corpo = '';
  req.on('data', (pedaco) => (corpo += pedaco));
  req.on('end', () => {
    if (req.url.endsWith('.html')) {
      res.setHeader('content-type', 'text/html; charset=utf-8');
      return res.end(readFileSync('.' + req.url));
    }
    console.log('método :', req.method);
    console.log('url    :', req.url);
    console.log('tipo   :', req.headers['content-type'] ?? '(nenhum)');
    console.log('corpo  :', corpo || '(vazio)');
    res.end('Consulta agendada.');
  });
}).listen(4400, () => console.log('espelho ouvindo em http://localhost:4400'));
espelho ouvindo em http://localhost:4400

Se essas linhas parecerem mágica, elas estão destrinchadas em como criar um servidor HTTP no Node. Aqui elas são só o espelho. Abrindo http://localhost:4400/agendamento.html, preenchendo “Ana Prado” e “Nina” e clicando no botão, é isto que o terminal do servidor mostra:

bash
node espelho.mjs
método : POST url : /agendar tipo : application/x-www-form-urlencoded corpo : tutor=Ana+Prado&pet=Nina&obs=mancando+da+pata+traseira

Três coisas para guardar dessa saída: o método veio do method, o endereço veio do action, e as chaves tutor, pet e obs vieram dos atributos name.

method="get": os dados viram query string

Troque uma palavra no markup e o envio muda de forma:

html
<form action="/agendar" method="get">
  <input type="text" id="tutor" name="tutor">
  <input type="text" id="pet" name="pet">
  <textarea id="obs" name="obs"></textarea>
  <button>Agendar consulta</button>
</form>
método : GET url : /agendar?tutor=Ana+Prado&pet=Nina&obs=mancando+da+pata+traseira tipo : (nenhum) corpo : (vazio)

Os mesmos dados, agora colados na URL depois de um ?, separados por &. O corpo está vazio e não existe content-type nenhum — não há corpo para descrever.

E se você esquecer o method? O navegador assume GET:

html
<form action="/agendar">
  <input type="text" id="tutor" name="tutor">
  <input type="text" id="pet" name="pet">
  <button>Agendar consulta</button>
</form>
método : GET url : /agendar?tutor=Ana+Prado&pet=Nina tipo : (nenhum) corpo : (vazio)

Por isso tanto formulário de iniciante expõe senha na barra de endereço: ninguém escolheu GET, ninguém escreveu method.

method="post": os dados viram corpo

Com POST, a URL fica limpa e os dados viajam no corpo da requisição, com um content-type avisando o formato:

método : POST url : /agendar tipo : application/x-www-form-urlencoded corpo : tutor=Ana+Prado&pet=Nina&obs=mancando+da+pata+traseira

A regra prática para escolher:

use quando porque
get busca, filtro, listagem a URL vira link compartilhável e recarregável
post cadastro, login, agendamento não fica no histórico nem no log do servidor

O que o action leva embora quando o método é GET

Este detalhe derruba gente experiente. Se o action já tiver uma query string, GET e POST tratam ela de formas opostas.

html
<form action="/agendar?origem=instagram" method="get">
  <input type="text" id="tutor" name="tutor">
  <button>Agendar consulta</button>
</form>
método : GET url : /agendar?tutor=Ana+Prado tipo : (nenhum) corpo : (vazio)

O origem=instagram sumiu. Num envio GET o navegador substitui a query string inteira do action pelos dados do formulário. Trocando só o método para POST, o mesmo action:

método : POST url : /agendar?origem=instagram tipo : application/x-www-form-urlencoded corpo : tutor=Ana+Prado

Agora origem=instagram sobreviveu, porque os dados foram para o corpo e não disputaram espaço com a URL. Se você precisa daquele parâmetro num formulário GET, ele tem que virar campo: <input type="hidden" name="origem" value="instagram">.

Sem name, o campo não existe para o navegador

Volte ao primeiro formulário e apague um único atributo: o name do textarea.

html
<form action="/agendar" method="post">
  <label for="tutor">Nome do tutor</label>
  <input type="text" id="tutor" name="tutor">

  <label for="pet">Nome do pet</label>
  <input type="text" id="pet" name="pet">

  <label for="obs">Observações</label>
  <textarea id="obs"></textarea>

  <button>Agendar consulta</button>
</form>

Preenchendo os três campos normalmente, com “mancando da pata traseira” nas observações:

método : POST url : /agendar tipo : application/x-www-form-urlencoded corpo : tutor=Ana+Prado&pet=Nina

O texto foi digitado, aparece na tela e simplesmente não foi enviado. Sem aviso, sem erro no console, sem nada. É o bug de formulário mais comum que existe e o mais chato de achar, porque a tela está certa.

id e name parecem sinônimos e não são. O id serve para o CSS, para o JavaScript e para o label; ele nunca sai do navegador. O name é a chave que viaja. Um campo pode ter os dois, e no dia a dia costuma ter.

label e for: o clique que aumenta a área do campo

label não é um texto qualquer perto do campo. Quando ele está ligado ao controle, o clique no texto vale como clique no campo. Há duas formas de ligar:

html
<label for="tutor">Nome do tutor</label>
<input type="text" id="tutor" name="tutor">

<label>Nome do pet
  <input type="text" id="pet" name="pet">
</label>

<label for="telefone">Telefone</label>
<input type="tel" id="fone" name="telefone">

<label for="lembrete">Quero lembrete no WhatsApp</label>
<input type="checkbox" id="lembrete" name="lembrete" value="sim">

Todo label tem uma propriedade control, que é o campo que ele comanda. Perguntando ao navegador, um por um:

js
[...document.querySelectorAll('label')].map((l) => ({
  texto: l.textContent.trim().split('\n')[0],
  controla: l.control ? l.control.id : null,
}));
[ { texto: 'Nome do tutor', controla: 'tutor' }, { texto: 'Nome do pet', controla: 'pet' }, { texto: 'Telefone', controla: null }, { texto: 'Quero lembrete no WhatsApp', controla: 'lembrete' } ]

Os dois primeiros funcionam, cada um por uma via: o primeiro pelo par for/id, o segundo por envolver o campo. O terceiro está solto: o for vale telefone, mas o id do campo é fone — quem vale telefone ali é o name. Esse erro nasce sempre da mesma confusão: for casa com id, nunca com name.

E a ligação não é enfeite. Clicando nos textos e olhando o que o navegador faz depois de cada clique:

foco depois de clicar em "Nome do tutor": tutor foco depois de clicar em "Telefone" : checkbox antes : false checkbox depois: true

O clique no primeiro texto focou o campo tutor. O clique no texto “Telefone” não focou nada — document.activeElement continua sendo o body, que não tem id, por isso a linha sai vazia. E o clique no texto do checkbox marcou o checkbox. Numa tela de celular isso é a diferença entre acertar um alvo de 16 pixels e acertar uma frase inteira — e é também o que um leitor de tela anuncia quando o campo recebe foco. O assunto continua em acessibilidade em HTML.

Todo button dentro de form é um botão de enviar

Esta é a pegadinha que faz a página “recarregar sozinha”. Um button sem type dentro de um formulário vale type="submit" — inclusive quando o texto dele diz outra coisa.

html
<form action="/agendar" method="get">
  <input type="text" id="tutor" name="tutor">
  <button id="a">Agendar consulta</button>
  <button id="b" type="button">Limpar</button>
  <input id="c" type="submit" value="Agendar consulta">
  <input id="d" type="reset" value="Limpar">
</form>
js
['a', 'b', 'c', 'd'].map((id) => {
  const el = document.getElementById(id);
  return { markup: el.outerHTML.slice(0, 34), type: el.type };
});
[ { markup: '<button id="a">Agendar consulta</b', type: 'submit' }, { markup: '<button id="b" type="button">Limpa', type: 'button' }, { markup: '<input id="c" type="submit" value=', type: 'submit' }, { markup: '<input id="d" type="reset" value="', type: 'reset' } ]

O botão a não tem type escrito e mesmo assim o navegador diz submit. Agora o efeito prático, num arquivo limpar.html com um botão “Limpar” que ninguém marcou como type="button":

html
<form action="/agendar" method="get">
  <input type="text" id="tutor" name="tutor">
  <button>Limpar</button>
</form>
url depois de clicar em "Limpar": http://localhost:4400/agendar?tutor=Ana+Prado

Clicar em “Limpar” enviou o formulário: a página saiu de limpar.html e foi parar em /agendar, com o campo pendurado na URL. Acrescente type="button" ao mesmo botão e a URL não sai do lugar:

url depois de clicar em "Limpar" (type="button"): http://localhost:4400/limpar.html

Quando o envio já é feito por JavaScript, a página ainda assim tenta recarregar — quem segura isso é o preventDefault no evento de submit.

fieldset, legend e o desligamento em bloco

fieldset agrupa campos que pertencem ao mesmo assunto, e legend dá título ao grupo. Além do visual, o fieldset tem um poder que nenhuma div tem: com o atributo disabled, ele desliga tudo que está dentro dele de uma vez.

html
<form action="/agendar" method="post">
  <fieldset>
    <legend>Sobre o tutor</legend>
    <label for="tutor">Nome completo</label>
    <input type="text" id="tutor" name="tutor">
  </fieldset>

  <fieldset>
    <legend>Serviços</legend>
    <label for="s1">Consulta</label>
    <input type="checkbox" id="s1" name="servico" value="consulta">
    <label for="s2">Vacina</label>
    <input type="checkbox" id="s2" name="servico" value="vacina">
    <label for="s3">Banho</label>
    <input type="checkbox" id="s3" name="servico" value="banho">
  </fieldset>

  <fieldset disabled>
    <legend>Retorno (só para quem já é cliente)</legend>
    <label for="prontuario">Número do prontuário</label>
    <input type="text" id="prontuario" name="prontuario" value="4471">
  </fieldset>

  <button>Agendar consulta</button>
</form>

Antes de enviar, pergunte ao navegador o que ele acha do campo que está lá dentro. A resposta tem uma armadilha:

js
const campo = document.getElementById('prontuario');

console.log('campo.disabled            :', campo.disabled);
console.log('campo.matches(":disabled"):', campo.matches(':disabled'));
console.log('fieldset.disabled         :', campo.closest('fieldset').disabled);
campo.disabled : false campo.matches(":disabled"): true fieldset.disabled : true

campo.disabled diz false e o campo está desligado assim mesmo. A propriedade disabled só espelha o atributo escrito naquele elemento, e ninguém escreveu disabled no input — quem tem o atributo é o fieldset. O estado que vale de verdade é o que o seletor :disabled enxerga, e esse diz true. Se você já escreveu if (campo.disabled) num formulário com fieldset e o if nunca entrou, era exatamente isto.

Marcando “Consulta” e “Vacina” e enviando:

método : POST url : /agendar tipo : application/x-www-form-urlencoded corpo : tutor=Ana+Prado&servico=consulta&servico=vacina

O prontuario tinha value="4471" escrito no HTML e não foi enviado, porque campo desligado fica de fora do pacote — e nem precisou de disabled nele, o fieldset do lado de fora bastou. E servico apareceu duas vezes, uma por checkbox marcado. O “banho”, que ficou desmarcado, não aparece: checkbox desmarcado não manda chave nenhuma, nem com valor vazio.

Sobre qual campo usar para cada tipo de dado — data, e-mail, telefone, número — o assunto tem lição própria em tipos de input HTML.

Acento, espaço e o campo que aparece duas vezes

O formato application/x-www-form-urlencoded não aceita qualquer caractere. O navegador codifica antes de mandar. Enviando “Ana Praça” no nome e “Nina está mancando & com febre” nas observações:

método : POST url : /agendar tipo : application/x-www-form-urlencoded corpo : tutor=Ana+Pra%C3%A7a&obs=Nina+est%C3%A1+mancando+%26+com+febre

Espaço virou +, o ç virou %C3%A7, o á virou %C3%A1 e o & virou %26 — se ele fosse literal, seria confundido com o separador de campos. Isso não é problema seu para resolver na mão: quem lê do outro lado desfaz tudo.

js
const corpo = 'tutor=Ana+Pra%C3%A7a&servico=consulta&servico=vacina';
const dados = new URLSearchParams(corpo);

console.log('tutor          :', dados.get('tutor'));
console.log('servico (get)  :', dados.get('servico'));
console.log('servico (getAll):', dados.getAll('servico'));
console.log('fromEntries    :', Object.fromEntries(dados));
tutor : Ana Praça servico (get) : consulta servico (getAll): [ 'consulta', 'vacina' ] fromEntries : { tutor: 'Ana Praça', servico: 'vacina' }

Guarde a última linha. Object.fromEntries é o atalho preferido de quem está começando e ele perde dados em silêncio: das duas escolhas de serviço, sobrou “vacina”. Para chave que pode repetir, é getAll. No Express, essa distinção reaparece em req.params, req.query e req.body.

Tem um detalhe que quase ninguém liga a formulário: a codificação declarada na página não muda só como o texto aparece na tela — ela decide em que codificação o formulário é enviado. Duas fontes declaram isso, e o cabeçalho HTTP ganha do <meta>. O espelho aqui de cima manda charset=utf-8 no cabeçalho, então apagar o <meta charset="UTF-8"> do HTML não muda nada: a página continua em UTF-8. Para ver o estrago é preciso calar as duas — tirar a linha do HTML e tirar o charset do cabeçalho:

js
res.setHeader('content-type', 'text/html'); // sem charset: ninguém declarou nada

Sem ninguém declarando, o Chromium adivinha pelo conteúdo da página:

js
console.log('encoding do documento:', document.characterSet);
encoding do documento: windows-1252

E o mesmo “Ana Praça” chegou no servidor assim:

corpo : tutor=Ana+Pra%E7a

%E7 em vez de %C3%A7. É a origem daquele “Ana Praça” que aparece no banco de dados semanas depois, e é o mesmo problema descrito em the character encoding of the HTML document was not declared.

enctype: quando o corpo deixa de ser query string

Para enviar arquivo, a query string não serve — ela é texto. Aí entra o enctype:

html
<form action="/agendar" method="post" enctype="multipart/form-data">
  <input type="text" id="tutor" name="tutor">
  <input type="file" id="exame" name="exame">
  <button>Agendar consulta</button>
</form>

Anexando um exame.txt de duas linhas, o corpo que chega no espelho é outro animal:

tipo : multipart/form-data; boundary=----WebKitFormBoundaryquKOxPq4c0yJB4GO corpo : ------WebKitFormBoundaryquKOxPq4c0yJB4GO Content-Disposition: form-data; name="tutor"

Ana Prado ——WebKitFormBoundaryquKOxPq4c0yJB4GO Content-Disposition: form-data; name=“exame”; filename=“exame.txt” Content-Type: text/plain

Hemograma de Nina leucocitos: 12.400

——WebKitFormBoundaryquKOxPq4c0yJB4GO–

Cada campo virou um bloco separado por uma linha de fronteira sorteada pelo navegador, e o do arquivo trouxe nome do arquivo e tipo junto. Agora tire o enctype e mande o mesmo arquivo:

método : POST url : /agendar tipo : application/x-www-form-urlencoded corpo : tutor=Ana+Prado&exame=exame.txt

Só o nome do arquivo chegou. O conteúdo nunca saiu do computador de quem preencheu. Formulário com input type="file" precisa de enctype="multipart/form-data" e method="post" — não há jeito de subir arquivo por GET.

O formulário que parece não fazer nada

Quando o botão não faz absolutamente nada, três causas cobrem quase todos os casos. Este markup tem duas delas de propósito:

html
<form id="agendamento" action="/agendar" method="post">
  <input type="text" id="tutor" name="tutor">
</form>
<button id="fora">Agendar consulta</button>

<form action="/newsletter" method="post">
  <input type="email" id="email" name="email">
  <form action="/agendar" method="post">
    <input type="text" id="pet" name="pet">
  </form>
</form>

Comparando o arquivo que saiu do servidor com a árvore que o navegador montou:

js
const fonte = await (await fetch(location.href)).text();

console.log({
  formulariosNoHtml: fonte.split('<form').length - 1,
  formulariosNoDom: document.querySelectorAll('form').length,
  botaoPertenceA: document.getElementById('fora').form,
  petPertenceA: document.getElementById('pet').form?.getAttribute('action'),
});
{ formulariosNoHtml: 3, formulariosNoDom: 2, botaoPertenceA: null, petPertenceA: '/newsletter' }

O botão está fora do form, então ele não pertence a formulário nenhum e não tem o que enviar. E o form aninhado desapareceu: escrevi três, o navegador montou dois. Formulário dentro de formulário não existe no HTML, o parser descarta o de dentro e adota os campos dele para o de fora — por isso o campo pet acabou pertencendo ao /newsletter.

A terceira causa é o name esquecido, que já vimos: o formulário envia, o servidor responde, e o campo só não está lá.

Para o botão fora do form, a correção não exige mudar o layout. O atributo form, apontando para o id do formulário, liga qualquer controle a ele de qualquer lugar da página:

html
<form id="agendamento" action="/agendar" method="post">
  <input type="text" id="tutor" name="tutor">
</form>

<input type="text" id="pet" name="pet" form="agendamento">
<button id="fora" form="agendamento">Agendar consulta</button>
botão pertence a: agendamento método : POST url : /agendar tipo : application/x-www-form-urlencoded corpo : tutor=Ana+Prado&pet=Nina

O campo e o botão moram fora das tags <form> e mesmo assim os dois foram enviados juntos.

O que vem depois

Você já consegue montar um formulário que envia. O passo seguinte é ele recusar lixo antes de enviar: required, type, minlength e pattern fazem essa barreira sem JavaScript nenhum, e é o assunto de validação de formulário sem JavaScript. Depois disso, quando a validação precisa reagir enquanto a pessoa digita, entra a validação com JavaScript.

O mapa inteiro da trilha, com a ordem de estudo, está no guia de HTML do zero.

Prefere aprender em vídeo?

Tem uma aula sobre este assunto no nosso canal.

Ver todos os vídeos do canal
  • html
  • formulario
  • form
  • label
  • post

Perguntas frequentes

Preciso de JavaScript para um formulário funcionar?
Não. A tag form já sabe empacotar os campos e mandar para o servidor sozinha. O JavaScript entra quando você quer enviar sem recarregar a página, mostrar erro em tempo real ou montar campos dinamicamente.
O que acontece se eu não escrever o atributo action?
O navegador envia para a URL da própria página. Testei com um formulário sem action em /sem-action.html e o envio voltou para http://localhost:4400/sem-action.html — a página recarrega, os campos esvaziam e parece que nada aconteceu.
Dá para ter dois botões de envio no mesmo formulário?
Dá. Coloque name e value em cada botão. Só o botão realmente clicado entra nos dados: com dois botões name="acao", clicar em "Confirmar" mandou tutor=Ana+Prado&acao=confirmar, sem sinal do outro botão.
Posso repetir o mesmo name em vários campos?
Pode, e é assim que grupos de checkbox funcionam. A chave aparece uma vez por campo marcado. No servidor, leia com getAll — usar get ou Object.fromEntries devolve só um dos valores e joga o resto fora.

Dúvidas e comentários

Travou em algum passo? Pergunte aqui — a equipe e outros alunos respondem.

Todo o código deste artigo foi executado em Node 24.16.0 e Chromium 151.0.7922.34 (Playwright), macOS, e as saídas exibidas são as reais — como produzimos este conteúdo.

Fontes consultadas

  1. MDN — Sua primeira página com formulário HTML — developer.mozilla.org
  2. HTML Standard — 4.10 Forms — html.spec.whatwg.org

Continue por aqui