Plan: Multi-Env PR Preview + Strapi Draft Preview¶
Status: Complete¶
All 4 phases implemented and merged in PR #604.
Implementation Notes (Actual vs. Plan)¶
Phase 1 — Frontend preview mode¶
Implemented as planned with one structural change: CMS fetch logic was extracted into a shared
client/src/lib/cms-fetch.ts helper exposing cmsUrl() and cmsHeaders(). All 7 CMS hooks
were migrated to use these helpers rather than duplicating the env-var logic inline.
App.tsx wraps the app in PreviewProvider and renders PreviewBanner at root. The banner is a
fixed amber bar showing "Preview mode — showing draft content". It is driven by
import.meta.env.VITE_CMS_PREVIEW_MODE baked in at Docker build time.
Phase 2 — Strapi API token provisioned at deploy time¶
The original plan used bootstrap() to seed the token. This was changed during implementation:
Strapi v5 does not load custom APIs without a content-type schema, so the
GET /api/preview-token endpoint cannot be registered via the normal plugin/API discovery path.
The endpoint is instead registered directly on the Koa router inside the register() lifecycle
hook via strapi.server.routes().
Token creation is also deferred: calling tokenService.list() during bootstrap crashes in
Strapi v5. The token is now created lazily on the first HTTP request to /api/preview-token,
using strapi.db.query for existence check and tokenService.create() on first call. The
plaintext value is cached in process.env.STRAPI_PREVIEW_TOKEN_VALUE for the process lifetime.
The endpoint returns 403 on a wrong X-Bootstrap-Secret header and 409 if a token exists in the
database but the cached plaintext is gone (meaning the CMS was redeployed without clearing the
token row — the fix is to redeploy CMS with a fresh database or delete the token row manually).
CI serialization in deploy-pr-preview.yml: CMS deploy → CMS health poll → token fetch →
frontend Docker build (with VITE_CMS_URL, VITE_CMS_PREVIEW_MODE=true,
VITE_CMS_PREVIEW_TOKEN build args).
Phase 3 — Strapi preview-button plugin¶
Implemented as planned. cms/config/plugins.js wires preview-button for project-page,
content-post, and hero-section using process.env.PREVIEW_FRONTEND_URL. container_apps.bicep
and main.bicep thread the preview_frontend_url param. CI passes
preview_frontend_url=https://dev.sven-relijveld.com at CMS deploy time.
Phase 4 — Label-triggered auto-deploy¶
Implemented as planned. deploy-pr-preview.yml adds a pull_request trigger for labeled,
synchronize, unlabeled, and closed events. Fires deploy when the preview label is added, or
on push to a PR that already has the label. PR_NUMBER env is extended to resolve from the
pull_request event context. The preview label was created in the GitHub repo (blue).
Gotchas discovered during implementation¶
- Strapi v5 custom API registration: APIs without a content-type schema are not discovered at
startup. Routes must be registered via
strapi.server.routes()in theregister()lifecycle, not as a standardsrc/api/directory structure. tokenService.list()crash on bootstrap: Deferred token creation to first HTTP request resolves this; the service layer is not fully initialized duringbootstrap().- Git bash path conversion: On Windows, git bash converts
/cmsbuild args toC:/Program Files/Git/cms. CI scripts useMSYS_NO_PATHCONV=1to prevent this. - Docker layer caching with build args:
--build-argchanges do not bust the builder cache layer. Use--no-cachewhen the token value must be fresh (or accept a stale token from cache). STRAPI_BOOTSTRAP_SECRETdocker-compose fallback: The:-fallback does not work if the variable is set but empty in the host shell. The variable must be explicitly set in.envfor local development.
Goal¶
Every PR gets a full isolated environment (frontend + backend + CMS) where:
- Content editors can open a draft entry in Strapi admin and click "Preview" to see it rendered live in the PR frontend — without publishing.
- Developers get a shareable URL per-PR with the latest code and draft CMS content, so design/content reviews happen before merge.
Current State¶
| What exists | Detail |
|---|---|
| PR preview workflow | deploy-pr-preview.yml — manual /deploy comment or workflow_dispatch; deploys frontend + backend + UIGen + CMS as separate Container App replicas tagged pr-<N> |
| Per-PR CMS container | Already deployed with its own cms_url output (e.g. https://dna-dev-portfolio-we-ca-cms-pr42.…azurecontainerapps.io) |
draftAndPublish: true |
Enabled on all 7 content types: hero-section, about-section, contact-section, footer-section, how-its-made-section, content-post, project-page |
strapi-plugin-preview-button |
Installed, enabled, but contentTypes: [] — unconfigured |
| Frontend CMS fetch | All hooks (useHeroSection, useAboutSection, etc.) fetch GET /api/<type>?populate=* — no status=draft param |
| Strapi API tokens | Not yet created for preview use; public CMS endpoints use no auth |
Architecture¶
PR frontend (pr-42.dev.sven-relijveld.com)
└── sends ?status=draft&locale=en to CMS API
└── Authorization: Bearer <STRAPI_PREVIEW_TOKEN> ← read-only API token
PR CMS admin (cms.pr-42.dev.sven-relijveld.com/admin)
└── Content editor edits draft entry
└── Clicks "Preview" sidebar button
└── Opens PR frontend at /api/preview?secret=<PREVIEW_SECRET>&type=<ct>&slug=<slug>
↓
PR frontend /api/preview route (or Vite-served proxy)
↓ validates secret, sets preview cookie
Page renders with ?status=draft CMS fetch
Key decisions¶
| Decision | Choice | Reason |
|---|---|---|
| Draft token type | Strapi read-only API token (not user session) | Stateless; safe to bake into frontend env var at build time |
| Preview activation | ?preview=1 query param on frontend + VITE_CMS_PREVIEW_SECRET checked server-side |
Simple; no separate Next.js-style preview API needed (this is Vite/React SPA) |
| Token scope | Token created per-PR CMS instance at deploy time via Strapi bootstrap | Each PR gets its own isolated token; no shared secret across envs |
| Preview button URLs | {cms_url}/admin → {frontend_url}/preview?secret={secret}&type={uid}&slug={slug} |
Strapi interpolates {slug} from the entry |
| Production CMS | No draft preview in prod — only published content fetched | Draft reads require token auth; prod frontend stays unauthenticated |
Implementation Phases¶
Phase 1 — Frontend preview mode (no auth, draft fetch on ?preview=1)¶
Files to change:
client/src/hooks/useHeroSection.ts,useAboutSection.ts,useContactSection.ts,useFooterSection.ts,useHowItsMadeSection.ts,useProjectPageCMS.ts,useContentPosts.ts- Read
VITE_CMS_PREVIEW_MODE=trueenv var - Append
&status=draftto CMS fetch URL when preview mode is active -
Add
Authorization: Bearer ${VITE_CMS_PREVIEW_TOKEN}header when token is set -
client/src/contexts/preview-context.tsx(new) PreviewContextwithisPreview: booleanderived fromimport.meta.env.VITE_CMS_PREVIEW_MODE-
PreviewBannercomponent: fixed top bar "Preview mode — draft content" with dismiss -
Dockerfile.frontend -
Add
ARG VITE_CMS_PREVIEW_MODE=false/ARG VITE_CMS_PREVIEW_TOKEN= -
docker-compose.yml - Add
VITE_CMS_PREVIEW_MODE/VITE_CMS_PREVIEW_TOKENto frontend build args (both empty by default)
Result: PR preview frontend fetches draft content when VITE_CMS_PREVIEW_MODE=true is baked in at build time.
Phase 2 — Strapi API token provisioned at deploy time¶
Files to change:
cms/src/index.js—bootstrap()function- Add
seedPreviewToken(strapi): checks if token namedpreview-readexists, creates it if not - Token type:
read-only, permissions:find+findOneon all content types -
Writes token value to a known env var or outputs it via
strapi.log.info -
deploy-pr-preview.yml - After CMS warmup, call
GET {cms_url}/api/preview-token(new endpoint, see below) to retrieve the token - Pass token as
VITE_CMS_PREVIEW_TOKENbuild arg to frontend Docker build -
Order: CMS must be up before frontend is built — move frontend build after CMS deploy + warmup
-
cms/src/api/preview-token/(new read-only endpoint) GET /api/preview-token— returns the currentpreview-readAPI token value- Protected by
STRAPI_BOOTSTRAP_SECRET(env var set at deploy time, not exposed to frontend) - Only enabled when
ENVIRONMENT !== production
Result: Each PR gets a freshly created CMS token, passed as a build arg into the frontend image.
Phase 3 — Strapi preview-button plugin wired to PR frontend¶
Files to change:
cms/config/plugins.js— populatecontentTypesinpreview-buttonconfig:
"preview-button": {
enabled: true,
config: {
contentTypes: [
{
uid: "api::project-page.project-page",
draft: {
url: "{PREVIEW_FRONTEND_URL}/projects/{slug}",
query: { preview: "1" },
},
published: { url: "{PREVIEW_FRONTEND_URL}/projects/{slug}" },
},
{
uid: "api::content-post.content-post",
draft: {
url: "{PREVIEW_FRONTEND_URL}/content/{slug}",
query: { preview: "1" },
},
published: { url: "{PREVIEW_FRONTEND_URL}/content/{slug}" },
},
{
uid: "api::hero-section.hero-section",
draft: { url: "{PREVIEW_FRONTEND_URL}/" },
published: { url: "{PREVIEW_FRONTEND_URL}/" },
},
],
},
},
PREVIEW_FRONTEND_URLinjected as env var at CMS deploy time (the PR frontend URL)-
Plugin config reads
process.env.PREVIEW_FRONTEND_URLwith prod fallback tohttps://www.sven-relijveld.com -
deploy-pr-preview.yml - Pass
PREVIEW_FRONTEND_URL=<frontend_url>as CMS Container App env var at deploy time
Result: Content editors see a "Preview" button in the Strapi admin sidebar that opens the draft entry in the PR frontend.
Phase 4 — Label-triggered auto deploy (opt-in via preview label)¶
Replace the manual /deploy comment with label-based automation:
deploy-pr-preview.yml: addpull_requesttrigger forlabeled+synchronizeeventslabeled: deploy when label name ==previewsynchronize: re-deploy when new commits pushed and PR already haspreviewlabel (check viagithub.event.pull_request.labels)unlabeled/closed: teardown job removes Container App replicas for the PR- Keep
workflow_dispatchas manual override for debugging - Add
previewlabel to GitHub repo labels (colour:#0075ca)
Infrastructure changes¶
| Resource | Change |
|---|---|
| Container Apps (PR) | No new resources — existing pr-<N> replicas already created |
| CMS env vars | Add PREVIEW_FRONTEND_URL, STRAPI_BOOTSTRAP_SECRET to Bicep PR deploy params |
| Frontend build args | Add VITE_CMS_PREVIEW_MODE, VITE_CMS_PREVIEW_TOKEN |
| Strapi API tokens table | Seeded by bootstrap; no Bicep change needed |
Sequence diagram (Phase 1–3)¶
/deploy comment
│
├─ deploy CMS container (pr-N tag)
├─ wait for CMS warmup + seed (bootstrap runs, creates preview-read token)
├─ fetch token: GET {cms_url}/api/preview-token (with STRAPI_BOOTSTRAP_SECRET)
│
├─ build frontend image:
│ --build-arg VITE_CMS_PREVIEW_MODE=true
│ --build-arg VITE_CMS_PREVIEW_TOKEN=<token>
│ --build-arg VITE_CMS_URL=<cms_url>
│
├─ deploy frontend container (pr-N tag, points at pr-N CMS)
│
└─ PR comment updated:
Frontend: https://pr-42.dev.sven-relijveld.com ← renders draft content
CMS admin: https://cms.pr-42.dev.sven-relijveld.com/admin ← preview button opens above
Decisions¶
| Question | Decision |
|---|---|
| Token endpoint auth | STRAPI_BOOTSTRAP_SECRET request header — CI passes it, endpoint rejects requests without it |
| Frontend build order | Serialize: CMS deploy + warmup → token fetch → frontend build (adds ~2–3 min to PR preview CI) |
| Phase 4 auto-trigger | Opt-in via preview label on the PR; pull_request labeled event fires deploy; unlabeled/closed fires teardown |
Files to create / modify summary¶
| File | Change |
|---|---|
client/src/contexts/preview-context.tsx |
New — PreviewContext + PreviewBanner |
client/src/hooks/use*.ts (7 CMS hooks) |
Add status=draft + Authorization header when preview mode |
Dockerfile.frontend |
2 new ARG/ENV lines |
docker-compose.yml |
2 new build args (empty by default) |
cms/config/plugins.js |
Populate preview-button contentTypes |
cms/src/index.js |
Add seedPreviewToken() to bootstrap |
cms/src/api/preview-token/ |
New read-only endpoint (3 files) |
.github/workflows/deploy-pr-preview.yml |
Serialize CMS→token→frontend; pass new build args; pass CMS env vars |
infra/modules/container_apps.bicep |
Add PREVIEW_FRONTEND_URL + STRAPI_BOOTSTRAP_SECRET to CMS CA env block (PR-only) |