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-postcollection shipped (PRs #560, #562);project-pagecollection 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.tshelper,PreviewBanner, Strapi API token provisioning,preview-buttonplugin wired for 3 content types, label-triggered auto-deploy. PR #604.- Phase 4 (Frontend collections) ⚠️ —
/content(articles + presentations) shipped./blog,/speaking,/announcementsroutes 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: trueminReplicas: 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
devandprodenvironments
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
strapischema (set viasearch_pathin connection config) - MI must have
CREATE SCHEMApermission — one-offGRANTindeploy-infrastructure.ymlpost-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
tokenquery 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 butnlvalues can be empty initially. - Strapi admin account provisioning for colleague: done manually on first deploy, documented in runbook.
cms.dev.sven-relijveld.comDNS: confirm subdomain pattern matches existing dev domain setup.