Meta tags no HTML: charset, viewport e o resto do head
O que cada meta tag faz de verdade: o charset que salva os acentos, o viewport que ajusta o celular e por onde entram o CSS e o JavaScript.
O <head> é a parte da página que ninguém vê e todo mundo sente. É lá que você
diz ao navegador como ler os acentos, como tratar a largura do celular, qual
texto vai na aba e onde estão o CSS e o JavaScript.
Todos os exemplos desta lição são da mesma página: a agenda de consultas da Clínica Veterinária Pata Amiga, uma clínica de bairro que atende cães e gatos. Cada afirmação aqui foi medida num Chromium 151 de verdade, controlado por script no Node, e a saída colada é a que apareceu no meu terminal.
Esses dados recebem o nome de metadados do documento. Em palavras simples, eles descrevem como a página deve ser interpretada e apresentada antes de a pessoa interagir com o conteúdo visível.
A ficha técnica entregue antes da vitrine
Uma loja pode ter produtos ótimos na vitrine, mas transportadora, catálogo e
fiscalização precisam primeiro de endereço, medidas e identificação corretos.
O head funciona como essa ficha técnica: charset, viewport, title, links
e metas orientam navegador e outros consumidores. O body continua sendo a
vitrine usada pela pessoa.
Antes do primeiro experimento, escolha uma meta e escreva duas previsões: o que deve aparecer no DOM e qual comportamento deve mudar. Depois confira o painel Elements e a medição correspondente. Essa dupla checagem evita avaliar metadado apenas pelo fato de a tag existir no arquivo.
Um documento HTML tem duas metades, e elas têm públicos diferentes. O <body> é
o que a pessoa lê. O <head> é um bilhete para o software: navegador, buscador,
WhatsApp, leitor de tela.
<!DOCTYPE html>
<html lang="pt-BR">
<head>
<meta charset="UTF-8">
<title>Clínica Veterinária Pata Amiga</title>
</head>
<body>
<h1>Clínica Veterinária Pata Amiga</h1>
<p>Consultas, vacinas e banho para cães e gatos na Vila Mariana.</p>
</body>
</html>Nada do que está dentro do <head> aparece na página. Se você escrever um
parágrafo ali, o navegador o empurra para o <body> na hora de montar a árvore
— o <head> só aceita metadado. A estrutura completa desse esqueleto está em
estrutura de uma página HTML; aqui a gente entra no
que vai dentro dele.
O navegador monta o head mesmo quando você não escreve ele
Antes de decorar as tags, vale ver uma coisa que surpreende quem está começando:
<head> e <body> são opcionais no arquivo. O parser inventa os dois.
Este arquivo não tem nenhum dos dois:
<!DOCTYPE html>
<html lang="pt-BR">
<title>Pata Amiga</title>
<meta charset="UTF-8">
<h1>Vacinação sem hora marcada</h1>
<meta name="viewport" content="width=device-width, initial-scale=1">
<p>Chegue e retire a senha.</p>Carreguei ele num Chromium de verdade, com o Playwright dirigindo o navegador
pelo Node, e pedi a lista de filhos de document.head e de document.body:
import { chromium, devices } from 'playwright-core';
import { pathToFileURL } from 'node:url';
const navegador = await chromium.launch();
const pagina = await navegador.newPage();
await pagina.goto(pathToFileURL('sem-head.html').href);
const r = await pagina.evaluate(() => ({
head: [...document.head.children].map((e) => e.outerHTML),
body: [...document.body.children].map((e) => e.outerHTML),
}));
console.log('document.head:');
r.head.forEach((l) => console.log(' ' + l));
console.log('document.body:');
r.body.forEach((l) => console.log(' ' + l));Leia com calma. O <title> e a primeira <meta> foram para o <head> sozinhos.
A segunda <meta> ficou no <body>, porque veio depois do <h1> — e o
<h1> é o que fecha o head e abre o body. O navegador decidiu a fronteira pela
primeira tag de conteúdo que encontrou.
Aquele navegador e aquela pagina das primeiras linhas são os mesmos de todos
os trechos daqui para a frente: abro o Chromium uma vez e reaproveito a aba em
cada teste, para os blocos não ficarem repetindo a abertura.
meta charset: quem decide como os bytes viram letras
O arquivo salvo no seu disco é uma sequência de números. charset é a tabela que
diz quais letras aqueles números representam. Sem essa linha, o navegador chuta.
E o chute depende de como a página chegou até ele. Testei a mesma página, com
acentos e sem <meta charset>, de duas maneiras: aberta direto do disco e
servida por um servidor HTTP que não manda charset no cabeçalho.
const casos = [
['sem charset, arquivo local', pathToFileURL('charset-sem.html').href],
['sem charset, servido ', 'http://localhost:3211/charset-sem.html'],
['com charset, servido ', 'http://localhost:3211/charset-com.html'],
];
for (const [rotulo, url] of casos) {
await pagina.goto(url);
const r = await pagina.evaluate(() => ({
cs: document.characterSet,
h1: document.querySelector('h1').textContent,
}));
console.log('%s %s %s', rotulo, r.cs.padEnd(13), r.h1);
}Essa é a linha do meio que arruína a tarde de muita gente. Abrindo o arquivo com
dois cliques, os acentos aparecem certos e você jura que está tudo bem. No ar,
num servidor de verdade, o mesmo arquivo vira Vacinação. É o clássico
“funciona na minha máquina”, só que de codificação.
A correção é uma linha, e ela é a primeira coisa dentro do head:
<head>
<meta charset="UTF-8">
<title>Clínica Veterinária Pata Amiga</title>
</head>Por que tão cedo? A especificação do HTML é explícita: a declaração precisa
caber inteira nos primeiros 1024 bytes do arquivo. Mas “a especificação
manda” e “o navegador desiste” são coisas diferentes, e eu fui medir onde o
Chromium realmente perde a linha. Gerei a mesma página com a meta charset
empurrada por um comentário cada vez maior dentro do head, e depois com ela
caída no meio do body:
import { writeFileSync } from 'node:fs';
const montar = (onde, bytes) => `<!DOCTYPE html>
<html lang="pt-BR">
<head>
${onde === 'head' ? ` <!-- ${'x'.repeat(bytes)} -->\n <meta charset="UTF-8">` : ''}
<title>Pata Amiga</title>
</head>
<body>
<h1>Vacinação de cães e gatos</h1>
${onde === 'body' ? ` <p>${'a'.repeat(bytes)}</p>\n <meta charset="UTF-8">` : ''}
</body>
</html>`;
const casos = [
// rótulo, onde a meta fica, quantos bytes vêm antes dela
['head, depois de 1 KB de comentário ', 'head', 1000],
['head, depois de 100 KB de comentário', 'head', 100000],
['body, depois de 500 bytes de texto ', 'body', 500],
['body, depois de 1 KB de texto ', 'body', 1000],
];
for (const [rotulo, onde, bytes] of casos) {
const nome = `teste-${onde}-${bytes}.html`;
writeFileSync(nome, montar(onde, bytes));
await pagina.goto('http://localhost:3211/' + nome);
const r = await pagina.evaluate(() => ({
cs: document.characterSet,
h1: document.querySelector('h1').textContent,
}));
console.log('%s %s %s', rotulo, r.cs.padEnd(13), r.h1);
}O resultado é mais generoso do que a regra dos 1024 bytes sugere — e é
justamente por isso que ele engana. Enquanto a meta charset está dentro do
head, o Chromium volta atrás e refaz a leitura do documento: cem mil bytes de
comentário na frente dela e os acentos ainda saem certos. O resgate só acaba
quando a declaração cai depois de conteúdo já montado: na última linha, com
1 KB de texto antes dela, o navegador já tinha se comprometido com
windows-1252 e não voltou mais.
Ou seja: o limite prático não é uma contagem de bytes que você vai ficar
medindo, é uma posição. meta charset na primeira linha do head, antes de
qualquer outra coisa. O diagnóstico completo de quando isso dá errado, com a
mensagem que o console cospe, está em
the character encoding of the HTML document was not declared.
viewport: 980 pixels de largura num celular de 393
Esta é a meta que separa um site que funciona no celular de um site que exige pinça e paciência. Sem ela, o navegador do celular finge que a tela é grande: monta a página numa área de 980 pixels e depois encolhe o desenho inteiro para caber na tela real.
Peguei a página da Pata Amiga em duas versões — uma com a meta, outra sem — e carreguei as duas num Chromium emulando um iPhone 15, cuja tela tem 393 pixels CSS de largura:
const iphone = devices['iPhone 15'];
const contexto = await navegador.newContext(iphone);
const pagina = await contexto.newPage();
console.log('tela do aparelho: %dpx CSS', iphone.viewport.width);
for (const arquivo of ['sem-viewport.html', 'com-viewport.html']) {
await pagina.goto(pathToFileURL(arquivo).href);
const largura = await pagina.evaluate(() => document.documentElement.clientWidth);
const zoom = iphone.viewport.width / largura;
console.log('%s -> layout %dpx | h1 de 32px aparece com %spx na tela',
arquivo.padEnd(17), largura, (32 * zoom).toFixed(1));
}Um título de 32 pixels chegando aos olhos com 12,8. É por isso que sites antigos, no celular, parecem uma miniatura de si mesmos. A linha que resolve:
<meta name="viewport" content="width=device-width, initial-scale=1">width=device-width diz “a largura de layout é a largura do aparelho”.
initial-scale=1 diz “comece sem zoom”. Você não precisa de mais nada aí dentro.
Sem viewport, a media query nem dispara
O estrago não para no tamanho da letra. Como o navegador acha que a página tem 980 pixels de largura, o CSS acha a mesma coisa — e todo o seu trabalho de responsividade fica desligado.
Montei a lista de serviços da clínica em flex, com uma media query que empilha
os itens em telas estreitas:
.servicos { display: flex; gap: 16px; }
@media (max-width: 600px) { .servicos { display: block; } }E medi, no mesmo iPhone emulado, se a regra casou:
for (const arquivo of ['mq-sem.html', 'mq-com.html']) {
await pagina.goto(pathToFileURL(arquivo).href);
const r = await pagina.evaluate(() => ({
bate: window.matchMedia('(max-width: 600px)').matches,
display: getComputedStyle(document.querySelector('.servicos')).display,
}));
console.log('%s max-width:600px casa? %s -> display: %s',
arquivo.padEnd(11), String(r.bate).padEnd(5), r.display);
}Sem a meta, matchMedia responde false num celular. A linha do CSS estava
certa o tempo todo; faltava o <head> avisar qual é a largura real. Se as suas
media queries não pegam no telefone e pegam quando você estreita a janela do
computador, é aqui que você começa a procurar.
title: um texto, quatro lugares
O <title> não é uma meta tag, mas mora no head e é o mais visível de todos. O
mesmo texto aparece na aba do navegador, no nome sugerido ao salvar nos
favoritos, na entrada do histórico e como o link azul do resultado de busca.
<title>Agendar consulta — Clínica Veterinária Pata Amiga</title>Sem ele, o campo simplesmente fica vazio:
for (const a of ['sem-title.html', 'pata-amiga.html']) {
await pagina.goto('http://localhost:3211/' + a);
console.log('%s document.title = %o', a.padEnd(19), await pagina.title());
}No arquivo sem <title>, o campo veio string vazia — e aí o navegador preenche a
aba sozinho, com o nome do arquivo ou a URL.
Duas regras práticas para escrever um bom title. Primeiro: o que a pessoa
procuraria vem na frente, porque a aba corta o fim (“Agendar consulta”, não
“Pata Amiga — Agendar consulta”). Segundo: cada página do site tem o seu. Um
site inteiro com <title>Home</title> repetido em vinte páginas é indistinguível
no histórico e no Google.
link rel=“stylesheet”: por onde o CSS entra
O CSS externo entra pelo head, com <link>. Essa tag não fecha — não existe
</link>.
<link rel="stylesheet" href="/estilo.css">O estilo.css da clínica tem duas linhas, o suficiente para a diferença
aparecer:
body { font-family: system-ui, sans-serif; background: #0f1115; color: #e8eaed; }
h1 { color: #4ade80; }Comparei a mesma página com e sem essa linha, olhando quantas folhas de estilo o
documento carregou e qual cor o <h1> acabou tendo:
for (const a of ['sem-css.html', 'com-css.html']) {
await pagina.goto('http://localhost:3211/' + a);
const r = await pagina.evaluate(() => {
const h1 = getComputedStyle(document.querySelector('h1'));
return { cor: h1.color, fonte: h1.fontFamily, folhas: document.styleSheets.length };
});
console.log('%s folhas=%d h1.color=%s font-family=%s',
a.padEnd(13), r.folhas, r.cor.padEnd(20), r.fonte);
}Sem a linha, zero folhas de estilo, título preto e a fonte serifada padrão do navegador. Com ela, o verde e a fonte do sistema.
rel="stylesheet" é obrigatório: é ele que diz o que aquele arquivo é. Apaguei
o atributo e registrei todas as requisições que a página fez:
const pedidos = [];
pagina.on('request', (req) => pedidos.push(req.url().split('/').pop()));
await pagina.goto('http://localhost:3211/sem-rel.html');
await pagina.waitForLoadState('load');
const r = await pagina.evaluate(() => ({
folhas: document.styleSheets.length,
cor: getComputedStyle(document.querySelector('h1')).color,
}));
console.log('pedidos feitos :', pedidos.join(', '));
console.log('folhas de estilo:', r.folhas, '| cor do h1:', r.cor);O estilo.css nem chegou a ser pedido. Sem rel, o navegador não sabe para que
serve aquele link e simplesmente o ignora — um bug silencioso, sem uma linha
sequer no console. A posição desse recurso dentro do documento fica mais clara
quando você revisa a estrutura do HTML.
script no head: o erro mais comum desta lição
Aqui está a armadilha que pega quase todo mundo na primeira página com
JavaScript. O arquivo agenda.js da clínica procura o botão de agendar:
const botao = document.querySelector('#agendar');
botao.addEventListener('click', () => {
alert('Consulta agendada!');
});E a página o carrega no head, do jeito que parece mais organizado:
<head>
<meta charset="UTF-8">
<title>Agendar consulta — Pata Amiga</title>
<script src="agenda.js"></script>
</head>
<body>
<h1>Agendar consulta</h1>
<button id="agendar">Agendar</button>
</body>Capturei os erros que o Chromium jogou no console ao carregar essa página:
pagina.on('pageerror', (e) => console.log(e.stack));
await pagina.goto('http://localhost:3211/script-no-head.html');
await pagina.waitForLoadState('load');O script rodou antes de o <button> existir. querySelector devolveu null,
e null não tem addEventListener. O botão está lá no arquivo, você o vê na
tela — mas na hora em que o script rodou, o parser ainda não tinha chegado nele.
A correção é uma palavra: defer. Ela manda o navegador baixar o script em
paralelo e só executar depois que o HTML inteiro estiver montado.
- <script src="agenda.js"></script>
+ <script src="agenda.js" defer></script>Rodando as duas versões lado a lado, com a mesma captura de erros:
for (const a of ['script-no-head.html', 'script-defer.html']) {
const erros = [];
const ouvir = (e) => erros.push(e.stack);
pagina.on('pageerror', ouvir);
await pagina.goto('http://localhost:3211/' + a);
await pagina.waitForLoadState('load');
console.log('== ' + a);
console.log(erros.length ? erros.join('\n') : '(console limpo)');
pagina.off('pageerror', ouvir);
}Colocar o <script> no fim do body, logo antes de </body>, resolve o mesmo
problema. defer no head é melhor porque o download começa mais cedo, enquanto
o HTML ainda está sendo lido. A diferença entre defer, async e nenhum dos
dois está detalhada em
script defer e async.
description, theme-color e as metas que só robô lê
O resto do head é um punhado de metas curtas que nunca mudam nada na tela. Elas falam com outros programas.
| meta | quem lê | o que faz |
|---|---|---|
description |
buscador | o texto cinza abaixo do link no Google |
theme-color |
Chrome no Android, Safari no iOS | pinta a barra do navegador com a sua cor |
robots |
buscador | noindex tira a página do índice |
author |
ninguém importante | metadado informativo, sem efeito prático |
keywords |
ninguém | ignorada pelo Google desde 2009 |
<meta name="description" content="Consulta, vacina e banho para cães e gatos na Vila Mariana. Agende online e escolha o horário em menos de um minuto.">
<meta name="theme-color" content="#0f1115">
<!-- só na página de agradecimento: tira ela da busca -->
<meta name="robots" content="noindex">A description não é fator de ranqueamento, mas é o texto que decide se a pessoa
clica no seu resultado ou no de baixo. Escreva entre 110 e 155 caracteres, e
escreva uma por página. A robots com noindex é para páginas que existem mas
não devem aparecer na busca — a de agradecimento depois do agendamento, por
exemplo. Já a prévia que aparece quando alguém cola o link no WhatsApp vem de
outra família de metas, as do
Open Graph.
O head que eu colo em todo projeto novo
Junte tudo e o head mínimo de um projeto sério cabe em oito linhas:
<!DOCTYPE html>
<html lang="pt-BR">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Agendar consulta — Clínica Veterinária Pata Amiga</title>
<meta name="description" content="Consulta, vacina e banho para cães e gatos na Vila Mariana. Agende online e escolha o horário em menos de um minuto.">
<meta name="theme-color" content="#0f1115">
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
<link rel="stylesheet" href="/estilo.css">
<script src="/agenda.js" defer></script>
</head>Repare no lang="pt-BR" lá no <html>, que não é meta tag e é tão importante
quanto: é ele que faz o leitor de tela pronunciar “vacinação” em português, e
não em inglês.
Carreguei essa página no iPhone emulado, com o servidor que não manda charset no cabeçalho, e conferi tudo de uma vez:
const erros = [];
pagina.on('pageerror', (e) => erros.push(e.message));
await pagina.goto('http://localhost:3211/pata-amiga.html');
await pagina.waitForLoadState('load');
const r = await pagina.evaluate(() => ({
layout: document.documentElement.clientWidth,
cs: document.characterSet,
titulo: document.title,
corH1: getComputedStyle(document.querySelector('h1')).color,
}));
console.log('largura de layout :', r.layout + 'px');
console.log('characterSet :', r.cs);
console.log('document.title :', r.titulo);
console.log('cor do h1 :', r.corH1);
console.log('erros no console :', erros.length);Um auditor de head em vinte linhas
Decorar essa lista é chato. Conferir com um script é rápido. Este arquivo lê um
.html e diz o que falta no head:
import { readFileSync } from 'node:fs';
import { JSDOM } from 'jsdom';
const arquivo = process.argv[2];
const { document: doc } = new JSDOM(readFileSync(arquivo, 'utf8')).window;
const meta = (n) => doc.querySelector(`head meta[name="${n}"]`)?.content;
const checagens = [
['charset', doc.querySelector('head meta[charset]')?.getAttribute('charset')],
['viewport', meta('viewport')],
['title', doc.querySelector('head title')?.textContent],
['description', meta('description')],
['lang no <html>', doc.documentElement.lang],
];
console.log(arquivo);
for (const [nome, valor] of checagens) {
const marca = valor ? ' ok ' : 'FALTA';
const extra = valor ? `${String(valor).length} caracteres` : '';
console.log(` ${marca} ${nome.padEnd(15)} ${extra}`);
}Rodando na página pronta e na primeira versão, aquela sem viewport:
node auditar-head.mjs pata-amiga.html
node auditar-head.mjs sem-viewport.htmlsem-viewport.html ok charset 5 caracteres FALTA viewport ok title 30 caracteres FALTA description ok lang no <html> 5 caracteres
Note que ele também mostra o tamanho de cada texto — útil para o title não passar dos 60 caracteres e a description ficar na faixa dos 150.
Por onde seguir
Com o head resolvido, o conteúdo é a próxima parada: títulos, parágrafos, links e imagens, que é o que a trilha de HTML cobre em seguida. Se você quiser o mapa inteiro antes de continuar, o guia completo de HTML mostra a ordem de estudo e onde cada assunto entra.
Perguntas frequentes
A ordem das tags dentro do head importa?
Preciso mesmo do viewport se meu site é simples?
Posso usar user-scalable=no para travar o zoom?
A meta keywords ainda serve para alguma coisa?
O que acontece se eu esquecer o title?
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 com Chromium 151.0.7922.34 (Playwright), e as saídas exibidas são as reais — como produzimos este conteúdo.
Fontes consultadas
- MDN — O elemento <meta> — developer.mozilla.org
- MDN — meta name="viewport" — developer.mozilla.org
- WHATWG HTML Standard — Specifying the document character encoding — html.spec.whatwg.org
- WHATWG HTML Standard — The head element — html.spec.whatwg.org


