Skip to content
RubyInsights
Links da sua SPA viram cards em branco: corrigindo previews com uma Lambda na frente do Amplify

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.

Publicado em 12 min de leitura

Também disponível em English

Compartilhar

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:

  1. O Amplify recebe GET /trips/a1b2c3.
  2. Uma regra de rewrite (status 200, para a URL não mudar) faz proxy desse caminho para uma function URL da Lambda.
  3. A Lambda busca a viagem na API e busca o index.html do app.
  4. 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.
  5. 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:description e og:image como fallback. Você só precisa de twitter:card definido como summary_large_image.
  • Tags em que nenhum scraper se apoia, como og:image:type, og:image:secure_url e author.
  • 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-store para 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 links modulepreload antes 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.