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.
5. Sitemap não é distribuição de link interno
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.