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.
Overzicht
Section titled “Overzicht” ┌───────────────────────────┐ │ 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.
Repo-structuur
Section titled “Repo-structuur”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.
Technische keuzes
Section titled “Technische keuzes”| 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.
Harde regels
Section titled “Harde regels”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.
3. Types worden nooit gedupliceerd
Section titled “3. Types worden nooit gedupliceerd”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.
4. Tenant-context is expliciet
Section titled “4. Tenant-context is expliciet”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.
5. Taal
Section titled “5. Taal”Code, configuratie, bestandsnamen, tabelnamen, kolomnamen, variabelen en commit-berichten in het Engels. Commentaar en documentatie mogen Nederlands.
6. Versies verifiëren, niet aannemen
Section titled “6. Versies verifiëren, niet aannemen”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.
Renderstrategie
Section titled “Renderstrategie”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.
Deployment
Section titled “Deployment”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.
Beveiliging
Section titled “Beveiliging”- Master ↔ klantsite: API-key per site plus HMAC-gesigneerde webhooks. Site → master is
POST /api/webhooks/siteop het dashboard (Contract). - CMS-auth leeft alleen op
admin.<SITE_DOMAIN>. Better AuthtrustedOriginsis 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
routeRulesgebruiken voor de route-cache, encachePublicRoutenooit vanuit een CMS-pagina ofAdminLayoutaanroepen.routeRulesmatchen 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.
Openstaande punten
Section titled “Openstaande punten”- Font-strategie: nu een systeemstack met optionele webfont-URL per tenant (
typography.fontHeadingUrl/fontBodyUrl). Self-hosted font-optimalisatie is nog niet uitgewerkt. astro checkvereist TypeScript 6.x (programmatic API);site-templatepinned daaromtypescript@6.0.3terwijl de rest van de monorepo TypeScript 7 gebruikt.- De provisioner wijst preview-hostnames toe (
{slug}.{previewBase}, defaultokhema.studio) en zet Coolifydomains. Wildcard-TLS (*.{previewBase}via Traefik DNS-01) is een Coolify/Traefik-operatorstap. Voor*.preview.okhema.studiois dit op 7 september 2026 hands-on geverifieerd; bestaande sites behouden HTTP-01. Zie Deploy en Preview-domeinen. themeals 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.tsgebruiktpostgres(url, { max: 10 })per container; Postgres staat opmax_connections=200. Bij groei óf pool verlagen óf pgbouncer ertussen —client.tsis in taak 1.11/1.12 bewust niet gewijzigd.