The character encoding of the HTML document was not declared
Os acentos viraram ção e o console avisa que a codificação não foi declarada: por que isso acontece, o que o navegador chuta e como corrigir.
Esse aviso informa que o documento HTML não declarou sua codificação de
caracteres: a regra usada para converter os bytes do arquivo em letras,
acentos e símbolos. Sem essa informação, o navegador tenta adivinhar. Quando o
palpite erra, Promoção aparece como Promoção.
Pense numa legenda de mapa. O papel contém os mesmos sinais, mas você precisa da
legenda correta para saber o que cada um representa. Os bytes também não mudam;
é a interpretação deles que muda entre UTF-8 e Windows-1252. Tecnicamente, o
navegador consulta BOM, header HTTP e <meta charset> nessa ordem antes de
recorrer a um palpite.
A correção usual é <meta charset="utf-8"> logo no começo do <head>, mas ela
só funciona se o servidor não declarar outra codificação com prioridade maior.
Todos os exemplos deste artigo são a mesma página: a vitrine de promoções da Livraria Aurora, uma livraria de bairro. Um título, um parágrafo, quatro palavras acentuadas. É suficiente para quebrar tudo.
A mensagem literal, e por que ela mudou de redação
A frase que dá título a este artigo é a redação antiga do aviso do Firefox, e é ela que aparece nos tutoriais e nas respostas de fórum:
O Firefox de hoje reescreveu esse texto. No código-fonte do navegador, o arquivo
que guarda as mensagens do parser de HTML traz a versão atual, sob a chave
EncNoDecl:
Repare no que mudou: a redação nova é explícita sobre os três lugares onde a declaração pode morar — header HTTP, meta tag e byte order mark. É exatamente a ordem que este artigo percorre, porque é a ordem em que o navegador procura.
Duas observações antes de reproduzir o problema. Primeira: isso é um aviso, não um erro. A página carrega, o JavaScript roda, nada explode — só o texto sai errado. Segunda: no Chrome você não vê aviso nenhum. Ele adivinha em silêncio, com o mesmo algoritmo, e o defeito aparece só na tela.
Reproduzindo: a mesma página servida sem charset nenhum
A vitrine da Livraria Aurora, salva em UTF-8 pelo editor, sem nenhuma declaração de codificação:
<!DOCTYPE html>
<html lang="pt-BR">
<head>
<title>Livraria Aurora</title>
</head>
<body>
<h1>Promoção de inverno</h1>
<p>Edição de aniversário com 30% de desconto até sábado.</p>
</body>
</html>Para controlar o que o servidor manda no header, um
servidor HTTP em Node sem framework nenhum
resolve. Ele serve a mesma página em quatro rotas, mudando só o
Content-Type. São dois arquivos: o sem-charset.html que você acabou de ver e
o com-charset.html, idêntico a ele mais a linha da meta — ela aparece inteira
daqui a duas seções, na correção.
import { createServer } from 'node:http';
import { readFileSync } from 'node:fs';
const semMeta = readFileSync('sem-charset.html');
const comMeta = readFileSync('com-charset.html');
const rotas = {
'/sem-nada': ['text/html', semMeta],
'/so-meta': ['text/html', comMeta],
'/header-brigando': ['text/html; charset=ISO-8859-1', comMeta],
'/certo': ['text/html; charset=utf-8', comMeta],
};
createServer((req, res) => {
const rota = rotas[req.url];
if (!rota) return res.writeHead(404).end();
res.writeHead(200, { 'Content-Type': rota[0] });
res.end(rota[1]);
}).listen(4173, () => console.log('Livraria Aurora no ar em http://localhost:4173'));Batendo na primeira rota:
curl -s -i http://localhost:4173/sem-nada<!DOCTYPE html> <html lang=“pt-BR”> <head> <title>Livraria Aurora</title> </head> <body> <h1>Promoção de inverno</h1> <p>Edição de aniversário com 30% de desconto até sábado.</p> </body> </html>
Aqui mora uma armadilha de diagnóstico: no curl o texto saiu certo. Não é
sinal de que está tudo bem. O curl despeja os bytes crus e quem decodifica é o
seu terminal, que já está em UTF-8. O navegador não tem essa sorte — ele precisa
decidir sozinho, e a linha Content-Type: text/html, sem charset, não ajuda em
nada.
Por que “ção” vira “ção”: os bytes por trás da confusão
Antes de olhar para o navegador, olhe para o arquivo. Este script lê os bytes da
palavra Promoção e mostra o que cada decodificador entende deles:
import { readFileSync } from 'node:fs';
const bytes = readFileSync('sem-charset.html');
const inicio = bytes.indexOf('Promoção');
const palavra = bytes.subarray(inicio, inicio + 10);
console.log('bytes :', palavra.toString('hex').match(/../g).join(' '));
console.log('utf-8 :', new TextDecoder('utf-8').decode(palavra));
console.log('1252 :', new TextDecoder('windows-1252').decode(palavra));São dez bytes para oito letras. P, r, o, m, o e o o final ocupam um
byte cada, porque estão na faixa ASCII. O ç ocupa dois — c3 a7 — e o ã
ocupa outros dois — c3 a3. É assim que o UTF-8 funciona: caractere fora do
ASCII vira sequência de dois a quatro bytes.
Agora repare no que a segunda decodificação faz. Windows-1252 é uma tabela de um
byte por caractere: ela não tem como saber que c3 a7 era para ser lido junto.
Então lê c3 como Ã, lê a7 como §, e o ç vira ç. Todo mojibake que
você já viu na vida é isso — a mesma sequência de bytes lida com a régua errada.
E é esse o palpite que sobra quando ninguém declara nada. Para ver o algoritmo
rodando sem depender de abrir o navegador na mão, dá para usar o jsdom, que
implementa a parte do HTML Standard que decide a codificação: BOM, header,
varredura da meta e, se nada aparecer, a codificação padrão. O script busca as
quatro rotas do servidor e mostra, para cada uma, a codificação escolhida e o
texto que sobrou:
import { JSDOM } from 'jsdom';
const rotas = ['/sem-nada', '/so-meta', '/header-brigando', '/certo'];
console.log('rota'.padEnd(18), 'characterSet'.padEnd(14), 'o que o <h1> virou');
for (const rota of rotas) {
const resposta = await fetch(`http://localhost:4173${rota}`);
const bytes = Buffer.from(await resposta.arrayBuffer());
const dom = new JSDOM(bytes, { contentType: resposta.headers.get('content-type') });
const doc = dom.window.document;
console.log(rota.padEnd(18), doc.characterSet.padEnd(14), doc.querySelector('h1').textContent);
}Quatro respostas com os mesmos bytes de conteúdo e dois resultados
diferentes. Sem declaração nenhuma sobra o padrão — windows-1252 — e o texto
quebra. Guarde a terceira linha: ela é o assunto de duas seções adiante.
A correção de uma linha, e o lugar exato dela
A declaração vai dentro do <head>, e o lugar não é decorativo — é o primeiro
filho, antes do <title>:
<!DOCTYPE html>
<html lang="pt-BR">
<head>
<meta charset="utf-8">
<title>Livraria Aurora</title>
</head>
<body>
<h1>Promoção de inverno</h1>
<p>Edição de aniversário com 30% de desconto até sábado.</p>
</body>
</html>É a rota /so-meta da tabela acima: UTF-8, texto inteiro. Uma linha, problema
resolvido — desde que as próximas quatro seções não estejam acontecendo com você.
Por que antes do <title>? Porque o título também pode ter acento. O navegador
lê os bytes na ordem: se ele encontrar Promoções da Livraria Aurora no title
antes de saber a codificação, precisa recomeçar a análise do documento do zero.
A ordem canônica das metatags está detalhada em
meta tags no HTML: charset, viewport e o resto do head,
e o esqueleto completo do documento em
estrutura de uma página HTML.
O header do servidor ganha da meta
Essa é a causa que mais consome tempo, porque o HTML está visivelmente correto e
o texto continua quebrado. A rota /header-brigando serve o arquivo com a
meta certa, mas o servidor anuncia outra coisa:
curl -sI http://localhost:4173/header-brigando | grep -i content-typeResultado, na tabela da seção anterior: windows-1252, texto quebrado. O
charset do header HTTP tem prioridade sobre a <meta> — o navegador já
começou a decodificar antes mesmo de ler o primeiro byte do seu HTML. E o
ISO-8859-1 que você declarou virou windows-1252 no caminho, porque o Encoding
Standard manda tratar os dois como o mesmo rótulo.
A correção é no servidor, não no HTML:
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });curl -sI http://localhost:4173/certo | grep -i content-typeA meta chegou tarde: o limite dos 1024 primeiros bytes
O navegador não lê o documento inteiro procurando a declaração. Ele faz uma
varredura rápida nos 1024 primeiros bytes e desiste. Um comentário grande, um
bloco de licença ou um <style> inline antes da meta empurram ela para fora
dessa janela.
Dá para medir o efeito com precisão. O script monta a mesma página duas vezes, mudando só o tamanho de um comentário, e informa em que byte a meta ficou:
import { JSDOM } from 'jsdom';
function pagina(tamanhoDoComentario) {
return Buffer.from(`<!DOCTYPE html>
<html lang="pt-BR">
<head>
<!-- ${'x'.repeat(tamanhoDoComentario)} -->
<meta charset="utf-8">
<title>Livraria Aurora</title>
</head>
<body><h1>Promoção de inverno</h1></body>
</html>`);
}
for (const tamanho of [800, 1000]) {
const bytes = pagina(tamanho);
const posicao = bytes.indexOf('<meta charset');
const doc = new JSDOM(bytes, { contentType: 'text/html' }).window.document;
console.log(
`meta no byte ${String(posicao).padStart(4)} →`,
doc.characterSet.padEnd(13),
doc.querySelector('h1').textContent,
);
}Duzentos bytes de comentário separam a página certa da página quebrada. O
Firefox tem uma mensagem específica para esse caso, a EncMetaTooLate, e ela é
bem menos famosa que a do título deste artigo:
Ou seja: se o console reclama de meta encontrada tarde demais, você não precisa adicionar nada. Precisa subir a linha que já existe.
O editor também erra: o arquivo salvo em Windows-1252
Existe o caso inverso, e ele confunde muito. A meta diz utf-8, o header diz
utf-8, e o texto continua errado — porque o arquivo em si não está em UTF-8. É
o legado de editores antigos, de exportações de sistema e de arquivos que
atravessaram o Windows.
O script simula exatamente isso: pega o HTML correto e regrava os mesmos caracteres em Windows-1252, num arquivo novo, mantendo a meta que promete UTF-8.
import { readFileSync, writeFileSync } from 'node:fs';
import { JSDOM } from 'jsdom';
const texto = readFileSync('com-charset.html', 'utf8');
// o editor salvou o arquivo em Windows-1252, mas a meta continua dizendo utf-8
const bytesLatin1 = Buffer.from(texto, 'latin1');
writeFileSync('salvo-em-latin1.html', bytesLatin1);
const dom = new JSDOM(bytesLatin1, { contentType: 'text/html; charset=utf-8' });
const i = bytesLatin1.indexOf(Buffer.from('Promo', 'latin1'));
console.log('bytes :', bytesLatin1.subarray(i, i + 8).toString('hex').match(/../g).join(' '));
console.log('h1 :', dom.window.document.querySelector('h1').textContent);O sintoma agora é outro. Não é ç: é o losango com interrogação, o caractere de
substituição U+FFFD. Ele aparece quando o decodificador de UTF-8 encontra um
byte que não pode existir naquela posição — e7 sozinho promete uma sequência de
três bytes que nunca vem — e troca a sequência inválida por esse marcador.
Os dois sintomas apontam para direções opostas, e essa é a informação mais
útil deste artigo. Texto com à significa arquivo em UTF-8 lido como
Windows-1252: conserte a declaração. Texto com o losango de interrogação
significa arquivo em Windows-1252 lido como UTF-8: conserte o arquivo, não a
declaração.
Para ver a perda acontecendo em vez de adivinhar, peça ao decodificador que reclame em vez de substituir:
import { readFileSync } from 'node:fs';
const bytes = readFileSync('salvo-em-latin1.html');
// fatal: true manda o decodificador reclamar em vez de trocar por "�"
const texto = new TextDecoder('utf-8', { fatal: true }).decode(bytes);
console.log(texto);TypeError: The encoded data was not valid for encoding utf-8 at TextDecoder.decode (node:internal/encoding:494:28) at file:///private/tmp/aurora/estrito.mjs:6:57 at ModuleJob.run (node:internal/modules/esm/module_job:439:25) at async node:internal/modules/esm/loader:633:26 at async asyncRunEntryPointWithESMLoader (node:internal/modules/run_main:101:5) { code: ‘ERR_ENCODING_INVALID_ENCODED_DATA’ }
Node.js v24.16.0
O navegador nunca lança esse erro — ele engole a falha e desenha o losango. O
fatal: true só transforma em exceção o que já estava acontecendo em silêncio.
Para descobrir em que codificação um arquivo está sem abrir editor nenhum, o
comando file responde direto no terminal (a flag é -I maiúsculo no macOS e
-i minúsculo no Linux):
file -I com-charset.html salvo-em-latin1.htmlNo VS Code, a mesma informação está na barra de status, no canto inferior direito. Clicando nela você escolhe Save with Encoding → UTF-8 e o arquivo é regravado de verdade.
BOM: os três bytes invisíveis que ganham de todo mundo
O terceiro lugar citado pela mensagem do Firefox é o byte order mark. São três
bytes no começo do arquivo — ef bb bf — que alguns editores gravam para marcar
UTF-8. Eles não aparecem no editor, mas aparecem no xxd. Cole os três bytes na
frente da página sem meta e compare os dois começos:
printf '\xef\xbb\xbf' | cat - sem-charset.html > com-bom.html
head -c 3 com-bom.html | xxd
head -c 3 com-charset.html | xxdO primeiro arquivo começa com o BOM; o segundo começa com <!D, o início do
<!DOCTYPE. E o BOM tem a palavra final: ele ganha até do header HTTP. O script
serve os mesmos bytes com e sem BOM, sempre anunciando ISO-8859-1 no
Content-Type:
import { readFileSync } from 'node:fs';
import { JSDOM } from 'jsdom';
const semMeta = readFileSync('sem-charset.html');
const comBom = Buffer.concat([Buffer.from([0xef, 0xbb, 0xbf]), semMeta]);
for (const [nome, bytes] of [['sem BOM', semMeta], ['com BOM', comBom]]) {
const dom = new JSDOM(bytes, { contentType: 'text/html; charset=ISO-8859-1' });
const doc = dom.window.document;
console.log(nome, '→', doc.characterSet, '→', doc.querySelector('h1').textContent);
}Saber disso serve para diagnosticar, não para adotar: não use BOM de
propósito. Três bytes invisíveis no início do arquivo vazam para todo lado —
quebram o <?php de abertura, aparecem antes do <!DOCTYPE em templates
concatenados e estragam a leitura de JSON. Declare o charset na meta e no header,
e deixe o BOM para o dia em que ele explicar por que um arquivo teimoso funciona
sem meta nenhuma.
Os três lugares para conferir, nesta ordem
O navegador decide a codificação nesta sequência, e a primeira resposta que ele encontra encerra a busca:
Na prática, o roteiro de investigação é este:
| ordem | o que rodar | o que você quer ver |
|---|---|---|
| 1 | curl -sI https://seusite.com |
a linha Content-Type: text/html; charset=utf-8 |
| 2 | ver o código-fonte da página no navegador | <meta charset="utf-8"> como primeira linha do head |
| 3 | file -I pagina.html no terminal |
charset=utf-8 |
E, se quiser confirmar o veredito do próprio navegador, o console responde em uma linha:
console.log(document.characterSet);O que fazer agora
Abra o head do seu projeto e garanta que <meta charset="utf-8"> é a primeira
linha lá dentro. Depois rode o curl -sI na URL publicada. São dois minutos e
resolvem o aviso na esmagadora maioria dos casos.
Com a codificação declarada, você pode escrever ção, € e → direto no HTML,
sem precisar de código numérico para cada símbolo — e aí vale entender quando as
entidades HTML ainda são obrigatórias, porque nesses
casos elas não são estética, são sintaxe. Para ver como esses bytes viram
elementos na tela, o caminho é
como o navegador monta a árvore DOM.
E se você está montando sua primeira página do zero, o
guia completo de HTML mostra a ordem de estudo inteira.
Perguntas frequentes
Escrever UTF-8 em maiúscula ou utf-8 em minúscula faz diferença?
Ainda posso usar a forma antiga, com meta http-equiv?
Meu site é só em inglês, preciso declarar mesmo assim?
Ainda faz sentido declarar ISO-8859-1 num projeto novo?
Esse aviso prejudica o SEO da página?
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 jsdom 30.0.1, e as saídas exibidas são as reais — como produzimos este conteúdo.
Fontes consultadas
- HTML Standard — Determining the character encoding — html.spec.whatwg.org
- MDN — O elemento meta e o atributo charset — developer.mozilla.org
- Encoding Standard — encoding.spec.whatwg.org
- Firefox — htmlparser.properties, as mensagens do parser no código-fonte — searchfox.org


