3

Meu site tinha 595 alimentos e 0 páginas indexáveis e o culpado era eu.

Mantenho o Taco Explorer, um portal em cima da Tabela Brasileira de Composição de Alimentos (TACO, da Unicamp): 595 alimentos com macros, micronutrientes, calculadoras, comparador e uma API pública.

Semana passada fui olhar o Google Analytics achando que ia decidir qual feature nova construir. Saí de lá com a certeza de que eu tinha construído a coisa errada por meses.

O diagnóstico

A página de alimentos é uma SPA: React Query, tabela paginada, filtros de vegano/sem glúten/proteína mínima, e o detalhe do alimento abrindo num <dialog>. Funciona muito bem. É rápida, é bonita, o pessoal usa.

E é completamente invisível para o Google.

O HTML inicial que sai do servidor não tem um único nome de alimento. Nenhum link para alimento nenhum. Os 595 itens só existem depois que o JS hidrata e o React Query resolve. Na prática eu tinha 7 URLs no sitemap — e uma delas, descobri depois, apontava para uma rota que nem existe (/calculadora, sendo que a rota real é /calculadoras).

Enquanto isso, o que as pessoas digitam no Google é:

  • "quantas calorias tem arroz integral cozido"
  • "tabela nutricional feijão carioca"
  • "quantos gramas tem uma colher de sopa de aveia"

Eu tinha a resposta exata para as 595 variações disso. Guardada atrás de um onClick.

O que fiz

Migrei o detalhe do alimento de dialog para rota estática de verdade, no App Router:

/alimentos/1-arroz-integral-cozido
/alimentos/12-feijao-carioca-cozido
...

generateStaticParams puxa a base inteira e pré-renderiza as 595 páginas, com ISR de 24h. Cada uma tem:

  • H1 e <title> no formato da pergunta ("Arroz integral cozido: calorias e tabela nutricional")
  • tabela de porções renderizada no servidor (25/50/100/150/200g + medidas caseiras). Isso é texto no HTML, não JS
  • micronutrientes, FAQ com os números reais do alimento
  • JSON-LD: BreadcrumbList + FAQPage + NutritionInformation
  • uma calculadora de gramas que roda no cliente escalando os valores por 100g — sem bater na API, porque é só regra de três e não tem motivo para gastar request nisso

Resultado do build: de 7 para 624 URLs no sitemap, 636 páginas geradas em 52s.

As pedras no caminho (a parte útil do post)

1. Meu próprio rate limit quase derrubou o deploy

Meu backend tem um rate limit caseiro de 60 req/min, chaveado por assinante ou por IP. Pré-renderizar 595 páginas dispara algumas centenas de requisições em sequência, do build para a minha própria API.

Ou seja: eu tinha escrito um rate limit que bloqueava eu mesmo. O build inteiro ia morrer em 429.

A correção foi isentar o tráfego interno — o site consumindo a própria API com a chave de admin:

const adminKey = process.env.ADMIN_API_KEY
if (adminKey && request.headers['x-api-key'] === adminKey) return

O adminKey && não é decoração: sem ele, se a variável estivesse indefinida, uma request sem nenhum header passaria batido (undefined === undefined). Rate limit com bypass acidental é pior que rate limit nenhum, porque você acha que está protegido.

2. Slug bonito me custaria uma varredura na base

A ideia inicial era /alimentos/arroz-integral-cozido. Aí lembrei que descrição da TACO colide quando normalizada — tem entrada com vírgula, sem vírgula, com e sem acento, que viram exatamente o mesmo slug. E resolver slug → id sem o id no meio exigiria varrer a base a cada request.

Fui de /alimentos/{id}-{slug}, estilo Stack Overflow. Feio? Um pouco. Mas o parse é um regex e nunca colide:

export function parseFoodSlug(slug: string): number | null {
  const match = /^(\d+)(?:-|$)/.exec(slug)
  if (!match) return null
  const id = Number(match[1])
  return Number.isSafeInteger(id) && id > 0 ? id : null
}

3. A og-image que nunca existiu

Meu metadata apontava para /og-image.jpg desde sempre. O arquivo nunca foi criado. Todo link que eu compartilhei em grupo de WhatsApp, LinkedIn, onde fosse, saiu sem preview — pelo tempo inteiro que o site está no ar.

Troquei por app/opengraph-image.tsx, com next/og gerando a imagem. E fui além: cada alimento tem a própria OG image, com as calorias e os macros dentro do card. Ela é gerada sob demanda e cacheada, então não custa nada no build.

Detalhe que me pegou: se você declara openGraph.images no metadata, ele sobrescreve o arquivo opengraph-image.tsx. Tive que remover a declaração para a convenção de arquivo valer.

4. capitalize não é o que você acha

A coluna de porções ficou mostrando "25 G", "100 G". O culpado foi o capitalize do Tailwind, que maiúscula a primeira letra de cada palavra. Trocado por first-letter:uppercase.

Botar as 624 URLs no sitemap resolve descoberta, não autoridade. Como a listagem continua client-side, nenhuma página estática linkava para as páginas de alimento.

Criei hubs por categoria (/alimentos/categoria/frutas-e-derivados), server-rendered, e coloquei as 15 categorias no footer de todas as páginas. Aí o caminho fecha: qualquer página → footer → categoria → os alimentos dela → alimentos relacionados.

Como testei sem a API de produção

A API de produção passou a exigir gateway, então eu não conseguia buildar localmente contra ela. Em vez de mockar resposta por resposta, subi um servidor HTTP de 150 linhas servindo o CSV original da TACO nos mesmos formatos dos endpoints reais.

Buildei contra isso: 636/636 páginas, tsc limpo, 404 correto para slug inválido e para id inexistente, OG image devolvendo PNG. Levou 15 minutos para escrever e me deu confiança real, em vez de "deve funcionar em prod".

O que eu não sei ainda

Se vai funcionar. Acabei de subir. SEO tem ciclo de semanas e eu vou descobrir junto com vocês se conteúdo de tabela nutricional gerado a partir de base pública ranqueia ou se o Google trata como thin content — o que seria justo, considerando que a fonte dos dados é a mesma que todo mundo tem.

A aposta é que a combinação de dado oficial + medida caseira + porção calculada + a busca sendo hiperespecífica ("colher de sopa de aveia", não "aveia") dê alguma diferenciação.

Se alguém aqui já passou por migração de SPA para SSG focada em busca orgânica e tem números do antes/depois, eu quero muito ouvir. Principalmente sobre quanto tempo levou para o Google começar a indexar volume dessa ordem.

Carregando publicação patrocinada...
4
1