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

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.

Rodolfo Mori12 min de leitura

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.

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

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

js
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));
document.head: <title>Pata Amiga</title> <meta charset="UTF-8"> document.body: <h1>Vacinação sem hora marcada</h1> <meta name="viewport" content="width=device-width, initial-scale=1"> <p>Chegue e retire a senha.</p>

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.

js
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);
}
sem charset, arquivo local UTF-8 Vacinação de cães e gatos sem charset, servido windows-1252 Vacinação de cães e gatos com charset, servido UTF-8 Vacinação de cães e gatos

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:

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

js
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);
}
head, depois de 1 KB de comentário UTF-8 Vacinação de cães e gatos head, depois de 100 KB de comentário UTF-8 Vacinação de cães e gatos body, depois de 500 bytes de texto UTF-8 Vacinação de cães e gatos body, depois de 1 KB de texto windows-1252 Vacinação de cães e gatos

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:

js
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));
}
tela do aparelho: 393px CSS sem-viewport.html -> layout 980px | h1 de 32px aparece com 12.8px na tela com-viewport.html -> layout 393px | h1 de 32px aparece com 32.0px na tela

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:

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

css
.servicos { display: flex; gap: 16px; }
@media (max-width: 600px) { .servicos { display: block; } }

E medi, no mesmo iPhone emulado, se a regra casou:

js
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);
}
mq-sem.html max-width:600px casa? false -> display: flex mq-com.html max-width:600px casa? true -> display: block

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.

html
<title>Agendar consulta — Clínica Veterinária Pata Amiga</title>

Sem ele, o campo simplesmente fica vazio:

js
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());
}
sem-title.html document.title = '' pata-amiga.html document.title = 'Agendar consulta — Clínica Veterinária Pata Amiga'

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.

O CSS externo entra pelo head, com <link>. Essa tag não fecha — não existe </link>.

html
<link rel="stylesheet" href="/estilo.css">

O estilo.css da clínica tem duas linhas, o suficiente para a diferença aparecer:

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

js
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-css.html folhas=0 h1.color=rgb(0, 0, 0) font-family=Times com-css.html folhas=1 h1.color=rgb(74, 222, 128) font-family=system-ui, sans-serif

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:

js
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);
pedidos feitos : sem-rel.html folhas de estilo: 0 | cor do h1: rgb(0, 0, 0)

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:

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

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

js
pagina.on('pageerror', (e) => console.log(e.stack));
await pagina.goto('http://localhost:3211/script-no-head.html');
await pagina.waitForLoadState('load');
TypeError: Cannot read properties of null (reading 'addEventListener') at http://localhost:3211/agenda.js:2:7

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.

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

js
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);
}
== script-no-head.html TypeError: Cannot read properties of null (reading 'addEventListener') at http://localhost:3211/agenda.js:2:7 == script-defer.html (console limpo)

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

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

js
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);
largura de layout : 393px characterSet : UTF-8 document.title : Agendar consulta — Clínica Veterinária Pata Amiga cor do h1 : rgb(74, 222, 128) erros no console : 0

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:

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

bash
node auditar-head.mjs pata-amiga.html
node auditar-head.mjs sem-viewport.html
pata-amiga.html ok charset 5 caracteres ok viewport 35 caracteres ok title 49 caracteres ok description 116 caracteres ok lang no <html> 5 caracteres

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

  • html
  • meta tags
  • head
  • charset
  • viewport

Perguntas frequentes

A ordem das tags dentro do head importa?
Importa para duas delas. A meta charset vem primeiro: a especificação do HTML exige que a declaração caiba inteira nos primeiros 1024 bytes do arquivo. E o link do CSS vem antes do script, para o estilo já estar a caminho quando o navegador começar a baixar o JavaScript. O resto — title, description, theme-color, favicon — pode ficar em qualquer ordem.
Preciso mesmo do viewport se meu site é simples?
Precisa. Sem ele o celular monta a página numa área de 980 pixels e encolhe tudo para caber na tela, incluindo o texto. E nenhuma media query de largura pequena dispara, porque para o CSS a página tem 980 pixels de largura, não os 393 da tela.
Posso usar user-scalable=no para travar o zoom?
Pode, mas não faça isso. Travar o zoom impede quem enxerga mal de aumentar o texto, e o iOS ignora a instrução desde a versão 10 justamente por isso. Use apenas width=device-width, initial-scale=1.
A meta keywords ainda serve para alguma coisa?
Não serve. O Google anunciou em 2009 que ignora essa tag no ranqueamento, e nada mudou desde então. Se você encontrar meta keywords num projeto antigo, pode apagar sem medo.
O que acontece se eu esquecer o title?
A página abre normalmente, mas document.title fica vazio e o navegador preenche a aba com o nome do arquivo ou com a URL. No resultado de busca, o Google escreve um título por conta própria, tirado do conteúdo da página — e quase sempre um pior do que você escreveria.

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 com Chromium 151.0.7922.34 (Playwright), e as saídas exibidas são as reais — como produzimos este conteúdo.

Fontes consultadas

  1. MDN — O elemento <meta> — developer.mozilla.org
  2. MDN — meta name="viewport" — developer.mozilla.org
  3. WHATWG HTML Standard — Specifying the document character encoding — html.spec.whatwg.org
  4. WHATWG HTML Standard — The head element — html.spec.whatwg.org

Continue por aqui