Bernardo Knoblauch

beknology

A multilingual headless blog for writers and editors, on Next.js 16 and Strapi 5.

Personal project · Headless blog · Node.js

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

Images

Context

Each post, category and tag has a per-locale version in Strapi, with pt-BR as the source. Front end and CMS are one Turborepo on Node.js, with shared types in @blog/types. The front end uses the App Router and Tailwind.

Problem

Writers need one flow to publish, see what was read and decide who gets the full text. Deploys, the cache and visitor accounts stay out of that flow. Drafts, SEO and media stay in it.

What I did and why

Architecture and content

  • Strapi 5 on PostgreSQL: Post, Author, Category and Tag with draft/publish. i18n is per field: title, slug, excerpt, body and SEO change per locale; image, author, category and tags stay the same across a post's translations. Reading time is computed in a Strapi middleware from the content.
  • Theme Settings, a single type with no i18n, stores two hex palettes. Next injects them as custom properties, dark by default. With no saved record, this portfolio's palette is the fallback.
  • SSG and ISR. Every path carries the locale, including the default: / answers 307 to /pt-BR. On publish, a webhook invalidates the page by tag immediately (revalidateTag with expire 0). The TTLs (300 s on the API, 1 h on ISR) are only a safety net. Post, category, tag and author routes do not use generateStaticParams. The middleware file is proxy.ts.
  • Pages: paginated home, post, category, tag, author, search, 404, account, login, sign-up and an admin area. Pagination, not infinite scroll. Uploads on Cloudinary. Public read permission is created in the Strapi bootstrap, not by hand in the admin panel.

End-to-end Redis caching

  • A custom Next.js cache handler on Redis, without the @neshca/cache-handler package. The tag index is a SET, so one revalidation does not drop a key written at the same time. The ISR cache survives deploys and restarts. I tested it by killing the Next.js process and pausing Strapi: a fresh process served the page from cache with the origin down.
  • Running the failure scenario for real, I found two bugs: a rejected connection promise stayed memoized and turned the cache off for the rest of the process, and the Redis client's default reconnection hung every render when Redis went down. I fixed them by resetting the promise and using reconnectStrategy: false with a 2 s timeout: with Redis down, the page responds in about 0.5 s without cache, and it starts writing again on its own once Redis is back.
  • Strapi REST API caching on Redis with the community plugin that supports Strapi 5, since the plugin named in the spec only supports v4.

Authentication and SEO

  • Firebase Auth (email/password and Google) with cumulative reader, subscriber, editor and admin roles in Custom Claims, set only on the server. The default role is assigned when the session is created. Session cookies, protected routes on the Node.js runtime and a lazily initialized Firebase Admin SDK. The Strapi admin login stays separate.
  • Premium posts: the full text is for subscribers and above; everyone else sees an excerpt. Draft Mode is limited to editors and admins.
  • Per-language SEO: the real slug of each locale in hreflang, the sitemap and the switcher; JSON-LD (Article, BreadcrumbList, WebSite), Open Graph generated on the fly, RSS, robots and AVIF/WebP images.
  • GA4 only after consent (LGPD), with reading events and Web Vitals.

CI

  • CI on GitHub Actions running install, lint, type-check and build on every PR and push to main. The Next and Strapi builds pass without Postgres, Redis or the CMS running.

Outcome

  • The public page follows publication, with no Next rebuild.
  • A smoke test against production. 15 ADRs record the trade-offs.

Next steps

  • Automated tests.
  • A custom domain and Cloudflare CDN, postponed until the domain exists.

Contact