<link rel="stylesheet" href="/assets/fonts/inter/inter.css" />
All posts

Como diagnostiquei e resolvi uma crise de indexação SEO em produção: estudo de caso

Status: rascunho pessoal, aguardando revisão antes da publicação.

Este não é um guia teórico: é o relato de uma sessão real de debugging neste mesmo site — um portfólio pessoal construído com Angular 21 (standalone components, SSR/prerendering, signals), backend NestJS + MongoDB no Railway e deploy estático via FileZilla numa hospedagem Plesk. O sintoma inicial era simples de descrever e frustrante de diagnosticar: zero páginas indexadas no Google, embora o site estivesse online havia meses com conteúdo real. O que parecia um problema acabou revelando cinco, encadeados um dentro do outro.

O stack e o contexto

  • Frontend: Angular 21, standalone components, publicado como site estático pré-renderizado (nenhum servidor Node ativo em produção — o FileZilla envia o resultado do build para o Plesk).
  • Backend: NestJS + MongoDB, publicado no Railway, atrás de um rate limiter (@nestjs/throttler).
  • Conteúdo: um blog com artigos técnicos, traduzido em 7 idiomas (italiano por padrão + inglês, albanês, espanhol, português, francês, alemão).

Sintoma 1: o site não indexa nada

Primeiro diagnóstico, o mais simples: comparar o HTML que um crawler realmente recebe para um artigo antigo (que funciona) com um novo (recém-publicado).

$ curl -s https://gentsallaku.it/blog/articolo-vecchio | grep "<title>"
<title>Operatori RxJS: Guida Pratica con Casi d'Uso Reali | Gent Sallaku</title>

$ curl -s https://gentsallaku.it/blog/articolo-nuovo | grep "<title>"
<title>Gent Sallaku | Senior Front-End & API Developer</title>

O segundo comando é a prova: o crawler recebe o título genérico da homepage, não o do artigo. A causa: o site é estático, cada página é gerada (pré-renderizada) no momento do build, e cada novo artigo tinha sido inserido diretamente no banco de dados sem nunca refazer o build e o upload. O Google via, literalmente, o último build disponível — que não continha o conteúdo mais recente. Não era um bug de código, era uma falha no processo.

Sintoma 2: nem os artigos antigos estavam todos rastreados

Segunda descoberta: o sitemap.xml exposto pelo site listava só um punhado de URLs estáticas, sem uma única entrada para os artigos individuais do blog. Mesmo os posts pré-renderizados corretamente não tinham como ser descobertos sistematicamente pelo Google, a não ser pelo rastreamento orgânico dos links internos — muito mais lento do que um sitemap declarado explicitamente.

Sintoma 3: o verdadeiro banco de dados de produção se chamava "test"

Aqui o diagnóstico deixou de ser trivial. Tentando entender por que alguns conteúdos publicados nunca apareciam, verifiquei diretamente a string de conexão do MongoDB usada em produção no Railway:

MONGODB_URI=mongodb://mongo:•••@mongodb-rhkn.railway.internal:27017

Nenhum nome de banco de dados na string. Quando o driver do Mongo não encontra um path explícito, recorre silenciosamente a um banco chamado "test". O banco "correto", chamado portfolio_prod, existia de fato no mesmo servidor — mas estava abandonado, parado em onze posts de meses atrás. O site no ar, na verdade, servia conteúdo de um banco que qualquer pessoa poderia razoavelmente supor que estivesse livre para testes destrutivos.

A correção exigiu três passos, nesta ordem, para não perder dados reais no meio do caminho:

  1. mongodump do banco de dados test (28 posts, milhares de visualizações, dados de consentimento GDPR — tudo conteúdo real)
  2. mongorestore --drop dentro de portfolio_prod, no mesmo servidor
  3. Atualização da variável MONGODB_URI no Railway para apontar explicitamente para portfolio_prod, com o consequente redeploy automático

Verificado com uma chamada direta à API logo após a troca, para confirmar zero perda de dados antes de dar o capítulo por encerrado.

Sintoma 4: as páginas pré-renderizadas estavam sempre em inglês

Ao verificar o conteúdo real das páginas estáticas geradas no build, um detalhe destoava: o texto da homepage — bio, seções, rótulos — aparecia sempre em inglês, até para o único idioma que deveria ser o padrão: o italiano.

export function resolveInitialLanguage(): Lang {
  if (typeof localStorage !== 'undefined') { /* ... */ }
  if (typeof navigator !== 'undefined') { /* ... */ }
  return 'en'; // ← eseguito SEMPRE durante il prerendering: Node non ha
               //    né localStorage né navigator
}

Durante o prerendering, o ambiente é Node — não existe navegador, então nem localStorage nem navigator estão disponíveis, e a função sempre recorria ao valor fixo no código 'en'. Cada página estática do site, desde sempre, tinha sido gerada em inglês, independentemente do idioma declarado.

Sintoma 5: hreflang mentindo ao Google

A última descoberta foi a mais traiçoeira porque tudo parecia correto: cada página declarava corretamente 7 tags hreflang, uma por idioma, segundo as boas práticas. O problema estava no formato das URLs declaradas:

