
Links da sua SPA viram cards em branco: corrigindo previews com uma Lambda na frente do Amplify
Crawlers de preview de link não executam JavaScript, então toda rota de uma SPA estática gera o mesmo card genérico. Veja como uma Lambda na frente do AWS Amplify injeta tags Open Graph por página, os seis bugs que a impediam de funcionar e a versão enxuta que funciona.
Também disponÃvel em English
A maior parte do que escrevo aqui é sobre Rails, mas a lição desta semana veio do outro lado da stack: uma single-page app em Next.js e Vite, com build estático, servida pelo AWS Amplify atrás do CloudFront. O time de produto tinha um pedido que parecia trivial. Quando um usuário compartilha o link de uma viagem no WhatsApp, no Facebook ou no LinkedIn, o preview deveria mostrar o nome da viagem, uma descrição útil e uma imagem larga do mapa. Em vez disso, todo link mostrava o mesmo card genérico: o nome do app, o slogan e um logo quadrado minúsculo.
O time já tinha implementado a correção padrão, uma função Lambda que injeta as tags Open Graph na página. Mesmo assim, não funcionava. Este post mostra por que apps estáticos têm esse problema, como funciona a abordagem com Lambda, os seis bugs que a impediam de funcionar e a versão enxuta que funciona.
Por que apps estáticos compartilham mal
Os crawlers de preview de link (facebookexternalhit, WhatsApp, LinkedInBot, Slack, Telegram, iMessage) buscam sua URL uma vez, leem o <head> e vão embora. Eles não executam JavaScript. O problema todo é esse.
Numa SPA exportada estaticamente, toda rota serve o mesmo index.html. A página da viagem em /trips/a1b2c3 só vira uma página de viagem depois que o React inicializa no navegador e chama a sua API. O crawler nunca vê isso. Ele vê o <title> padrão e as tags og:* que você embutiu no build, então todo link fica igual.
No Rails, esse problema quase não existe. O servidor renderiza a página, então você define as tags no layout a cada request e pronto:
<% content_for :head do %>
<meta property="og:title" content="<%= @trip.share_title %>" />
<meta property="og:image" content="<%= trip_og_image_url(@trip) %>" />
<% end %>
Uma SPA estática não tem servidor no caminho da requisição, então você precisa colocar um de volta, pelo menos nas rotas que as pessoas compartilham.
A arquitetura: uma função na frente do shell
O padrão é simples:
- O Amplify recebe
GET /trips/a1b2c3. - Uma regra de rewrite (status 200, para a URL não mudar) faz proxy desse caminho para uma function URL da Lambda.
- A Lambda busca a viagem na API e busca o
index.htmldo app. - Ela remove do shell o tÃtulo e as tags OG padrão, injeta as da viagem no topo do
<head>e retorna a página. - Os crawlers leem o head e montam o card. As pessoas recebem o app normal, que inicializa e renderiza a viagem como sempre.
O body nunca é tocado. A função só reescreve o head, então, para humanos, o app se comporta exatamente como antes. Essa é a principal vantagem em relação a fazer prerender de todas as páginas: você só faz trabalho no servidor para as rotas que as pessoas compartilham, e o app continua sendo um build estático simples.
Seis bugs que quebravam os previews
A função original parecia completa. Tinha geração de tags, fallbacks por ambiente, cache e até uma página de preview estilizada. Mesmo assim, todo preview saÃa genérico. Veja o que estava errado, mais ou menos em ordem de impacto.
1. O rewrite nunca chegava à função
O Amplify avalia as regras de rewrite de cima para baixo e para no primeiro match. O catch-all da SPA (a regex que manda todo caminho sem extensão para /index.html) estava listado acima da regra /trips/<*>, então os crawlers recebiam o shell estático com as tags padrão. A função só era alcançada em alguns ambientes, o que fazia o problema parecer intermitente.
A segunda armadilha do rewrite é o caminho de destino. Se o target for https://<fn-url>/<*>, a função recebe /a1b2c3 em vez de /trips/a1b2c3, e o matching de rotas falha em silêncio. Mantenha o prefixo no target e mantenha as rotas de compartilhamento acima do catch-all:
[
{ "source": "/trips/<*>", "target": "https://<fn-id>.lambda-url.us-east-1.on.aws/trips/<*>", "status": "200" },
{ "source": "<SPA catch-all regex>", "target": "/index.html", "status": "200" }
]
2. As falhas da API eram invisÃveis
O helper da API retornava null para qualquer resposta fora da faixa 2xx, sem logar nada. Um 401 por falta de um header, um 404 por endpoint errado e um timeout de 2,5 segundos geravam o mesmo card genérico, e os logs diziam apenas found: false. Uma linha de log transformou uma tarde de chutes numa correção de cinco minutos:
if (!res.ok) {
console.warn(JSON.stringify({ msg: "api miss", url, status: res.status }));
return null;
}
O mesmo helper também assumia um payload plano, enquanto a API retornava JSON:API (data.attributes.*). Todo campo era lido como undefined, o que gerava cards dizendo "A trip on the app", sem nenhum detalhe. Achate a resposta antes de construir qualquer coisa a partir dela.
3. A imagem tinha um Content-Type inválido
Este era o bug sutil. O mapa da viagem era servido por um serviço de redimensionamento de imagens que respondia com Content-Type: image, um tipo sem subtipo. Os navegadores nem ligam e renderizam a imagem. Facebook e LinkedIn validam a resposta da imagem e a rejeitam, então o card cai para só texto.
A função declarava og:image:type: image/png, o que não ajudava. Essa tag é só uma dica. Os scrapers confiam nos headers de resposta da própria imagem.
A correção: servir a imagem pela mesma função, em /trips/:token/og.png, e definir o tipo real lendo os magic bytes do arquivo. De bônus, a imagem passa a ficar no seu próprio domÃnio.
function sniffType(b) {
if (b[0] === 0x89 && b.toString("ascii", 1, 4) === "PNG") return "image/png";
if (b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff) return "image/jpeg";
if (b.toString("ascii", 0, 4) === "RIFF" && b.toString("ascii", 8, 12) === "WEBP") return "image/webp";
return null;
}
As function URLs da Lambda retornam conteúdo binário em base64, com isBase64Encoded: true. A correção definitiva continua sendo armazenar os arquivos com os metadados certos na origem; o relay só evita que você fique esperando por isso.
4. O fallback era um logo quadrado
Sempre que uma busca falhava, o card usava um Ãcone do app de 512x512. Toda plataforma renderiza uma imagem quadrada como uma miniatura pequena ao lado do texto, e é por isso que os previews nunca ficavam "paisagem", mesmo quando as tags estavam tecnicamente presentes. Use um JPEG de 1200x630 com a sua marca como fallback e declare og:image:width e og:image:height para que o Facebook não pule a imagem no primeiro compartilhamento.
5. Tags duplicadas
A função emitia <link rel="canonical"> duas vezes, e o build do Vite vinha com seus próprios og:title e description padrão. Com duas cópias de uma tag na página, alguns parsers leem a primeira e outros, a última. Remova do shell tudo o que é seu (title, description, canonical, todas as tags og:* e twitter:*) antes de injetar as suas.
Use também uma função como substituição no String.replace. Do contrário, uma viagem chamada Me $& my crew sai corrompida, porque $& é um padrão de substituição:
html.replace(charsetTag, () => `${charsetTag}\n${head}`);
6. Tempo: datas deslocadas e timeouts empilhados
Dois bugs de tempo, ambos clássicos. Primeiro, new Date("2026-07-16") é meia-noite em UTC, então formatar essa data num fuso horário dos EUA imprimia o dia anterior. Um valor só de data é uma data de calendário; formate em UTC. Quem trabalha com Rails conhece isso como a diferença entre Date e Time, e o JavaScript facilita esquecer.
Segundo, a função buscava a viagem primeiro no ambiente "próprio" e só depois tentava os outros, então um miss custava dois timeouts completos, cerca de cinco segundos. O WhatsApp desiste bem antes disso. Consulte todos os ambientes em paralelo e fique com o primeiro acerto, em ordem de prioridade:
async function resolve(kind, key, envs) {
const results = await Promise.all(envs.map((e) => apiGet(e.api, ENDPOINTS[kind] + encodeURIComponent(key))));
const i = results.findIndex(Boolean);
return i >= 0 ? { env: envs[i], data: results[i] } : { env: envs[0], data: null };
}
A versão enxuta
Depois de corrigir os bugs, reduzi a função de cerca de 700 para cerca de 400 linhas. A maior parte do código removido não estava errada; só não mudava nenhum preview:
- A página de preview standalone. A versão original renderizava um card estilizado no body e só servia o app de verdade atrás de um link
?view=app. Crawlers nunca olham o body, então agora todo mundo recebe o app real com o head certo, num salto só em vez de dois. - Tags do Twitter duplicadas. O X usa
og:title,og:descriptioneog:imagecomo fallback. Você só precisa detwitter:carddefinido comosummary_large_image. - Tags em que nenhum scraper se apoia, como
og:image:type,og:image:secure_urleauthor. - EstatÃsticas extras na descrição, que a empurravam além dos cerca de 300 caracteres que as plataformas exibem.
O núcleo do handler é curto:
export const handler = async (event) => {
const [, kind, key, isImage] = (event.rawPath || "").match(ROUTE) ?? [];
const envs = envOrder(event.headers);
if (isImage) return serveImage(kind, key, envs);
const shellFirst = getShell(envs[0]); // busca o shell enquanto a API responde
const { env, data } = await resolve(kind, key, envs);
const url = `${env.public}/${kind}/${key}`;
const card = data && buildCard(kind, data, url);
const tags = card || { title: "App", description: "Default tagline", url, image: null };
const shell = env === envs[0] ? await shellFirst : await getShell(env);
return {
statusCode: 200, // o Facebook rejeita de cara páginas que não retornam 200
headers: {
"Content-Type": "text/html; charset=utf-8",
"Cache-Control": card ? "public, max-age=60, stale-while-revalidate=300" : "no-store",
},
body: inject(shell, headTags(tags, env)),
};
};
Alguns detalhes importam mais do que parecem:
- Sempre retorne 200, mesmo para uma viagem desconhecida. Um 404 não gera card nenhum; um card genérico com a sua marca é melhor. Marque os misses como
no-storepara que uma CDN não fixe o card genérico numa URL válida. - Injete no topo do head, logo depois do
<meta charset>. O WhatsApp analisa mais ou menos os primeiros 300 KB da página, e o Vite pode empilhar muitos linksmodulepreloadantes das suas tags. - Reescreva os caminhos de assets relativos à raiz no shell para o seu domÃnio público, para que a página funcione no domÃnio de qualquer ambiente e os module scripts continuem same-origin.
- Faça cache do shell em memória por alguns minutos e mantenha a cópia antiga se um refresh falhar. Um shell desatualizado é melhor que um app quebrado.
Saiba o que cada plataforma realmente mostra
"Descrição rica" significa coisas diferentes em cada plataforma, e isso muda o que você deve otimizar:
- LinkedIn mostra a imagem, o tÃtulo e o domÃnio. Sem descrição.
- Facebook muitas vezes trunca ou omite a descrição, dependendo do layout.
- WhatsApp, Slack e Telegram mostram a descrição, e o WhatsApp ignora imagens maiores que cerca de 300 KB.
Então coloque o fato principal no tÃtulo. "Annapolis a St. Michaels · 23 nm" funciona em todas as plataformas. "Passeio de sábado" não funciona em nenhuma. Mantenha a imagem larga (cerca de 1,91:1, com pelo menos 600 px de largura para o card grande) e prefira JPEG para deixá-la leve.
Testando sem chutar
Simule o crawler com curl e confira tanto a página quanto a imagem para a qual ela aponta:
URL=https://app.example.com/trips/a1b2c3
for ua in "facebookexternalhit/1.1" "WhatsApp/2.24.0 A" "LinkedInBot/1.0"; do
echo "== $ua"
curl -s -A "$ua" "$URL" | grep -oE 'og:(title|description|image)" content="[^"]*'
done
curl -sI https://app.example.com/trips/a1b2c3/og.png | grep -iE 'content-(type|length)'
Depois, force um novo scrape com o Facebook Sharing Debugger e o LinkedIn Post Inspector. Os dois fazem cache agressivo, assim como o WhatsApp, que guarda o cache por URL no celular de quem envia. Adicione ?v=2 para testar um preview novo. Por fim, logue o user agent e se o card foi encontrado, em toda requisição. Quando um preview quebrar em produção, essa única linha de log diz em qual metade do sistema olhar.
Quando não fazer isso
Se o seu app Next.js pode rodar no servidor, dispense a Lambda. O generateMetadata em app/trips/[token]/page.tsx faz tudo isso nativamente, por requisição, sem regras de rewrite nem cirurgia no HTML. O padrão com Lambda se paga quando o app é um static export ou uma SPA em Vite e você não quer assumir um servidor só por causa dos previews.
E se você está no Rails, fica a dica: meta tags renderizadas no servidor são uma daquelas vantagens sem graça que você só percebe quando somem.
Checklist
- As rotas de compartilhamento são reescritas acima do catch-all da SPA, e o target mantém o prefixo do caminho.
- Os misses da API logam o status code e a URL.
- O formato do payload bate com o que o construtor do card lê.
- A resposta da imagem tem um Content-Type
image/*real, é larga e fica abaixo de cerca de 300 KB. - A imagem de fallback é 1200x630, não um logo quadrado.
- Exatamente um title, uma description, um canonical e um conjunto de tags
og:*por página. - Valores só de data são formatados em UTC.
- As buscas por ambiente rodam em paralelo, dentro de um único timeout.
- A página sempre retorna 200, e os misses não vão para o cache.
- O tÃtulo carrega o fato principal, porque o LinkedIn não mostra mais nada.
Comentários
Faça login com GitHub para comentar.
Ainda não há comentários.