Skip to content

Content Management Plan

Status: PARTIALLY SHIPPED

  • Phase 1 (Strapi scaffold) ✅ — CMS deployed to dev and prod with custom domain, managed identity Postgres auth, and plugin configuration. PRs #486, #507–#516.
  • Phase 2 (Infrastructure) ✅ — ACA, Key Vault secrets, custom domain, deploy pipeline. PRs #486, #528.
  • Content collections ✅ — content-post collection shipped (PRs #560, #562); project-page collection shipped (PR #484).
  • Phase 3 (Frontend singletons) ✅ — Hero, about, footer, contact, how-it's-made sections wired to Strapi singletons with static fallback. PRs #592, #600.
  • PR preview + draft content ✅ — cms-fetch.ts helper, PreviewBanner, Strapi API token provisioning, preview-button plugin wired for 3 content types, label-triggered auto-deploy. PR #604.
  • Phase 4 (Frontend collections) ⚠️ — /content (articles + presentations) shipped. /blog, /speaking, /announcements routes not implemented.
  • Phases 5–7 ❌ — Case study join and remaining CI/CD enhancements not started.

Static text in the frontend (hero headline, about text, footer tagline, contact labels, how-it's-made descriptions) is currently hardcoded in TSX components. This plan implements a Strapi v5 headless CMS to enable content editing without redeploying the application.

Scope: text content and new content collections. Hero headshot, project images, CV photos, and other binary media are out of scope — they remain in client/public/ or the existing cv-downloads blob storage.

Users: one technical user (owner) + one non-technical colleague. Edit frequency: 1–2x per week max.


Architecture Overview

Frontend (www.sven-relijveld.com)
  └── fetch content from Strapi REST API (cms.sven-relijveld.com)
        ├── singletons  → hero, about, footer, contact, how-it's-made
        └── collections → blog posts, speaking engagements, announcements,
                          case studies (joined to Postgres projects by slug)

Postgres (existing Flexible Server)
  ├── existing schema  → projects, experience, skills, awards, certifications
  └── strapi schema    → Strapi's own tables (managed by Strapi migrations)

Strapi Admin (cms.sven-relijveld.com/admin)
  └── auth: Strapi admin credentials (no EasyAuth, no SSO)

No sync job. No Postgres page_content tables. No bidirectional diff. Strapi is the single source of truth for CMS content. If Strapi is ever abandoned, a one-off migration script moves content to whatever replaces it.


Content Model

Singletons (one instance per locale)

Content type Key fields
hero-section headline, tagline, roles (repeatable), cta_primary, cta_secondary
about-section heading, body (richtext)
footer-section name, tagline, copyright, email
contact-section heading, location, form labels
how-its-made-section heading, subtitle, tech_cards (repeatable component)

Collections (many instances)

Content type Key fields
blog-post title, slug, body (richtext), published_at, excerpt
speaking-engagement title, slug, event, date, location, body (richtext), recording_url
announcement title, slug, body (richtext), published_at
case-study slug (join key to Postgres projects), body (richtext), problem, solution, lessons_learned

Case study join: frontend fetches project structured data from Postgres (/api/projects/{slug}) and case study prose from Strapi (/cms/api/case-studies?filters[slug][$eq]={slug}). If no matching case study exists in Strapi, the project detail page renders without long-form prose — graceful degradation.

Localization

All content types have i18n enabled (en and nl locales). Locale switcher is a separate future task — for now frontend defaults to en.


Infrastructure

Strapi Container App

New ACA in infra/modules/container_apps.bicep:

  • Name: dna-{env}-portfolio-we-ca-cms
  • ingress.external: true
  • minReplicas: 0, maxReplicas: 1
  • Custom domain: cms.sven-relijveld.com (dev: cms.dev.sven-relijveld.com)
  • Managed TLS cert via ACA (same pattern as frontend)
  • Managed identity: backend MI (dna-{env}-portfolio-we-id-be) for Postgres auth
  • Deployed to both dev and prod environments

Environment variables (from Key Vault)

Secret name Purpose
strapi-app-keys Strapi session encryption
strapi-api-token-salt API token hashing
strapi-admin-jwt-secret Admin UI JWT signing
strapi-jwt-secret Content API JWT signing

Postgres connection uses the existing backend MI — no Postgres password.

Postgres

  • Existing Flexible Server (uksouth)
  • Separate strapi schema (set via search_path in connection config)
  • MI must have CREATE SCHEMA permission — one-off GRANT in deploy-infrastructure.yml post-deploy step (same pattern as existing AAD admin registration)

Admin group membership

Admin Entra group membership is declared in Bicep parameters. Adding a new admin = PR to the relevant bicepparams file. No manual portal clicks.


Auth

  • Strapi admin UI at cms.sven-relijveld.com/admin
  • Auth: Strapi's own admin credentials
  • No EasyAuth on the Strapi CA
  • No SSO (Strapi v5 SSO is enterprise-tier only)
  • Colleague gets a Strapi admin account provisioned manually on first deploy

Preview

Strapi v5 Draft & Publish + Preview plugin:

  • Editor clicks "Preview" in Strapi admin
  • Strapi opens www.sven-relijveld.com/preview?token={draft_token}&type={content_type}&slug={slug}
  • Frontend detects token query param, fetches draft content from Strapi, renders the actual portfolio page with draft content applied
  • Preview tab shows the real portfolio UI, not a separate preview renderer

Frontend changes

New routes

Route Content type Source
/blog blog-post list Strapi
/blog/:slug blog-post detail Strapi
/speaking speaking-engagement list Strapi
/speaking/:slug speaking-engagement detail Strapi
/announcements announcement list Strapi
/announcements/:slug announcement detail Strapi

Existing components

Singleton content (hero, about, footer, contact, how-it's-made) switches from hardcoded strings to Strapi API calls. Static strings become the fallback (initialData) if the Strapi fetch fails.

Content fetching

React Query hooks per content type. Strapi base URL from an env var (VITE_CMS_URL). Types generated via @strapi/sdk-plugin and committed to shared/cms.types.ts.


Build sequence

Phase Scope Effort
1 — Strapi scaffold cms/ directory, Postgres MI auth, content types, i18n, seed data, Dockerfile.strapi ~6h
2 — Infrastructure ACA Bicep, Key Vault secrets, custom domain, DNS, deploy pipeline ~4h
3 — Frontend singletons Replace hardcoded strings with Strapi API calls, static fallback, React Query hooks ~3h
4 — Frontend collections New routes + pages for blog, speaking, announcements ~4h
5 — Case study join Strapi case-study collection + frontend merge with Postgres project data ~2h
6 — Preview Draft & Publish config, preview URL handler in frontend ~2h
7 — CI/CD Build + push portfolio-cms image, deploy step, smoke test ~2h
Total ~23h

What was dropped from the previous plan

Dropped Reason
FastAPI page_content Postgres tables (Option A) Strapi is single source of truth; no fallback DB needed
Bidirectional sync job Only needed to keep Postgres in sync with Strapi; eliminated with Option A
sync_conflicts table + conflict resolution endpoints Consequence of dropping the sync job
App Configuration as content storage Key-value store unsuitable for structured content
Nginx proxy for Strapi (/cms/* on frontend CA) Strapi has its own external ingress; no proxy needed
EasyAuth gate on Strapi No EasyAuth on prod frontend CA; Strapi admin credentials used instead
Warmup GitHub Actions job 30s cold start accepted; complexity not justified
Custom admin route /admin/content Replaced by Strapi's own admin UI

Saved: ~11h of sync/fallback infrastructure, cleaner architecture.


Escape hatch

If Strapi is ever abandoned: write a one-off script that reads all Strapi content via the REST API and migrates it to the replacement system. The Postgres strapi schema is self-contained and can be dropped cleanly. No ongoing cost to maintain the escape hatch.


Open questions

  • Dutch (nl) content: who writes the Dutch copy? Locale schema is ready from day 1 but nl values can be empty initially.
  • Strapi admin account provisioning for colleague: done manually on first deploy, documented in runbook.
  • cms.dev.sven-relijveld.com DNS: confirm subdomain pattern matches existing dev domain setup.