Pular para o conteúdo
Cursos

DevClub

LógicaFront-endBack-endMobile

IA Club

IA na prática
Estudar programaçãoEstudar IA
Erro resolvidoIniciantecódigo testado

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.

Rodolfo Mori12 min de leitura

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:

The character encoding of the HTML document was not declared. The document will render with garbled text in some browser configurations if the document contains characters from outside the US-ASCII range. The character encoding of the page must be declared in the document or in the transfer protocol.

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:

The character encoding of the document was not declared, so the encoding was guessed from content. The character encoding needs to be declared in the Content-Type HTTP header, using a meta tag, or using a byte order mark.

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:

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

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

bash
curl -s -i http://localhost:4173/sem-nada
HTTP/1.1 200 OK Content-Type: text/html Date: Sat, 22 Aug 2026 19:07:07 GMT Connection: keep-alive Keep-Alive: timeout=5 Transfer-Encoding: chunked

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

js
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));
bytes : 50 72 6f 6d 6f c3 a7 c3 a3 6f utf-8 : Promoção 1252 : Promoção

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:

js
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);
}
rota characterSet o que o <h1> virou /sem-nada windows-1252 Promoção de inverno /so-meta UTF-8 Promoção de inverno /header-brigando windows-1252 Promoção de inverno /certo UTF-8 Promoção de inverno

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

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

bash
curl -sI http://localhost:4173/header-brigando | grep -i content-type
Content-Type: text/html; charset=ISO-8859-1

Resultado, 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:

js
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
bash
curl -sI http://localhost:4173/certo | grep -i content-type
Content-Type: text/html; charset=utf-8

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

js
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,
  );
}
meta no byte 863 → UTF-8 Promoção de inverno meta no byte 1063 → windows-1252 Promoção de inverno

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:

A meta tag attempting to declare the character encoding declaration was found too late, and the encoding was guessed from content instead. The meta tag needs to be moved to the start of the head part of the document.

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.

js
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);
bytes : 50 72 6f 6d 6f e7 e3 6f h1 : Promo��o de inverno

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:

js
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);
node:internal/encoding:494 if (!chunk) return decodeUTF8(input, ignoreBom, this[kFatal]); ^

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

bash
file -I com-charset.html salvo-em-latin1.html
com-charset.html: text/html; charset=utf-8 salvo-em-latin1.html: text/html; charset=iso-8859-1

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

bash
printf '\xef\xbb\xbf' | cat - sem-charset.html > com-bom.html
head -c 3 com-bom.html | xxd
head -c 3 com-charset.html | xxd
00000000: efbb bf ... 00000000: 3c21 44 <!D

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

js
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);
}
sem BOM → windows-1252 → Promoção de inverno com BOM → UTF-8 → Promoção de inverno

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:

1. BOM — os bytes ef bb bf no início ganha de tudo; invisível no editor 2. charset no header Content-Type vem do servidor, não do seu HTML 3. meta charset no head só vale dentro dos 1024 primeiros bytes 4. ninguém declarou: palpite windows-1252 é aqui que o aviso nasce

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:

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

  • html
  • charset
  • utf-8
  • acentos
  • erro

Perguntas frequentes

Escrever UTF-8 em maiúscula ou utf-8 em minúscula faz diferença?
Não faz. O nome da codificação é comparado sem diferenciar maiúscula de minúscula, então charset="UTF-8" e charset="utf-8" produzem exatamente o mesmo resultado. Escolha um dos dois e mantenha o padrão no projeto todo.
Ainda posso usar a forma antiga, com meta http-equiv?
Pode. A linha meta http-equiv="Content-Type" content="text/html; charset=utf-8" continua válida e faz a mesma coisa. O atributo charset sozinho é o atalho moderno para ela, ocupa menos bytes e é mais difícil de escrever errado.
Meu site é só em inglês, preciso declarar mesmo assim?
Sim. Um nome próprio com acento, um símbolo de moeda, uma aspa tipográfica colada do editor de texto ou um emoji já saem da faixa ASCII. E o aviso aparece pela ausência da declaração, não pela presença do acento.
Ainda faz sentido declarar ISO-8859-1 num projeto novo?
Não. Quando você declara ISO-8859-1, o navegador usa windows-1252 no lugar, por compatibilidade histórica. Nenhum dos dois tem emoji, nem grego, nem japonês. Em projeto novo, UTF-8 é o único padrão razoável.
Esse aviso prejudica o SEO da página?
O aviso em si é um alerta do console, não uma penalidade. O prejuízo é indireto e real: título e descrição podem ser lidos com o texto quebrado pelos robôs, e auditorias como o Lighthouse têm um item específico para a declaração de charset.

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 jsdom 30.0.1, e as saídas exibidas são as reais — como produzimos este conteúdo.

Fontes consultadas

  1. HTML Standard — Determining the character encoding — html.spec.whatwg.org
  2. MDN — O elemento meta e o atributo charset — developer.mozilla.org
  3. Encoding Standard — encoding.spec.whatwg.org
  4. Firefox — htmlparser.properties, as mensagens do parser no código-fonte — searchfox.org

Continue por aqui