beknología
Blog headless multilingüe para quien escribe y edita, con Next.js 16 y Strapi 5.
Proyecto personal · Blog headless · Node.js
Imágenes
Contexto
Cada post, categoría y tag tiene versión por locale en Strapi, con pt-BR como origen. Front y CMS forman un monorepo Turborepo, ambos sobre Node.js, con tipos en @blog/types. El front usa App Router y Tailwind.
Problema
Quien escribe necesita un solo flujo para publicar, ver qué se leyó y decidir quién accede al texto completo. El deploy, la caché y la cuenta del visitante quedan fuera de ese flujo. El borrador, el SEO y los medios entran en él.
Qué hice y por qué
Arquitectura y contenido
- Strapi 5 con PostgreSQL: Post, Author, Category y Tag con borrador/publicación. El i18n es por campo: título, slug, extracto, texto y SEO cambian por locale; imagen, autor, categoría y tags son los mismos en todas las traducciones del post. El tiempo de lectura se calcula en un middleware de Strapi a partir del contenido.
- Theme Settings, single type sin i18n, guarda dos paletas en hex. Next las inyecta como custom properties y lo oscuro es el predeterminado. Sin el registro, vale la paleta de este portafolio.
- SSG e ISR. Toda ruta lleva el locale, incluido el predeterminado: / responde 307 a /pt-BR. En la publicación, un webhook invalida la página por tag, al momento (revalidateTag con expire 0). Los TTL (300 s en la API, 1 h en ISR) quedan solo como red de seguridad. Post, categoría, tag y autor no usan generateStaticParams. El middleware se llama proxy.ts.
- Páginas: home paginada, post, categoría, tag, autor, búsqueda, 404, cuenta, login, registro y área de admin. Paginación, no scroll infinito. Uploads en Cloudinary. El permiso público de lectura se crea en el bootstrap de Strapi, no a mano en el panel.
Caché en Redis de punta a punta
- Cache handler propio de Next.js en Redis, sin el paquete @neshca/cache-handler. El índice por tag es un SET, para que una revalidación no borre una clave escrita al mismo tiempo. La caché de ISR sobrevive a deploys y reinicios. Lo probé matando el proceso de Next.js y pausando Strapi: un proceso nuevo sirvió la página desde la caché, con el origen caído.
- Al ejecutar el escenario de fallo de verdad encontré dos bugs: la promise de conexión rechazada quedaba memorizada y apagaba la caché hasta el final del proceso, y la reconexión por defecto del cliente Redis colgaba todo render cuando Redis se caía. Lo corregí reiniciando la promise y usando reconnectStrategy: false con timeout de 2 s: con Redis caído, la página responde en unos 0,5 s sin caché y vuelve a escribir sola cuando Redis regresa.
- Caché de la API REST de Strapi en Redis con el plugin de la comunidad compatible con Strapi 5, porque el plugin previsto en la especificación solo soporta la v4.
Autenticación y SEO
- Firebase Auth (email/contraseña y Google) con roles acumulativos lector, suscriptor, editor y admin en Custom Claims, definidos solo en el servidor. El rol por defecto entra al crear la sesión. Session cookies, rutas protegidas en el runtime de Node.js y Firebase Admin SDK inicializado bajo demanda. El login del panel de Strapi sigue aparte.
- Post premium: el texto completo es para suscriptor o superior; el visitante ve un fragmento. Draft Mode solo para editores y admins.
- SEO por idioma: slug real de cada locale en hreflang, sitemap y selector; JSON-LD (Article, BreadcrumbList, WebSite), Open Graph generado al momento, RSS, robots e imagen en AVIF/WebP.
- GA4 solo después del consentimiento (LGPD), con eventos de lectura y Web Vitals.
CI
- CI en GitHub Actions con install, lint, type-check y build en cada PR y push a main. El build de Next y el de Strapi pasan sin Postgres, Redis ni el CMS en marcha.
Resultado
- La página pública sigue la publicación, sin rebuild de Next.
- Smoke test contra producción. 15 ADRs registran los trade-offs.
Próximos pasos
- Tests automatizados.
- Dominio propio y CDN de Cloudflare, pospuestos hasta tener el dominio.