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.
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.
<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://.
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'));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:
node espelho.mjsTrê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:
<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>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:
<form action="/agendar">
<input type="text" id="tutor" name="tutor">
<input type="text" id="pet" name="pet">
<button>Agendar consulta</button>
</form>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:
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.
<form action="/agendar?origem=instagram" method="get">
<input type="text" id="tutor" name="tutor">
<button>Agendar consulta</button>
</form>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:
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.
<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:
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:
<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:
[...document.querySelectorAll('label')].map((l) => ({
texto: l.textContent.trim().split('\n')[0],
controla: l.control ? l.control.id : null,
}));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:
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.
<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>['a', 'b', 'c', 'd'].map((id) => {
const el = document.getElementById(id);
return { markup: el.outerHTML.slice(0, 34), type: el.type };
});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":
<form action="/agendar" method="get">
<input type="text" id="tutor" name="tutor">
<button>Limpar</button>
</form>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:
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.
<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:
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 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:
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:
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.
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));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:
res.setHeader('content-type', 'text/html'); // sem charset: ninguém declarou nadaSem ninguém declarando, o Chromium adivinha pelo conteúdo da página:
console.log('encoding do documento:', document.characterSet);E o mesmo “Ana Praça” chegou no servidor assim:
%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:
<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:
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:
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:
<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:
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'),
});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:
<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>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.
Perguntas frequentes
Preciso de JavaScript para um formulário funcionar?
O que acontece se eu não escrever o atributo action?
Dá para ter dois botões de envio no mesmo formulário?
Posso repetir o mesmo name em vários campos?
Dúvidas e comentários
Travou em algum passo? Pergunte aqui — a equipe e outros alunos respondem.
Entrar para perguntarÉ o mesmo login gratuito dos cursos.
Nenhuma dúvida por aqui ainda — a primeira pode ser a sua.
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
- MDN — Sua primeira página com formulário HTML — developer.mozilla.org
- HTML Standard — 4.10 Forms — html.spec.whatwg.org



