Open Graph no HTML: o preview do link no WhatsApp
O que o Google e o WhatsApp leem do seu HTML: title, meta description, hierarquia de títulos, o cartão do link compartilhado e os ícones da aba.
Open Graph é um punhado de meta tags no <head> que diz ao WhatsApp, ao
LinkedIn e ao Discord qual título, qual texto e qual imagem mostrar quando
alguém cola o seu link numa conversa. Sem elas, cada aplicativo adivinha — e
adivinha pelo pedaço errado da página.
Todos os exemplos aqui saem de uma página só: a do clube de leitura da Livraria Cardume, uma livraria de bairro que divulga a roda de leitura no grupo do WhatsApp dos clientes. A mesma página aparece em três versões, e cada versão gera um cartão diferente.
O cartão de visita que o robô busca
Quando alguém compartilha a Livraria Cardume, o WhatsApp não abre a página e “olha” para ela como uma pessoa. Ele manda um robô buscar o endereço e procura um cartão de visita já preenchido: título, resumo e imagem. Se o cartão não existe, tenta montar um com qualquer informação que encontrar.
Esse cartão é formado pelas propriedades Open Graph no <head>. Elas não
mudam o conteúdo visível; orientam o crawler que constrói o preview. Compare as
três versões da mesma página e anote qual meta tag mudou antes de olhar o cartão
resultante. Essa relação de causa e efeito é a microprática que separa a tag
necessária de uma lista copiada sem entender.
Antes de escrever qualquer tag, vale olhar o outro lado. Coloquei as três versões num servidor local que anota cada pedido e quem fez:
import { createServer } from 'node:http';
import { readFile } from 'node:fs/promises';
import { extname } from 'node:path';
const TIPOS = { '.html': 'text/html; charset=utf-8', '.png': 'image/png', '.svg': 'image/svg+xml' };
createServer(async (req, res) => {
const ua = req.headers['user-agent'] || '';
const robo = ua.match(/WhatsApp|LinkedInBot|Discordbot|Googlebot/i)?.[0] || 'navegador';
const arquivo = '.' + new URL(req.url, 'http://x').pathname;
try {
const corpo = await readFile(arquivo);
// a v4 é servida de propósito sem charset no cabeçalho — o teste vem adiante
const tipo = arquivo.includes('sem-charset') ? 'text/html' : TIPOS[extname(arquivo)];
res.writeHead(200, { 'Content-Type': tipo, 'Content-Length': corpo.length });
res.end(corpo);
console.log(`200 ${req.url} ${corpo.length} bytes <- ${robo}`);
} catch {
res.writeHead(404, { 'Content-Type': 'text/plain' });
res.end('nao encontrado');
console.log(`404 ${req.url} 0 bytes <- ${robo}`);
}
}).listen(4123);O bloco do catch não é enfeite: o log de 404 é metade do que este artigo tem
para mostrar. Com o servidor no ar, pedi a página e a imagem com o User-Agent
de cada aplicativo:
for ua in 'WhatsApp/2.2412.4 N' \
'LinkedInBot/1.0 (compatible; Mozilla/5.0)' \
'Mozilla/5.0 (compatible; Discordbot/2.0)'; do
curl -s -o /dev/null -A "$ua" http://127.0.0.1:4123/v3.html
curl -s -o /dev/null -A "$ua" http://127.0.0.1:4123/capa-clube-1200x630.png
doneQuem disparou esses pedidos fui eu, com o curl vestindo o User-Agent de cada
aplicativo — o log acima é a visão do servidor, não uma captura do robô do
WhatsApp em campo. O que ele deixa claro é a forma da visita, e são duas
coisas para guardar.
Um cartão custa dois pedidos, não um: primeiro o HTML, depois o arquivo
apontado pelo og:image, em conexões separadas. Por isso a imagem precisa estar
pública e responder 200 sozinha — se ela der 404, o cartão sai sem imagem mesmo
com todas as tags certas no <head>.
E o robô de preview pega o HTML que veio pela rede e vai embora. Ele não executa
o seu JavaScript — diferente do Googlebot, que renderiza a página. Meta tag
inserida por document.head.append() depois que a página carregou não existe
para o WhatsApp: para ele, o <head> é o que o servidor mandou.
Três versões da mesma página, três cartões
Escrevi um leitor de cartão: ele baixa o HTML, lê só o <head>, aplica a
cascata de fallback e tenta baixar a imagem. Não é o robô do WhatsApp — é a
mesma sequência de decisões, num programa que dá para abrir e ler linha a linha.
import { JSDOM } from 'jsdom';
const alvo = process.argv[2];
const html = await (await fetch(alvo)).text();
const { document } = new JSDOM(html).window;
const meta = (chave) =>
document.querySelector(`meta[property="${chave}"]`)?.content ||
document.querySelector(`meta[name="${chave}"]`)?.content ||
null;
const titulo = meta('og:title') || meta('twitter:title') || document.title || null;
const descricao = meta('og:description') || meta('twitter:description') || meta('description') || null;
const imagem = meta('og:image') || meta('twitter:image') || null;A cascata é a parte que ninguém decora e que explica tudo: og:title ganha
do <title>, e o <title> só é usado quando não existe og:title. Vale o
mesmo para a descrição.
Falta a imagem, que é o único campo em que o leitor precisa sair da página e baixar um arquivo. Esta é a segunda metade do mesmo arquivo:
let situacaoImagem = 'sem og:image — cartão sai só com texto';
if (imagem && !/^https?:/.test(imagem)) {
situacaoImagem = `"${imagem}" é caminho relativo — robô não resolve, cartão sai sem imagem`;
} else if (imagem) {
const resposta = await fetch(imagem);
const bytes = Buffer.from(await resposta.arrayBuffer());
const largura = bytes.readUInt32BE(16); // IHDR do PNG: largura e altura
const altura = bytes.readUInt32BE(20);
situacaoImagem = `${largura}x${altura} (${(largura / altura).toFixed(2)}:1), ${bytes.length} bytes`;
}
console.log(`--- ${alvo}`);
console.log(`titulo: ${titulo ?? '(nenhum)'}`);
console.log(`descricao: ${descricao ?? '(nenhuma)'}`);
console.log(`imagem: ${situacaoImagem}`);
console.log(`lang: ${document.documentElement.lang || '(nao declarado)'}`);
console.log(`h1: ${document.querySelectorAll('h1').length} na pagina`);
console.log(`auditoria: titulo ${titulo?.length ?? 0} chars, descricao ${descricao?.length ?? 0} chars`);Rodando o leitor nas três versões:
for v in v1 v2 v3; do node previa.mjs http://127.0.0.1:4123/$v.html; echo; done— http://127.0.0.1:4123/v2.html titulo: Clube de leitura de agosto | Livraria Cardume descricao: Roda de leitura gratuita na Livraria Cardume, toda última quinta do mês, às 19h, com edições comentadas e vagas limitadas. imagem: “capa-clube-400x209.png” é caminho relativo — robô não resolve, cartão sai sem imagem lang: pt-BR h1: 1 na pagina auditoria: titulo 45 chars, descricao 122 chars
— http://127.0.0.1:4123/v3.html titulo: Clube de leitura de agosto na Livraria Cardume descricao: Última quinta do mês, 19h, entrada gratuita. Trinta lugares, edições comentadas e café por conta da casa. imagem: 1200x630 (1.90:1), 3633 bytes lang: pt-BR h1: 1 na pagina auditoria: titulo 46 chars, descricao 105 chars
| versão | o que o cartão mostra | tamanho do HTML |
|---|---|---|
v1 — só <title> |
o título de 16 caracteres e o endereço cru | 258 bytes |
v2 — title, description e og:image relativa |
texto certo, nenhuma imagem | 552 bytes |
| v3 — Open Graph completo | título próprio, chamada própria, imagem grande | 1677 bytes |
O <head> completo custou 1,4 KB a mais que o mínimo. É o metadado mais
barato do site inteiro.
title: a aba, o favorito e o resultado de busca
O <title> faz três trabalhos ao mesmo tempo: nomeia a aba, nomeia o item
salvo nos favoritos e vira o link azul do resultado de busca. Por isso ele
começa pelo assunto e termina pela marca, nunca o contrário.
<title>Clube de leitura</title>
<title>Clube de leitura de agosto | Livraria Cardume</title>Todo mundo repete “no máximo 60 caracteres”. O número é um atalho ruim, porque o corte acontece por largura em pixels, não por contagem de letras. Medi quatro títulos num Chromium de verdade, com uma aproximação da tipografia do resultado no desktop — 20px Arial, cortando em 600px de coluna útil:
import { chromium } from 'playwright-core';
const titulos = [
'Clube de leitura de agosto | Livraria Cardume',
'Clube de leitura mensal e oficina de escrita | Livraria Cardume',
'MMMMM MMMMM MMMMM MMMMM MMMMM MMMMM MMMMM MMMMM MMMMM MMMMM',
'lilili lilili lilili lilili lilili lilili lilili lilili lilili',
];
const navegador = await chromium.launch({ channel: 'chromium' });
const pagina = await navegador.newPage();
const medidas = await pagina.evaluate((lista) => {
const ctx = document.createElement('canvas').getContext('2d');
ctx.font = '20px Arial, sans-serif';
return lista.map((t) => [t.length, Math.round(ctx.measureText(t).width)]);
}, titulos);
for (const [i, [chars, px]] of medidas.entries()) {
const corta = px > 600 ? 'CORTA' : 'cabe '; // 600px: a largura útil da coluna
console.log(`${String(chars).padStart(2)} chars ${String(px).padStart(3)} px ${corta} ${titulos[i]}`);
}
await navegador.close();Um título de 63 caracteres coube com folga; outro de 59 estourou. A conta de
letras erra porque a letra não tem largura fixa: na mesma medição, o M deu
16,7 px e o l deu 4,4 px — quase quatro vezes. As duas últimas linhas são
títulos artificiais, feitos só para esticar a diferença; o que elas provam é que
contar caractere não diz nada sobre o espaço ocupado. Escreva olhando o título
renderizado, não a régua.
O acento que vira ç
Se o <head> não declara a codificação, todo acento do título vira lixo. Salvei
esta página em UTF-8, sem <meta charset>:
<!doctype html>
<html lang="pt-BR">
<head>
<title>Edições comentadas | Livraria Cardume</title>
</head>
<body><h1>Edições comentadas</h1></body>
</html>O servidor lá de cima manda esse arquivo com Content-Type: text/html seco, sem
charset — a única página em que ele faz isso. Para ler o que sobra na aba,
sete linhas de Chromium:
import { chromium } from 'playwright-core';
const navegador = await chromium.launch({ channel: 'chromium' });
const pagina = await navegador.newPage();
await pagina.goto(process.argv[2], { waitUntil: 'networkidle' });
console.log('aba mostra:', await pagina.title());
await navegador.close();curl -sI http://127.0.0.1:4123/v4-sem-charset.html | grep -i content-type
node navegador.mjs http://127.0.0.1:4123/v4-sem-charset.htmlSem declaração nenhuma, o Chromium não adivinha UTF-8: ele cai na codificação
herdada do idioma (windows-1252) e lê cada byte como um caractere. O ç, que em
UTF-8 ocupa dois bytes, aparece como os dois caracteres que aqueles bytes valem
naquela tabela: ç.
O robô de preview cai no mesmo buraco: ele também recebe uma pilha de bytes e
precisa descobrir em que tabela lê-los, e o og:title sai com o mesmo lixo que
apareceu na aba. A correção é uma linha, e ela vem antes de tudo no <head> — o assunto está inteiro em
meta tags no HTML e no erro
the character encoding of the HTML document was not declared.
meta description não decide ranking, decide o clique
A meta description não é fator de posição há muitos anos. O que ela decide é
outra coisa: se a pessoa que já viu o seu link vai clicar nele.
<meta
name="description"
content="Roda de leitura gratuita na Livraria Cardume, toda última quinta do mês, às 19h, com edições comentadas e vagas limitadas."
/>Repare no conteúdo: dia, horário, preço e limite de vagas. Uma descrição que serve para qualquer página da livraria (“a melhor livraria da cidade, com atendimento de qualidade”) não ajuda ninguém a decidir.
Entre 110 e 155 caracteres é a faixa que costuma aparecer inteira. Acima disso o Google corta, e às vezes reescreve o trecho inteiro com um pedaço do seu texto que ele achou mais relevante para a busca feita — o que é uma boa notícia disfarçada: se ele reescreve muito, a página responde mais do que a descrição promete.
Um h1 e uma hierarquia que se lê sozinha
O <title> é o nome da página para quem está fora dela. O <h1> é o nome
para quem já está dentro. Podem ser diferentes, e normalmente são: o título
carrega a marca, o h1 não precisa.
<title>Clube de leitura de agosto | Livraria Cardume</title>
<main>
<h1>Clube de leitura de agosto</h1>
<h2>Como participar</h2>
<h3>Reserva pelo WhatsApp</h3>
<h2>O livro do mês</h2>
</main>A regra prática: se você ler só os títulos, de cima para baixo, precisa
entender do que a página trata. Um h3 logo depois de um h1, sem h2 no
meio, é um degrau faltando na escada — quem navega por leitor de tela sente
isso na hora. A mecânica de h1 a h6 está em
títulos e parágrafos em HTML.
lang, canonical e a página que mora em três endereços
lang="pt-BR" no <html> avisa o idioma. É o que faz o corretor ortográfico,
o tradutor automático e o leitor de tela escolherem português — sem ele, um
leitor de tela pode ler “clube de leitura” com pronúncia inglesa.
O canonical resolve outro problema. A mesma página do clube pode ser aberta
por endereços diferentes: com parâmetro de campanha, com âncora, com e sem
www. Para o robô, três endereços são três páginas — a não ser que a página
diga qual é a verdadeira.
<link rel="canonical" href="https://livrariacardume.com.br/clube-de-leitura" />
<meta property="og:url" content="https://livrariacardume.com.br/clube-de-leitura" />Pedi a mesma página nos três endereços e li o que ela declara:
import { JSDOM } from 'jsdom';
const base = 'http://127.0.0.1:4123/v3.html';
for (const endereco of [base, `${base}?utm_source=whatsapp&utm_campaign=clube`, `${base}#programacao`]) {
const { document } = new JSDOM(await (await fetch(endereco)).text()).window;
console.log(`pedido: ${endereco}`);
console.log(`canonical: ${document.querySelector('link[rel="canonical"]')?.href}`);
console.log(`og:url: ${document.querySelector('meta[property="og:url"]')?.content}\n`);
}pedido: http://127.0.0.1:4123/v3.html?utm_source=whatsapp&utm_campaign=clube canonical: http://127.0.0.1:4123/v3.html og:url: http://127.0.0.1:4123/v3.html
pedido: http://127.0.0.1:4123/v3.html#programacao canonical: http://127.0.0.1:4123/v3.html og:url: http://127.0.0.1:4123/v3.html
Os três pedidos chegaram por URLs diferentes e os três apontaram para o mesmo endereço canônico. É assim que os cliques vindos da campanha do WhatsApp somam com os cliques vindos da busca, em vez de virarem três páginas concorrentes.
As cinco tags de Open Graph que montam o cartão
Open Graph usa property, e não name — é a diferença que mais faz gente
perder uma tarde. (Repare que o meu leitor lá de cima procura nos dois atributos:
ele é tolerante de propósito. O robô do aplicativo não é obrigado a ser, e a
especificação pede property.) As cinco tags que montam o cartão:
<meta property="og:type" content="article" />
<meta property="og:url" content="https://livrariacardume.com.br/clube-de-leitura" />
<meta property="og:title" content="Clube de leitura de agosto na Livraria Cardume" />
<meta
property="og:description"
content="Última quinta do mês, 19h, entrada gratuita. Trinta lugares, edições comentadas e café por conta da casa."
/>
<meta property="og:image" content="https://livrariacardume.com.br/og/clube-agosto.png" />Mais três que valem o esforço:
<meta property="og:site_name" content="Livraria Cardume" />
<meta property="og:locale" content="pt_BR" />
<meta property="og:image:alt" content="Cadeiras em círculo no mezanino da Livraria Cardume" />O og:title não precisa ser igual ao <title>, e é bom que não seja. No
resultado de busca, a pessoa está procurando; num grupo de WhatsApp, ela não
estava procurando nada. O texto do cartão pode ser mais direto e mais convite —
compare os dois na saída da v3 lá em cima.
og:type sai de uma lista fechada, publicada em ogp.me: website, article,
book, profile e as famílias music.* e video.* (music.album,
video.movie). product, que aparece em muito tutorial, não está nessa
lista — é tipo de catálogo do Facebook, não do protocolo. Na prática o cartão
muda pouco: use website na home e article no resto.
og:image: o caminho relativo que apaga a imagem
Este é o erro campeão, e ele é silencioso: a página não quebra, o console não reclama, o cartão simplesmente sai sem imagem.
<meta property="og:image" content="capa-clube-400x209.png" />Dentro do navegador um caminho relativo funciona: o navegador resolve contra a
URL da página. Com o robô de preview a história é outra. A especificação do Open
Graph trata og:image como uma URL e usa o endereço completo em todos os
exemplos, mas não define o que acontece com caminho relativo — e o que a
especificação não define cada aplicativo resolve do seu jeito. O meu leitor
recusa, que é justamente o cenário que você não quer descobrir com o link já
compartilhado. Escreva o endereço inteiro, com protocolo:
<meta property="og:image" content="https://livrariacardume.com.br/og/clube-agosto.png" />
<meta property="og:image:width" content="1200" />
<meta property="og:image:height" content="630" />Com o caminho absoluto, o leitor baixou a imagem e mediu:
1200×630 dá 1,90:1, como o próprio leitor mediu — é o arredondamento prático da proporção 1,91:1 que a documentação do Facebook recomenda para o cartão grande. A mesma documentação põe 600 pixels de largura como piso: abaixo disso o aplicativo desiste do cartão grande e cai na miniatura quadrada ao lado do texto, o mesmo link ocupando uma fração da área na tela.
Declarar width e height também tem efeito prático, e aqui a documentação do
Facebook é explícita: com as duas declaradas, o crawler renderiza a imagem na
hora, sem precisar baixar e processar o arquivo antes. Já o og:image:alt
descreve a imagem para quem usa leitor de tela, exatamente como o alt de
uma imagem comum no HTML.
twitter:card: o que restou dele
O X ainda lê as tags twitter:, mas elas viraram, na prática, um complemento
de uma linha. Todo o resto ele pega do Open Graph.
<meta name="twitter:card" content="summary_large_image" />Repare no name, e não property — as tags do X seguem a convenção antiga.
summary_large_image pede o cartão grande; summary pede o pequeno. Só
escreva twitter:title ou twitter:image se você quiser um texto diferente
do Open Graph naquela rede. Duplicar tudo é manutenção dobrada sem ganho
nenhum.
Favicon: o pedido que o navegador faz sozinho
Aqui tem uma pegadinha que dá para ver com os próprios olhos. Abri a v1 — a
versão sem nenhum <link rel="icon"> — com o mesmo abridor de sete linhas da
seção do charset:
node navegador.mjs http://127.0.0.1:4123/v1.htmlE fui ler o terminal do servidor, que anotou dois pedidos:
O navegador pediu /favicon.ico por conta própria, mesmo sem ninguém ter
escrito isso no HTML. É comportamento herdado dos anos 1990, e continua vivo.
Declarando os ícones, o pedido muda. Servi a v3, que declara três, e o Chromium buscou o SVG primeiro:
<link rel="icon" href="/favicon.ico" sizes="32x32" />
<link rel="icon" href="/icone.svg" type="image/svg+xml" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />Um pedido só. Quando eu apaguei o icone.svg do disco e repeti o teste, o
mesmo Chromium foi atrás do .ico:
Ou seja: com o SVG declarado, esse Chromium nem tentou o .ico — só voltou a
ele, e ao palpite de /favicon.ico, quando o SVG sumiu. O .ico é a rede de
segurança para o navegador que ainda não entende SVG. Com esses três arquivos —
mais um site.webmanifest se o site vira atalho na tela inicial do celular —
você cobre navegador, aba fixada e ícone de iPhone.
O que não existe: meta keywords e as outras lendas
Sobrou muito folclore de 2005 circulando em tutorial. Vale saber o que pode sair
do seu <head> sem dó:
| tag | situação | o que fazer |
|---|---|---|
<meta name="keywords"> |
ignorada pelos buscadores há mais de uma década | apagar |
<meta name="robots" content="index, follow"> |
é o padrão; escrever não muda nada | apagar |
<meta name="revisit-after"> |
nenhum buscador relevante jamais deu suporte | apagar |
<meta name="author"> |
não é sinal de ranking | manter só se você usa |
<meta name="robots" content="noindex"> |
funciona mesmo | usar em página de teste e obrigado-pelo-pedido |
O noindex é o único da lista com efeito real, e é o que mais gente esquece de
tirar depois — página de produção nascida de um clone de ambiente de teste
costuma vir com ele colado no <head>. Se um site sumiu da busca do nada,
procure essa linha antes de procurar qualquer outra coisa.
O head inteiro, para copiar
Juntando tudo, este é o <head> da página do clube de leitura:
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Clube de leitura de agosto | Livraria Cardume</title>
<meta
name="description"
content="Roda de leitura gratuita na Livraria Cardume, toda última quinta do mês, às 19h, com edições comentadas e vagas limitadas."
/>
<link rel="canonical" href="https://livrariacardume.com.br/clube-de-leitura" />
<meta property="og:type" content="article" />
<meta property="og:site_name" content="Livraria Cardume" />
<meta property="og:locale" content="pt_BR" />
<meta property="og:url" content="https://livrariacardume.com.br/clube-de-leitura" />
<meta property="og:title" content="Clube de leitura de agosto na Livraria Cardume" />
<meta
property="og:description"
content="Última quinta do mês, 19h, entrada gratuita. Trinta lugares, edições comentadas e café por conta da casa."
/>
<meta property="og:image" content="https://livrariacardume.com.br/og/clube-agosto.png" />
<meta property="og:image:width" content="1200" />
<meta property="og:image:height" content="630" />
<meta property="og:image:alt" content="Cadeiras em círculo no mezanino da Livraria Cardume" />
<meta name="twitter:card" content="summary_large_image" />
<link rel="icon" href="/favicon.ico" sizes="32x32" />
<link rel="icon" href="/icone.svg" type="image/svg+xml" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
</head>O que vem depois
Copie esse <head> para a sua página, suba num endereço público e cole o link
numa conversa consigo mesmo no WhatsApp. É o teste mais rápido que existe: ou o
cartão aparece com imagem grande, ou você descobre em dez segundos qual das
tags está errada.
Depois disso, o próximo assunto da trilha de HTML é guardar dado no próprio elemento com data attributes — o caminho por onde o JavaScript conversa com o seu markup. E o guia completo de HTML mostra onde cada peça entra na ordem de estudo.
Perguntas frequentes
Corrigi as meta tags e o WhatsApp continua mostrando o cartão antigo. Por quê?
Preciso de Open Graph em toda página do site?
Dá para gerar a imagem do cartão automaticamente?
Open Graph melhora o ranking no Google?
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, Chromium 151 e curl 8.7.1 no macOS, e as saídas exibidas são as reais — como produzimos este conteúdo.
Fontes consultadas
- The Open Graph protocol — ogp.me
- MDN — O elemento meta — developer.mozilla.org
- Google Search Central — Links de título nos resultados da Pesquisa — developers.google.com
- Google Search Central — Meta tags que o Google entende — developers.google.com
- Meta for Developers — Sharing on the web (webmasters) — developers.facebook.com


