Bernardo Knoblauch

/bek.no.lo.ˈʒi.a/

Blog headless multilíngue para quem escreve e edita, em Next.js 16 e Strapi 5.

Projeto pessoal · Blog headless · Node.js

Stack
Node.js, TypeScript, Next.js, Strapi, PostgreSQL, Redis, Firebase Auth, Cloudinary, Tailwind, Turborepo

Imagens

Contexto

Cada post, categoria e tag tem versão por locale no Strapi, com pt-BR como origem. Front e CMS formam um monorepo Turborepo, os dois em Node.js, com tipos em @blog/types. O front usa App Router e Tailwind.

Problema

Quem escreve precisa de um fluxo só para publicar, ver o que foi lido e definir quem acessa o texto inteiro. Deploy, cache e a conta do visitante ficam fora desse fluxo. Rascunho, SEO e mídia entram nele.

O que fiz e por quê

Arquitetura e conteúdo

  • Strapi 5 com PostgreSQL: Post, Author, Category e Tag com rascunho/publicação. O i18n é por campo: título, slug, resumo, texto e SEO mudam por locale; imagem, autor, categoria e tags são os mesmos em todas as traduções do post. O tempo de leitura é calculado no middleware do Strapi a partir do conteúdo.
  • Theme Settings, single type sem i18n, guarda duas paletas em hex. O Next injeta como custom properties e o escuro é o padrão. Sem o registro salvo, vale a paleta deste portfólio.
  • SSG e ISR. Todo path leva o locale, inclusive o padrão: / responde 307 para /pt-BR. Na publicação, um webhook invalida a página por tag, na hora (revalidateTag com expire 0). Os TTLs (300 s na API, 1 h no ISR) ficam só como rede de segurança. Post, categoria, tag e autor não usam generateStaticParams. O middleware se chama proxy.ts.
  • Páginas: home paginada, post, categoria, tag, autor, busca, 404, conta, login, cadastro e área de admin. Paginação, não scroll infinito. Uploads no Cloudinary. Permissão pública de leitura criada no bootstrap do Strapi, não à mão no painel.

Cache em Redis de ponta a ponta

  • Cache handler próprio do Next.js em Redis, sem o pacote @neshca/cache-handler. O índice por tag é um SET, para uma revalidação não apagar uma chave gravada ao mesmo tempo. O cache de ISR sobrevive a deploys e restarts. Testei derrubando o processo do Next.js e pausando o Strapi: um processo novo serviu a página a partir do cache, com a origem fora do ar.
  • Rodando o cenário de falha de verdade, achei dois bugs: a promise de conexão rejeitada ficava memorizada e desligava o cache até o fim do processo, e a reconexão padrão do cliente Redis travava toda renderização quando o Redis caía. Corrigi resetando a promise e usando reconnectStrategy: false com timeout de 2 s: com o Redis fora, a página responde em cerca de 0,5 s sem cache e volta a gravar sozinha quando ele sobe.
  • Cache da API REST do Strapi em Redis com o plugin da comunidade compatível com Strapi 5, porque o plugin previsto na especificação só suporta a v4.

Autenticação e SEO

  • Firebase Auth (e-mail/senha e Google) com papéis cumulativos leitor, assinante, editor e admin em Custom Claims, definidos só no servidor. O papel padrão entra na sessão. Session cookies, rotas protegidas no runtime Node.js e Firebase Admin SDK inicializado sob demanda. O login do painel do Strapi continua separado.
  • Post premium: o texto completo só para assinante ou acima; visitante vê um trecho. Draft Mode só para editor e admin.
  • SEO por idioma: slug real de cada locale no hreflang, no sitemap e no seletor; JSON-LD (Article, BreadcrumbList, WebSite), Open Graph gerado na hora, RSS, robots e imagem em AVIF/WebP.
  • GA4 só depois do consentimento (LGPD), com eventos de leitura e Web Vitals.

CI

  • CI no GitHub Actions com install, lint, type-check e build em todo PR e push para main. O build do Next e o do Strapi passam sem Postgres, Redis ou CMS no ar.

Resultado

  • A página pública acompanha a publicação, sem rebuild do Next.
  • Smoke test contra produção. 15 ADRs registram os trade-offs.

Próximos passos

  • Testes automatizados.
  • Domínio próprio e CDN da Cloudflare, adiados até o domínio existir.

Contato