Skip to content
Anahê ecosystem · 05/05api.anahe.com.br

Anahê · API

What sits behind the three sites and the dashboard.

Role
Architecture & Back-end
Year
2026
Status
Live

— Ecosystem

Four apps, one API

Anahê Canavezzi is a writer and a designer. She needed three public sites (the book, the studio and the blog) and a dashboard to manage all of it without depending on me. I built the four Next.js apps in a Turborepo and pnpm monorepo, served by a single NestJS API and a single database.

Architecture decision

I considered micro-frontends and dropped the idea: there are no multiple teams and no runtime composition. These are four sites sharing a design system, types and schemas, which is exactly what Turborepo is for.

PostgreSQL · Redis · R2Railway · Cloudflare

One dashboard and one API manage the content of all three public sites.

— The story

How I built each part

Chapter 01Turborepo

A monorepo with dependency rules

The four Next.js apps and the API live in one repository, with Turborepo and pnpm workspaces. Apps import packages; packages never import apps. Among the product apps, only the API imports packages/db, so no site touches the database directly: business rules and cache invalidation live in one place.

Diagram · Turborepo
apps/
writerNext.js 16studioNext.js 16blogNext.js 16adminNext.js 16apiNestJS 11
import
packages/
uishadcn + tokenssharedZod schemasapi-clienttyped fetcherscontent-renderereditor blocksanalytics<Tracker />emailsReact Emailconfigeslint · tsconfig · tailwind
of the product apps, only api imports
packages/db
schema.prismamigrationsseedPostgreSQL 18

The Next.js apps never touch the database. They read and write through the API, using the types from packages/shared.

  • packages/shared depends only on zod
  • Turbo cache in CI: what didn't change doesn't run again

Chapter 02/public · /admin

Two surfaces, two trust levels

/public/* serves the sites: no login, mostly read-only, only the three sites as origins and Turnstile on the forms. /admin/* only accepts the dashboard as origin, with cookies, and goes through CSRF, JWT and roles. Both validate the body with the same Zod schema that validates the form.

Diagram · /public · /admin
/public/*sites · no login
HelmetCORS (sites)Throttler · RedisTurnstile (forms)ZodValidationPipeController
/admin/*dashboard · cookies
HelmetCORS (dashboard)Throttler · RedisCsrfGuardAdminAuthGuard · JWT + rolesZodValidationPipeController
both end in the same service
ServicePrisma 7PostgreSQL 18
  • nestjs-zod builds DTOs from packages/shared
  • Errors shaped as { code, message, details }

Chapter 03Modules

23 modules, one per responsibility

Each domain became a NestJS module: the content of the three sites, the people (dashboard, readers, leads and briefs) and the platform holding it all up. Inside content, a collections registry gives any dashboard list reordering and show/hide.

Diagram · Modules
Content5 modules
postscontentmediapublicrevalidation
People6 modules
authmereadersleadsbriefingsaudit
Platform12 modules
configloggerprismaredisstoragemailjobsanalyticsdashboardcryptoturnstilehealth

Highlighted are the modules the others rely on: content (collections that reorder and hide), revalidation (site cache) and jobs (queues).

  • config validates env with Zod and fails on boot
  • Swagger at /docs outside production

Chapter 04Publishing

Publishing wakes up only what changed

The public sites keep their data cached by tag. When Anahê publishes, the API writes to the database and queues the revalidation of each affected site, with the right tags. If a site is down, the job retries, and the action in the dashboard never fails because of it.

Diagram · Publishing
  1. Dashboard → API: POST /admin/posts/:id/publish
  2. API → Postgres: status = PUBLISHED
  3. API → BullMQ: revalidation queue · blog, writer
  4. API → Dashboard: 200 OK
  5. BullMQ → Blog: POST /api/revalidate · posts, post:slug, archive

Each site calls revalidateTag() only on the tags it receives, and the rest stays cached. The writer site gets posts the same way, and writings when the text goes there too.

  • x-revalidate-secret header
  • Reactions and comments load client-side, outside the cache

Chapter 05BullMQ

What runs outside the request

Anything that can wait goes to BullMQ, on Redis, in four queues. A scheduled post becomes a delayed job, and a safety job every 5 minutes publishes anything left behind if Redis loses a job. Emails and revalidations retry with exponential backoff; metrics and cleanups run on a schedule.

Diagram · BullMQ
Redis 7 · BullMQ4 queues
  • Schedule postpublishing · delayPublishes and revalidates
  • Post publishedrevalidationPOST /api/revalidate on the sites
  • Lead · comment · sign-in codemailResend + React Email
  • Every 5 minmaintenancePublishes overdue scheduled posts
  • DailymaintenancePageView → daily summary
  • DailymaintenanceDeletes unconfirmed readers > 7 days
  • Safety job every 5 min
  • LGPD cleanup of pending items older than 7 days

Chapter 06Reader · BFF

Commenting without an account or password

Readers confirm their email once per device. Since the API is on another subdomain, the blog acts as a BFF: it keeps the token in a blog-only httpOnly cookie and forwards it to the API in the x-reader-token header. The API stores only the hash.

Diagram · Reader · BFF
  1. Reader → Blog · BFF: enters an email
  2. Blog · BFF → API: POST /public/readers/identify
  3. API → E-mail: confirmation link · 30 min
  4. E-mail → Reader: opens the link
  5. Reader → Blog · BFF: /confirmar?token
  6. Blog · BFF → API: POST /public/readers/verify
  7. API → Blog · BFF: session token
  8. Blog · BFF → Reader: cookie reader_session · httpOnly

The cookie belongs to the blog alone. Issued by the API, it would need the root domain and the session would leak to the other subdomains.

  • Single-use confirmation link, 30 min
  • 1-year session per device

Chapter 07Deploy

Where each part runs

The .br domain stays at Registro.br, with DNS on Cloudflare. The sites and the dashboard run on Vercel with DNS only, as Vercel recommends. The API, Postgres and Redis run on Railway. Cloudflare still serves the R2 media bucket and the Turnstile on the forms.

Diagram · Deploy
Registro.br.br domainCloudflareDNS · DNSSEC
VercelDNS only
writerstudioblogadmin
RailwayDNS only
apiPostgreSQL 18Redis 7
the API also talks to
Cloudflare R2mediaResende-mail

In development, Docker Compose runs Postgres, Redis, RustFS and Maildev in place of the production services.

  • Full (strict) SSL on the zone
  • Public media at media.anahe.com.br

— Technologies

API

  • NestJS 11
  • nestjs-zod
  • Passport JWT
  • @nestjs/throttler
  • @nestjs/swagger
  • pino
  • Helmet

Data

  • Prisma 7
  • PostgreSQL 18
  • Redis 7
  • BullMQ

Infra

  • Railway
  • Cloudflare
  • R2
  • Resend
  • Sentry

Tests · CI

  • Vitest
  • Supertest
  • GitHub Actions

E2E

  • Playwright
  • axe-core