Ga naar inhoud

Architectuur

Status: levend document. Laatst bijgewerkt: augustus 2026. Dit document is de primaire context voor AI-agents die in deze repo werken. Wijk er niet van af zonder het bij te werken.

┌───────────────────────────┐
│ Master Dashboard (Nuxt) │
│ - klanten CRUD │
│ - provisioning-service │
│ - stats/audit aggregatie │
└───────┬───────────┬────────┘
│ │
Coolify v4 API │ │ Cloudflare for SaaS API
(app, env, deploy) │ │ (custom hostnames + TLS)
▼ ▼
┌──────────────────────────────────────────────────────────┐
│ Coolify op Hetzner VPS (Docker) │
│ ┌────────────┐ ┌────────────┐ ┌────────────┐ │
│ │ site: acme │ │ site: bravo│ │ site: ... │ (Astro SSR)│
│ │ TENANT_ID │ │ TENANT_ID │ │ │ │
│ └─────┬──────┘ └─────┬──────┘ └─────┬──────┘ │
│ │ │ │ │
│ ┌─────▼──────────────▼──────────────▼───────┐ │
│ │ Postgres (database-per-tenant) + backups │ │
│ └───────────────────────────────────────────┘ │
│ ┌────────┐ ┌──────────────┐ │
│ │ Umami │ │ R2/B2 media │ │
│ └────────┘ └──────────────┘ │
└──────────────────────────────────────────────────────────┘

Eén codebase, per klant een eigen deployment met eigen database en eigen domein.

platform/
├── apps/
│ ├── site-template/ # Astro SSR — het template dat per klant wordt gedeployed
│ ├── master-dashboard/ # Nuxt — centraal beheer (fase 2)
│ └── docs/ # Astro Starlight — deze documentatie (src/content/docs/)
├── packages/
│ ├── contract/ # @platform/contract — types + Zod-schema's, single source of truth
│ ├── db/ # @platform/db — tenant-schema + migraties + connectie
│ │ # @platform/db/master — master-schema, eigen migraties (drizzle-master/)
│ ├── blocks/ # @platform/blocks — block-registry (schema + renderer per blok)
│ └── ops/ # @platform/ops — bootstrap, users, backup, restore, verify
├── tenants/ # Bootstrap JSON configs voor tenant-content
├── scripts/ # provision-tenant-db, media-backup, dump-verify
├── .cursor/rules/
├── AGENTS.md
├── pnpm-workspace.yaml
├── turbo.json
└── tsconfig.base.json

@platform/core en @platform/ui worden pas aangemaakt als er daadwerkelijk gedeelde code is die nergens anders thuishoort. Niet vooruit scaffolden.

Vanaf taak 2.4 is de tenants-tabel in de master-database de bron voor welke klanten bestaan. De JSON-bestanden onder tenants/ blijven invoer voor de content-bootstrap; ze zijn geen tweede klantenregister en worden niet automatisch in het dashboard geïmporteerd.

Onderdeel Keuze Waarom Alternatief
Monorepo pnpm workspaces + Turborepo Gedeelde types zonder version-drift; volwassen standaard Nx als module-boundaries afgedwongen moeten worden
Klantsite Astro 7.2, SSR, Node-adapter, Tailwind 4 (@tailwindcss/vite) Content-site met dynamische delen; Server Islands
Master dashboard Nuxt Data-rijke app-UI; bestaande ervaring Astro SSR
Database Postgres + Drizzle, database-per-tenant Volledige isolatie, voorspelbare kosten, sterke SQL Turso (platform in transitie), Neon (branching)
Auth Better Auth De-facto standaard 2026 Auth.js (security-only onderhoud)
Hosting Coolify op Hetzner (Docker) Bestaande ervaring, API voor provisioning, vaste kosten Vercel for Platforms (nul ops, duurder bij veel sites)
Custom domains Cloudflare for SaaS Automatische TLS per klantdomein, $0,10/hostname na 100 gratis Coolify + Traefik DNS-01
Media S3-compatible (R2 of B2; lokaal MinIO) Geen egress-kosten bij R2; één client voor alle providers Lokaal volume (niet aan te raden bij meerdere sites)
Analytics Umami self-hosted Licht, multi-site uit één installatie, REST API Plausible (zwaarder), Cloudflare Web Analytics
Pagebuilder (fase 3) Puck Open-source, self-hosted, mapt op eigen componenten GrapesJS, of overstap naar Payload CMS 3

Lucia niet gebruiken — deprecated sinds maart 2025.

1. Huisstijl komt uit data, nooit uit code

Section titled “1. Huisstijl komt uit data, nooit uit code”

Componenten en blokken bevatten geen concrete kleuren, fonts, radii of spacing-waarden. Geen bg-blue-600, geen #1a1a1a, geen font-['Inter'].

De theme van een tenant staat als jsonb op de site-rij (themeSchema in @platform/contract), wordt bij SSR omgezet naar CSS custom properties op de layout-root via themeToCssVars(), en Tailwind v4-utilities (bg-primary, font-heading, rounded-md, …) verwijzen naar die variabelen via @theme inline in global.css. themeToCssVars() is de enige plek waar theme-waarden naar CSS worden vertaald — voeg geen tweede vertaallaag toe.

