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:
mongodumpdo banco de dadostest(28 posts, milhares de visualizações, dados de consentimento GDPR — tudo conteúdo real)mongorestore --dropdentro deportfolio_prod, no mesmo servidor- Atualização da variável
MONGODB_URIno Railway para apontar explicitamente paraportfolio_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étrica | Antes | Depois |
|---|---|---|
| Páginas estáticas geradas no build | 45 | 273+ |
| Entradas no sitemap.xml | ~40 (nenhum post individual) | 242 |
| Variantes hreflang funcionando | 0 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
testpoderia 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.