Skip to content

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 the register() lifecycle, not as a standard src/api/ directory structure.
  • tokenService.list() crash on bootstrap: Deferred token creation to first HTTP request resolves this; the service layer is not fully initialized during bootstrap().
  • Git bash path conversion: On Windows, git bash converts /cms build args to C:/Program Files/Git/cms. CI scripts use MSYS_NO_PATHCONV=1 to prevent this.
  • Docker layer caching with build args: --build-arg changes do not bust the builder cache layer. Use --no-cache when the token value must be fresh (or accept a stale token from cache).
  • STRAPI_BOOTSTRAP_SECRET docker-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 .env for local development.

Goal

Every PR gets a full isolated environment (frontend + backend + CMS) where:

  1. Content editors can open a draft entry in Strapi admin and click "Preview" to see it rendered live in the PR frontend — without publishing.
  2. 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=true env var
  • Append &status=draft to 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)

  • PreviewContext with isPreview: boolean derived from import.meta.env.VITE_CMS_PREVIEW_MODE
  • PreviewBanner component: 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_TOKEN to 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.jsbootstrap() function
  • Add seedPreviewToken(strapi): checks if token named preview-read exists, creates it if not
  • Token type: read-only, permissions: find + findOne on 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_TOKEN build 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 current preview-read API 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 — populate contentTypes in preview-button config:
"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_URL injected as env var at CMS deploy time (the PR frontend URL)
  • Plugin config reads process.env.PREVIEW_FRONTEND_URL with prod fallback to https://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: add pull_request trigger for labeled + synchronize events
  • labeled: deploy when label name == preview
  • synchronize: re-deploy when new commits pushed and PR already has preview label (check via github.event.pull_request.labels)
  • unlabeled / closed: teardown job removes Container App replicas for the PR
  • Keep workflow_dispatch as manual override for debugging
  • Add preview label 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)