Toets: verander de primaire kleur in de database, herlaad, en de site verandert mee zonder code-aanpassing.

2. Een pagina is een geordende reeks blokken

Section titled “2. Een pagina is een geordende reeks blokken”

Content wordt opgeslagen als blocks (jsonb): een array van objecten met een type-discriminator. Het TypeScript-type is een discriminated union in @platform/contract, gevalideerd met Zod bij schrijven én bij lezen. Nooit ongevalideerde JSON opslaan of renderen.

Dit is de aanname waar de pagebuilder op leunt. Voeg nooit een paginatype toe met vaste contentvelden buiten dit model om.

Elk type of Zod-schema dat zowel de klantsite als het master dashboard raakt, staat in @platform/contract en nergens anders. Herdefinieer nooit lokaal “even snel” een type dat daar al bestaat.

TENANT_ID, DATABASE_URL en SITE_DOMAIN komen via astro:env als server-secrets (runtime, niet geïnline’d bij build). Zo kan één image per template-versie meerdere tenants dienen via container-env. Nooit rechtstreeks process.env lezen, nooit een globale databaseconnectie. getDb(databaseUrl) krijgt de connectiestring altijd meegegeven.

In het master dashboard is useRuntimeConfig() het equivalent: private keys in nuxt.config.ts, gevuld met NUXT_DATABASE_URL, NUXT_BETTER_AUTH_SECRET, NUXT_BETTER_AUTH_URL en NUXT_SECRETS_KEY. Ook daar geen process.env in app- of servercode, en getMasterDb(databaseUrl) krijgt de string altijd mee. De master heeft géén tenant-theme, dus regel 1 geldt daar niet: gewone Tailwind-utilities zijn in dat dashboard juist correct.

Code, configuratie, bestandsnamen, tabelnamen, kolomnamen, variabelen en commit-berichten in het Engels. Commentaar en documentatie mogen Nederlands.

Astro 7.2 verscheen op 6 augustus 2026 en zit niet in de trainingsdata van de meeste modellen. Hetzelfde geldt voor recente Tailwind-, Drizzle- en Turborepo-releases, waar de configuratiesyntaxis tussen majors verschilt. Controleer versies via npm view voordat je configuratie schrijft.

Volledige SSR met Server Islands voor dynamische delen. Reden: de klant publiceert en ziet het direct, zonder rebuild-latency.

Daarbovenop de route-cache van Astro 7 (config-optie cache, stabiel sinds 7.0 — niet te verwarren met de experimentele incremental static builds, die we niet gebruiken zolang ze experimenteel zijn). Provider: memoryCache, geconfigureerd in apps/site-template/astro.config.mjs.

