Estrutura de uma página HTML: doctype, html, head e body
O esqueleto de um arquivo .html explicado linha por linha, e o que muda na página quando falta o doctype, o lang ou o próprio head.
Todo arquivo .html começa com o mesmo esqueleto: uma declaração de doctype, um
elemento html envolvendo tudo, um head com informação sobre a página e um
body com o que aparece na tela. São poucas linhas, sempre as mesmas, e cada uma
delas muda o comportamento do navegador de um jeito que dá para medir.
Os exemplos deste artigo montam o site da Escola de Música Alecrim, uma escola de bairro que dá aula de violão, piano e canto. Se você ainda não viu como uma tag vira elemento na tela, comece por o que é HTML.
Você não precisa tratar esse esqueleto como texto decorado. A gente vai chamar cada parte pelo nome correto e verificar no DOM o contrato que ela cumpre com o navegador.
A planta da página antes de colocar os móveis
Uma casa pode ter sofá e mesa sem uma planta bem organizada, mas depois fica
difícil saber onde estão a entrada, a parte elétrica e os cômodos. No documento
HTML, html delimita a construção, head reúne configurações que não viram
conteúdo visível e body recebe o que a pessoa usa. O nome técnico dessa
organização é estrutura do documento.
Antes de copiar o primeiro exemplo, aponte qual parte define o modo HTML, qual declara o idioma, qual configura os acentos e qual aparece na tela. Depois abra o arquivo e confira no DOM do DevTools. O navegador mostra a árvore que montou, então cada resposta pode ser verificada.
Este é o esqueleto completo. Salve como index.html e ele já abre no navegador:
<!DOCTYPE html>
<html lang="pt-BR">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Escola de Música Alecrim</title>
</head>
<body>
<h1>Escola de Música Alecrim</h1>
<p>Aulas de violão, piano e canto na Vila Madalena.</p>
</body>
</html>Antes de explicar linha por linha, vale perguntar ao próprio navegador o que ele
entendeu desse arquivo. O jsdom é uma implementação do parser de HTML que roda
no Node e segue a mesma especificação do Chrome:
import { JSDOM } from 'jsdom';
const dom = await JSDOM.fromFile('index.html');
const { document } = dom.window;
console.log('doctype declarado:', document.doctype?.name ?? '(nenhum)');
console.log('idioma do documento:', document.documentElement.lang);
console.log('codificação:', document.characterSet);
console.log('título da aba:', document.title);
console.log('filhos de <html>:', [...document.documentElement.children].map((e) => e.tagName).join(' + '));
console.log('filhos de <head>:', [...document.head.children].map((e) => e.tagName).join(', '));
console.log('filhos de <body>:', [...document.body.children].map((e) => e.tagName).join(', '));
console.log('texto que aparece na tela:', JSON.stringify(document.body.textContent.replace(/\s+/g, ' ').trim()));Repare no formato: o html tem exatamente dois filhos, sempre nessa ordem —
head e body. Tudo o mais que você escrever vai cair dentro de um dos dois. É
por isso que o esqueleto é sempre igual: ele não é enfeite, é a única forma
válida de um documento HTML.
<!DOCTYPE html>: a linha que desliga o modo quirks
Essa primeira linha não é uma tag. É uma declaração, herdada dos tempos em que existiam várias versões de HTML, e hoje ela faz uma coisa só: dizer ao navegador para usar as regras modernas de CSS. Sem ela, o navegador liga o modo quirks, um modo de compatibilidade com páginas dos anos 90.
Você consegue medir isso. Estes dois arquivos são idênticos, byte a byte, menos pela primeira linha:
<!DOCTYPE html>
<html lang="pt-BR">
<head>
<meta charset="UTF-8" />
<title>Escola de Música Alecrim</title>
<style>
.cartaz { width: 300; color: 00f; border: 1px solid }
</style>
</head>
<body>
<div class="cartaz">Matrículas abertas para a turma de violão</div>
<table><tr><td id="celula"><img src="nota.png" alt="" width="80" height="60" /></td></tr></table>
</body>
</html>O CSS aí dentro tem dois erros de propósito: width: 300 sem unidade e
color: 00f sem o #. Abrindo os dois arquivos no Chrome de verdade, com o
Playwright dirigindo:
import { chromium } from 'playwright-core';
import { pathToFileURL } from 'node:url';
const navegador = await chromium.launch({ channel: 'chrome' });
const aba = await navegador.newPage();
for (const arquivo of ['com-doctype.html', 'sem-doctype.html']) {
await aba.goto(pathToFileURL(arquivo).href);
const m = await aba.evaluate(() => {
const estilo = getComputedStyle(document.querySelector('.cartaz'));
return {
modo: document.compatMode,
largura: estilo.width,
cor: estilo.color,
celula: document.querySelector('#celula').offsetHeight,
};
});
console.log(arquivo);
console.log(' document.compatMode ..... ', m.modo);
console.log(' width: 300 (sem px) ..... ', m.largura);
console.log(' color: 00f (sem #) ...... ', m.cor);
console.log(' altura da célula da tabela', m.celula + 'px (a imagem tem 60px)');
}
await navegador.close();Três comportamentos diferentes no mesmo arquivo, decididos por uma linha:
CSS1Compaté o nome interno do modo padrão;BackCompaté o modo quirks. A propriedadedocument.compatModeé a forma mais rápida de conferir em qual dos dois a sua página está.- Sem doctype, o Chrome aceita
width: 300como 300 pixels. Com doctype, ele descarta a regra inválida, odivvolta a serautoe ocupa 1262px — a janela do teste tem 1280px de largura, menos os 8px de margem que o navegador dá aobodyde cada lado e os 2px da borda do própriodiv. - Sem doctype,
00fé aceito como cor e virargb(0, 0, 15)— quase preto, e não o azul que você esperava. Com doctype, a regra é ignorada e a cor fica no preto padrão. - A célula da tabela mede 66px no modo padrão e 62px no quirks. Esses 4px a mais são o espaço reservado embaixo da imagem para as descidas de letras como o “p” e o “g”. No modo padrão a imagem se alinha pela linha de base do texto; no quirks, não.
lang="pt-BR": quem realmente lê esse atributo
O atributo lang no html declara em que idioma a página está escrita. Muita
gente pula essa parte achando que é só documentação. Não é: o navegador usa esse
valor para tomar decisões de renderização.
A prova mais direta é a hifenização. Com hyphens: auto no CSS, o navegador
quebra palavras longas no fim da linha — mas só se souber de que idioma são,
porque cada idioma tem regras próprias de separação de sílabas. Salve este
arquivo como hifen.html:
<!DOCTYPE html>
<html lang="pt-BR">
<head>
<meta charset="UTF-8" />
<title>Escola de Música Alecrim</title>
<style>p { width: 50px; hyphens: auto; font: 16px/20px serif; margin: 0 }</style>
</head>
<body><p id="alvo">acompanhamento</p></body>
</html>A mesma página, com três valores diferentes de lang, medida no Chrome:
import { readFileSync } from 'node:fs';
const modelo = readFileSync('hifen.html', 'utf8');
const montar = (atributo) => modelo.replace('<html lang="pt-BR">', `<html ${atributo}>`);
const navegador = await chromium.launch({ channel: 'chrome' });
const aba = await navegador.newPage();
for (const atributo of ['lang="pt-BR"', 'lang="en"', '']) {
await aba.setContent(montar(atributo));
const m = await aba.evaluate(() => {
const p = document.querySelector('#alvo');
return {
linhas: p.offsetHeight / 20,
larguraDoTexto: p.scrollWidth,
casaComLangPtBr: p.matches(':lang(pt-BR)'),
};
});
console.log(`<html ${atributo}>`.replace(' >', '>'));
console.log(' linhas ocupadas pela palavra:', m.linhas);
console.log(' largura que o texto pediu:', m.larguraDoTexto + 'px (a coluna tem 50px)');
console.log(' o CSS :lang(pt-BR) pega esse parágrafo?', m.casaComLangPtBr ? 'sim' : 'não');
}
await navegador.close();Sem lang, o navegador não hifenizou nada: a palavra ficou numa linha só, pediu
113px numa coluna de 50px e vazou para fora do bloco. Com lang, ela coube.
E os dicionários de fato diferem: em português a mesma palavra ocupou três
linhas; em inglês, quatro.
Existe ainda o efeito que não dá para medir com código, e é o mais importante:
leitores de tela escolhem a voz e a pronúncia a partir do lang. Uma página em
português declarada como en é lida com sotaque de inglês, palavra por palavra,
e fica incompreensível.
head e body: o invisível e o visível
A divisão é essa, e ela é literal:
| onde | o que vai lá | quem consome |
|---|---|---|
head |
título da aba, codificação, viewport, folha de estilo, descrição | navegador, buscador, rede social |
body |
títulos, parágrafos, imagens, links, formulários | a pessoa que abriu a página |
Nada dentro do head é desenhado na tela. Repare na saída da primeira execução:
o body.textContent trouxe só “Escola de Música Alecrim Aulas de violão, piano e
canto na Vila Madalena.” — o <title>, que está no head, não apareceu ali,
apesar de ser texto. Ele vai para a aba do navegador, não para a página.
As duas meta do esqueleto são as que você não deve tirar. A charset avisa em
que codificação o arquivo foi salvo, e sem ela os acentos viram símbolos
estranhos — o assunto de
the character encoding of the HTML document was not declared.
A viewport faz o celular renderizar na largura real da tela em vez de fingir
que é um monitor. O resto do que cabe ali está em
meta tags no HTML.
O erro que o parser corrige sem avisar
O engano mais comum de quem está montando a primeira página é escrever no lugar
errado: um parágrafo dentro do head, uma meta dentro do body. Como o HTML
nunca reclama, o resultado é uma página silenciosamente diferente da que você
escreveu.
<!DOCTYPE html>
<html lang="pt-BR">
<head>
<meta charset="UTF-8" />
<title>Escola de Música Alecrim</title>
<p>Matrículas abertas até sexta</p>
</head>
<body>
<h1>Escola de Música Alecrim</h1>
<meta name="description" content="Aulas de violão, piano e canto" />
</body>
</html>await aba.goto(pathToFileURL('trocado.html').href);
console.log(await aba.evaluate(() => {
const onde = (sel) => document.querySelector(sel)?.parentElement.tagName ?? '(não existe)';
return [
'o <p> escrito dentro do head foi parar em: ' + onde('p'),
'o <meta name=description> escrito no body foi parar em: ' + onde('meta[name=description]'),
'filhos de <head>: ' + [...document.head.children].map((e) => e.tagName).join(', '),
'filhos de <body>: ' + [...document.body.children].map((e) => e.tagName).join(', '),
'o navegador enxerga a description? ' + (document.querySelector('head meta[name=description]') ? 'sim' : 'não'),
].join('\n');
}));Duas coisas quebraram de uma vez. O <p> que você pôs no head fechou o head
ali mesmo e virou o primeiro filho do body — na página, ele aparece acima do
h1, fora de ordem. E a description, que você achou que tinha declarado,
continua no body: quem procura por head meta[name="description"], que é o que
o Google e o WhatsApp fazem, não encontra nada.
A regra prática é curta: se é para uma pessoa ler na tela, vai no body; se é
informação sobre a página, vai no head. Não existe caso intermediário.
Pai, filho e irmão: o aninhamento é a estrutura
Tags ficam dentro de tags, e essa relação de dentro/fora é a única estrutura que o HTML tem. O vocabulário vem da árvore genealógica: quem está por fora é o pai, quem está por dentro é o filho, e quem divide o mesmo pai são irmãos.
<!DOCTYPE html>
<html lang="pt-BR">
<head>
<meta charset="UTF-8" />
<title>Turmas — Escola de Música Alecrim</title>
</head>
<body>
<h1>Turmas abertas</h1>
<ul>
<li>Violão iniciante — terças, 19h</li>
<li>Piano — sábados, 10h</li>
</ul>
</body>
</html>const { document } = (await JSDOM.fromFile('turmas.html')).window;
const desenhar = (no, nivel = 0) => {
for (const filho of no.children) {
console.log(' '.repeat(nivel) + '<' + filho.tagName.toLowerCase() + '>');
desenhar(filho, nivel + 1);
}
};
desenhar(document.documentElement);
const primeiroLi = document.querySelector('li');
console.log('---');
console.log('pai do primeiro <li>:', primeiroLi.parentElement.tagName);
console.log('avô do primeiro <li>:', primeiroLi.parentElement.parentElement.tagName);
console.log('irmão seguinte:', primeiroLi.nextElementSibling.textContent);Essa árvore desenhada pelo terminal é a mesma que você vê no inspetor do navegador, e é sobre ela que o CSS e o JavaScript trabalham depois. Vale a pena entender como ela é montada, tag por tag, em como o navegador monta a árvore DOM.
A indentação é só para você
O recuo de dois espaços que todo mundo usa não significa nada para o navegador. Ele existe para o olho humano enxergar o aninhamento. A prova é comparar a mesma lista escrita das duas formas:
const indentado = `<ul>
<li>Violão iniciante</li>
<li>Piano</li>
</ul>`;
const minificado = `<ul><li>Violão iniciante</li><li>Piano</li></ul>`;
for (const [rotulo, html] of [['indentado', indentado], ['minificado', minificado]]) {
const { document } = new JSDOM(html).window;
const ul = document.querySelector('ul');
console.log(rotulo);
console.log(' elementos dentro do <ul>:', ul.children.length);
console.log(' nós dentro do <ul> (contando texto):', ul.childNodes.length);
}Os dois têm os mesmos dois li. A diferença são os três nós de texto a mais na
versão indentada: as quebras de linha e os espaços entre as tags. Eles existem na
árvore, mas não mudam a estrutura, e é por isso que você pode indentar como
quiser.
O que o navegador escreve quando você não escreve nada
Aqui está a parte que confunde. Todo esse esqueleto é obrigatório — mas se você não digitar, o navegador digita por você. Este arquivo tem uma linha de texto e nenhuma tag:
Matrículas abertas até sextaAbrindo no Chrome e perguntando o que ele montou:
await aba.goto(pathToFileURL('solta.html').href);
console.log(await aba.evaluate(() => [
'árvore montada: ' + document.documentElement.outerHTML,
'document.doctype: ' + (document.doctype ? document.doctype.name : 'null'),
'document.compatMode: ' + document.compatMode,
'idioma declarado: ' + JSON.stringify(document.documentElement.lang),
'título da aba: ' + JSON.stringify(document.title),
].join('\n')));Três tags que você nunca digitou apareceram. O parser é obrigado pela
especificação a produzir uma árvore válida a partir de qualquer entrada, então
ele inventa html, head e body na hora.
Só que repare no que ele não inventou. document.doctype é null, o modo é
BackCompat, o idioma está vazio e o título da aba também. Doctype, lang e
title são exatamente as três coisas que ninguém preenche por você — e são as
três que decidem o modo de renderização, a leitura em voz alta e o nome que
aparece na aba e no resultado de busca.
Ou seja: escrever o esqueleto não é para o html e o body existirem. É para o
doctype, o lang e o title existirem.
Emmet: o esqueleto inteiro em duas teclas
Ninguém digita isso tudo na mão todo dia. O VS Code já vem com o Emmet, que
expande abreviações em HTML. Digite ! na primeira linha de um arquivo .html e
aperte Tab. O motor é o pacote emmet, o mesmo que o editor usa por dentro, e dá
para rodar ele direto no Node para ver a expansão exata:
import expandir from 'emmet';
const opcoes = { options: { 'output.indent': ' ' } };
console.log('--- "!" com a configuração de fábrica ---');
console.log(expandir('!', opcoes));
console.log('--- com emmet.variables: { "lang": "pt-BR" } ---');
console.log(expandir('!', { ...opcoes, variables: { lang: 'pt-BR' } }));</body> </html> — com emmet.variables: { “lang”: “pt-BR” } — <!DOCTYPE html> <html lang=“pt-BR”> <head> <meta charset=“UTF-8”> <meta name=“viewport” content=“width=device-width, initial-scale=1.0”> <title>Document</title> </head> <body>
</body> </html>
Duas coisas para levar daqui. A primeira: de fábrica, o Emmet gera
lang="en" — e é por isso que metade dos projetos brasileiros está declarada
como inglês. Corrija abrindo as configurações do VS Code e adicionando
"emmet.variables": { "lang": "pt-BR" }. A segunda: o <title> sai como
Document. Trocar esse texto é a primeira coisa a fazer depois de expandir,
porque é ele que vira o nome da aba e o título azul no Google.
O que vem depois
Com o esqueleto na mão, o próximo passo é preencher o body, e para isso você
precisa saber o que uma tag aceita além do nome: os atributos. Eles estão em
tags e atributos HTML, a próxima lição da
trilha de HTML. Se preferir ver o mapa completo antes de
seguir, o guia de HTML do zero mostra a ordem de estudo inteira
e onde cada assunto entra.
Prefere aprender em vídeo?
Tem uma aula sobre este assunto no nosso canal.
Perguntas frequentes
Preciso escrever DOCTYPE em maiúscula?
Um doctype antigo, do HTML 4, serve?
O que acontece se eu escrever dois body no mesmo arquivo?
Uso lang="pt-BR" ou lang="pt"?
O arquivo precisa se chamar index.html?
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 Chrome 151.0.7922.170 e Node 24.16.0 com jsdom 30.0.1, e as saídas exibidas são as reais — como produzimos este conteúdo.
Fontes consultadas
- WHATWG — HTML Standard: The html element — html.spec.whatwg.org
- WHATWG — Quirks Mode Standard — quirks.spec.whatwg.org
- MDN — Doctype — developer.mozilla.org



