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

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.

Rodolfo Mori11 min de leitura

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:

html
<!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:

js
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()));
doctype declarado: html idioma do documento: pt-BR codificação: UTF-8 título da aba: Escola de Música Alecrim filhos de <html>: HEAD + BODY filhos de <head>: META, META, TITLE filhos de <body>: H1, P texto que aparece na tela: "Escola de Música Alecrim Aulas de violão, piano e canto na Vila Madalena."

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:

html
<!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:

js
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();
com-doctype.html document.compatMode ..... CSS1Compat width: 300 (sem px) ..... 1262px color: 00f (sem #) ...... rgb(0, 0, 0) altura da célula da tabela 66px (a imagem tem 60px) sem-doctype.html document.compatMode ..... BackCompat width: 300 (sem px) ..... 300px color: 00f (sem #) ...... rgb(0, 0, 15) altura da célula da tabela 62px (a imagem tem 60px)

Três comportamentos diferentes no mesmo arquivo, decididos por uma linha:

  • CSS1Compat é o nome interno do modo padrão; BackCompat é o modo quirks. A propriedade document.compatMode é a forma mais rápida de conferir em qual dos dois a sua página está.
  • Sem doctype, o Chrome aceita width: 300 como 300 pixels. Com doctype, ele descarta a regra inválida, o div volta a ser auto e ocupa 1262px — a janela do teste tem 1280px de largura, menos os 8px de margem que o navegador dá ao body de cada lado e os 2px da borda do próprio div.
  • Sem doctype, 00f é aceito como cor e vira rgb(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:

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:

js
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();
<html lang="pt-BR"> linhas ocupadas pela palavra: 3 largura que o texto pediu: 50px (a coluna tem 50px) o CSS :lang(pt-BR) pega esse parágrafo? sim <html lang="en"> linhas ocupadas pela palavra: 4 largura que o texto pediu: 50px (a coluna tem 50px) o CSS :lang(pt-BR) pega esse parágrafo? não <html> linhas ocupadas pela palavra: 1 largura que o texto pediu: 113px (a coluna tem 50px) o CSS :lang(pt-BR) pega esse parágrafo? não

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.

html
<!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>
js
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');
}));
o <p> escrito dentro do head foi parar em: BODY o <meta name=description> escrito no body foi parar em: BODY filhos de <head>: META, TITLE filhos de <body>: P, H1, META o navegador enxerga a description? não

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.

html
<!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>
js
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);
<head> <meta> <title> <body> <h1> <ul> <li> <li> --- pai do primeiro <li>: UL avô do primeiro <li>: BODY irmão seguinte: Piano — sábados, 10h

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.

<html> <head> <body> <meta> <title> <h1> <ul> <li> <li>

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:

js
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);
}
indentado elementos dentro do <ul>: 2 nós dentro do <ul> (contando texto): 5 minificado elementos dentro do <ul>: 2 nós dentro do <ul> (contando texto): 2

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:

text
Matrículas abertas até sexta

Abrindo no Chrome e perguntando o que ele montou:

js
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')));
árvore montada: <html><head></head><body>Matrículas abertas até sexta </body></html> document.doctype: null document.compatMode: BackCompat idioma declarado: "" título da aba: ""

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:

js
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' } }));
--- "!" com a configuração de fábrica --- <!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Document</title> </head> <body>

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

Ver todos os vídeos do canal
  • html
  • doctype
  • head
  • body
  • estrutura

Perguntas frequentes

Preciso escrever DOCTYPE em maiúscula?
Não. Testado no Chrome 151, a versão minúscula produz exatamente o mesmo resultado: document.compatMode devolve CSS1Compat nos dois casos. A maiúscula é só convenção herdada do HTML 4, e a maior parte dos editores ainda gera assim.
Um doctype antigo, do HTML 4, serve?
Serve para abrir a página, mas não para o que interessa. Com <!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN"> o Chrome 151 devolve BackCompat, ou seja, modo quirks ligado. Se você abrir um projeto legado e o CSS estiver se comportando de um jeito estranho, essa é a primeira linha a conferir.
O que acontece se eu escrever dois body no mesmo arquivo?
O parser junta os dois num só. Com body id="a" e depois body class="b", o Chrome devolve um único body carregando id e class ao mesmo tempo, e o conteúdo dos dois vira irmão dentro dele. Nenhum erro aparece no console, o que torna o problema difícil de achar.
Uso lang="pt-BR" ou lang="pt"?
Prefira pt-BR quando o conteúdo for português do Brasil. Os dois ativam a hifenização, mas o seletor :lang(pt-BR) só casa com pt-BR, enquanto :lang(pt) casa com os dois. Declarar a região deixa a porta aberta para diferenciar pt-BR de pt-PT mais tarde, sem custo nenhum hoje.
O arquivo precisa se chamar index.html?
Para abrir com dois cliques na sua máquina, não: qualquer nome com extensão .html funciona. O nome index.html importa quando a página vai para um servidor, porque é o arquivo que ele entrega quando alguém pede a pasta sem citar arquivo nenhum.

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

  1. WHATWG — HTML Standard: The html element — html.spec.whatwg.org
  2. WHATWG — Quirks Mode Standard — quirks.spec.whatwg.org
  3. MDN — Doctype — developer.mozilla.org

Continue por aqui