De cache-handler wrapt de hele middleware-keten (geverifieerd in astro/dist/core/routing/handler.js: AstroHandler.render() geeft this.#astroMiddleware.handle(...) door aan this.#cacheHandler.handle() als diens next; memoryCache’s onRequest roept die next alleen aan bij een MISS of een STALE-revalidatie). Op een HIT draait er dus geen sessiecheck, geen render en geen query — een bezoeker raakt de database niet. Lokaal gemeten op de gebouwde server (node dist/server/entry.mjs, 26 augustus 2026, vijf publieke routes): circa 7–18 ms bij een MISS tegen circa 1–2,5 ms bij een HIT. Indicatief, niet een productiebenchmark.

Twee helften, beide in apps/site-template/src/lib/cache.ts:

Waar Wat
Zetten cachePublicRoute(Astro.cache, tags) in de publieke pagina’s maxAge 300 s, swr 3600 s, plus tags
Purgen purgePublicCache / purgeWholeSite in de CMS-actions invalidatie per tag of per pad

maxAge is kort en swr lang omdat publiceren expliciet purge’t: de TTL is een vangnet, niet het mechanisme. Mist een actie een purge, dan loopt de pagina binnen vijf minuten bij in plaats van tot de volgende deploy oud te blijven, en na maxAge serveert Astro de oude HTML meteen terwijl hij op de achtergrond hertekent.

Tags: elke publieke response krijgt public, plus een specifieke tag. Eén contentrij = <tabel>:<id> (op id, niet op slug — een slug kan wijzigen); overzichtspagina’s krijgen services / news / testimonials, de sitemap krijgt sitemap. Wijzigt een slug, dan purgen de acties óók de oude URL via path, anders blijft die de oude HTML serveren. Navigatie, huisstijl en alt-teksten staan op elke pagina en purgen daarom public.

De cache-key bevat de origin (memory-provider.js: `${url.origin}${url.pathname}...`), dus CMS-content op admin.<SITE_DOMAIN> deelt geen entries met de publieke host, ook al komen pathnames overeen. Bekende tracking-parameters (utm_*, fbclid, gclid, …) tellen standaard niet mee in de cache-key, en de provider houdt maximaal 500 entries bij (LRU) — een klant kan de cache dus niet laten volstromen via url-parameters.

Astro stuurt Cache-Control en Cache-Tag niet door naar de browser (de cache-handler verwijdert ze). Bezoekers cachen dus niets zelf en een purge is meteen zichtbaar.

Val in de middleware nooit terug op de statische Response.redirect-helper: die geeft een response met immutable headers, en de cache-handler wil er headers op zetten. Dat gaf een TypeError: immutable op elke redirect (/admin zonder sessie, /login op de publieke host, …). src/middleware.ts heeft daarom een eigen redirect() die new Response gebruikt.

Alleen in productiemodus. astro dev levert onvoorwaardelijk een no-op cache (NoopAstroCache, astro/dist/core/cache/handler.js) — geen enkele HIT, ongeacht provider-config. Cache-gedrag testen kan dus niet met de dev-server; dat vraagt pnpm run build gevolgd door node dist/server/entry.mjs (of astro preview).

Beperking: memoryCache leeft in het proces. Elke deploy of restart begint koud, en bij meer dan één replica per tenant heeft elke replica een eigen cache die de invalidate van de ander mist. Wordt dat relevant, dan neemt CacheProviderConfig een eigen entrypoint: een provider is niets meer dan onRequest plus invalidate.

Eén Docker-image van site-template (apps/site-template/Dockerfile, build-context = repo-root), per klant een container met eigen runtime-env. Public astro:env-vars (S3_PUBLIC_URL, EMAIL_FROM, …) worden bij de build ingebakken — zie Deploy. Postgres draait als aparte Coolify-resource met scheduled backups van alle databases; media heeft een aparte rclone-cron (Backups).

Bij 10–50 klanten volstaat een Hetzner CX42 (8 vCPU / 16 GB); reken op enkele honderden MB RAM per actieve SSR-site.

Template-update = één git-push. Coolify redeployt de klant-apps, hetzij via auto-deploy, hetzij via een script dat over alle app-UUID’s een deploy triggert.

Niet per klant forken. Zodra klantsites eigen code krijgen, verlies je de “update alles in één keer”-eigenschap. Klantverschillen horen in data (theme, blokken, content), niet in code.

Migraties voor bestaande tenants lopen via de runner in het master dashboard (/migrations, taak 2.8): schemastand per tenant, sequentieel toepassen, run-historie in migration_runs. Volgorde bij een schemawijziging: master deployen → migraties → sites uitrollen; migraties zijn daarom additief. Pre-deployment in Coolify (node /app/scripts/migrate.mjs) blijft het idempotente vangnet. Zie Deploy. Healthcheck: GET /robots.txt (geen DB). Na een database-restore altijd de app-container herstarten — getDb() cachet pools per URL.

  • Master ↔ klantsite: API-key per site plus HMAC-gesigneerde webhooks. Site → master is POST /api/webhooks/site op het dashboard (Contract).
  • CMS-auth leeft alleen op admin.<SITE_DOMAIN>. Better Auth trustedOrigins is die origin, niet de publieke site (same-site, andere origin). Session cookies zijn host-only.
  • Secrets uitsluitend via environment-variabelen, nooit in de repo.
  • Elke tenant een eigen databasegebruiker met rechten op alleen de eigen database.
  • Nooit routeRules gebruiken voor de route-cache, en cachePublicRoute nooit vanuit een CMS-pagina of AdminLayout aanroepen. routeRules matchen op pathname en de CMS-host deelt elke pathname met de publieke site. Omdat de cache vóór de middleware zit, zou een gecachede CMS-pagina zonder auth-check worden geserveerd. Opt-in per publieke pagina is daarom de enige toegestane vorm. De cache-key bevat wél de origin, dus CMS-content lekt niet naar de publieke host.
  • Font-strategie: nu een systeemstack met optionele webfont-URL per tenant (typography.fontHeadingUrl / fontBodyUrl). Self-hosted font-optimalisatie is nog niet uitgewerkt.
  • astro check vereist TypeScript 6.x (programmatic API); site-template pinned daarom typescript@6.0.3 terwijl de rest van de monorepo TypeScript 7 gebruikt.
  • De provisioner wijst preview-hostnames toe ({slug}.{previewBase}, default okhema.studio) en zet Coolify domains. Wildcard-TLS (*.{previewBase} via Traefik DNS-01) is een Coolify/Traefik-operatorstap. Voor *.preview.okhema.studio is dit op 7 september 2026 hands-on geverifieerd; bestaande sites behouden HTTP-01. Zie Deploy en Preview-domeinen.
  • theme als jsonb-blob versus losse kolommen: jsonb voor nu, heroverwegen bij het bouwen van een theme-editor.
  • Herevaluatie Payload CMS 3 vóór fase 3: als de pagebuilder meer dan circa vier tot zes weken zelfbouw kost, weegt Payload’s kant-en-klare blocks en multi-tenant plugin mogelijk op tegen de Next.js-overhead.
  • pgbouncer of kleinere pool vóór ~15 tenants. packages/db/src/client.ts gebruikt postgres(url, { max: 10 }) per container; Postgres staat op max_connections=200. Bij groei óf pool verlagen óf pgbouncer ertussen — client.ts is in taak 1.11/1.12 bewust niet gewijzigd.