<link rel="alternate" hreflang="en" href="https://gentsallaku.it/blog/articolo?lang=en" />
<link rel="alternate" hreflang="es" href="https://gentsallaku.it/blog/articolo?lang=es" />

Nenhuma página da aplicação lia aquele parâmetro ?lang=. Cada variante declarada resolvia, na prática, para o mesmíssimo HTML (o inglês citado acima). O Google recebia 7 declarações de conteúdo em idiomas diferentes que levavam todas à mesma página — um sinal que, na melhor das hipóteses, era ignorado e, na pior, reduzia a confiança geral no site.

A solução estrutural: URLs realmente prefixadas por idioma

A correção certa não era um parâmetro a ser lido, mas uma mudança de arquitetura: URLs realmente distintas por idioma (/en/blog/articolo, /es/blog/articolo...), para que o prerenderer gerasse conteúdo realmente diferente para cada uma, e não o mesmo arquivo repetido sete vezes com um rótulo diferente.

Primeira tentativa, e por que não funcionou

O primeiro design usava um UrlMatcher personalizado para interceptar o prefixo de idioma sem duplicar toda a árvore de rotas. Correto no papel — mas o primeiro build real quebrou todas as páginas existentes, não só as novas:

✘ ERROR: The 'homepage' server route does not match any routes
  defined in the Angular routing configuration.

O prerenderer do Angular recusa explicitamente o prerendering de qualquer rota com um matcher personalizado e, mais acima, nem desce aos seus children durante a validação cruzada cliente/servidor. Uma limitação não documentada de forma clara, descoberta só ao tentar o build de verdade — exatamente o tipo de risco que nenhuma análise estática do código poderia prever com certeza.

A solução: substituir o matcher por uma rota path: ':lang' protegida por um guard canMatch — mesmo comportamento (ignora segmentos que não são códigos de idioma válidos, ex. /dashboard), mas expresso como um segmento de path real, que o prerenderer sabe percorrer.

O efeito colateral escondido: o rate limiter

Com a nova estrutura funcionando, um segundo problema apareceu só depois de estender o prerendering a todos os idiomas: cerca de um quarto dos posts, de forma aparentemente aleatória, era gerado como "artigo não encontrado" em vez do conteúdo real.

A causa: gerar 29 posts × 7 idiomas significa disparar mais de 200 chamadas à API do blog em menos de um minuto, todas do mesmo IP da máquina de build — ultrapassando o rate limit padrão do backend (60 requisições/60 segundos). Uma primeira tentativa de correção com retries no cliente piorou a situação (páginas travadas no meio do carregamento, porque o prerenderer do Angular tem um tempo máximo de espera por rota e os retries o ultrapassavam). A correção certa estava antes: aumentar o rate limit especificamente nos dois endpoints públicos e somente leitura do blog, mantendo inalterada a proteção no login e nos formulários:

@Get('posts/:slug')
@Throttle({ default: { limit: 300, ttl: 60000 } }) // da 60 a 300 su questo endpoint
@ApiOperation({ summary: 'Get published post by slug (public)' })
findBySlug(@Param('slug') slug: string) {
  return this.blogService.findBySlug(slug);
}

Resultados mensuráveis

MétricaAntesDepois
Páginas estáticas geradas no build45273+
Entradas no sitemap.xml~40 (nenhum post individual)242
Variantes hreflang funcionando0 de 7 (mesmo HTML para todas)7 de 7, conteúdo realmente distinto
Banco de dados de produção"test" (implícito, não declarado)portfolio_prod (explícito)
Taxa de posts "não encontrado" no build multilíngue~25%0%

Lições aprendidas

  • Verifique sempre com curl, não a olho: o título genérico da homepage no lugar do título do artigo só foi identificado comparando os bytes reais recebidos por um crawler, e não olhando o site num navegador normal (que esconde esses problemas por executar o JavaScript de qualquer jeito).
  • Um nome de banco de dados implícito é um risco real, não só um descuido estético: qualquer pessoa que tivesse rodado um script "só para teste" contra um banco chamado literalmente test poderia ter alterado dados de produção sem saber.
  • Dados estruturados "corretos no papel" precisam ser verificados de ponta a ponta: o hreflang era sintaticamente perfeito, mas semanticamente falso, porque ninguém tinha verificado se as variantes declaradas levavam de fato a conteúdos diferentes.
  • Limitações não documentadas só aparecem ao tentar o build real: nenhuma leitura do código ou da documentação teria revelado que o Angular recusa o prerendering em rotas com matcher — só o erro de build deixou isso evidente.
  • Uma correção "defensiva" pode piorar um sintoma em vez de resolvê-lo: o retry no cliente parecia a solução óbvia para o rate limiting e, em vez disso, introduziu uma falha silenciosa pior (páginas travadas) do que a que deveria resolver.

💬 Notas dos leitores

0 notas

Escreva uma nota

Partilhe a sua opinião, uma sugestão ou um elogio

Notas recentes

Ainda não há notas. Seja o primeiro a comentar!