This roadmap describes the remaining work required to bring the Front-Office from its current state (v0.12.6, ~65-70% spec coverage) to production readiness (v1.0, 100%).Each phase is ordered by dependency chain and impact. Tasks are broken down to individual file-level changes.
Derived from the FO Coverage Analysis.
Reference specification: Next-Generation CMS — Technical Architecture V0.1 (October 2025).
Goal: Make the FO deployable on Kubernetes and served through the CDN.
Priority: P0 — blocks all SLOs and platform alignment.
Estimated effort: 2–3 weeks.
Return 200 OK with JSON body: { status: "ok", version: pkg.version, uptime: process.uptime(), timestamp: new Date().toISOString() }. Read version from package.json at startup.
0.25d
1.2.2
Create liveness probe route
server/routes/livez.get.ts
Lightweight — return 200 OK with { status: "alive" }. No external calls.
0.1d
1.2.3
Create startup probe route
server/routes/startupz.get.ts
Check that Nitro storage is initialized. Return 200 if ready, 503 if not.
0.1d
Acceptance:GET /healthz, /livez, /startupz return correct status codes. No auth required.
Intercept every SSR response. Compute Surrogate-Key header value from: route path segments (/articles/my-slug → articles articles/my-slug), tenant_id from runtime config, site_id from config, content type (page, article, category, tag, homepage). Format: space-separated keys.
0.5d
1.4.2
Add tenant/site prefixed keys
server/middleware/surrogate-keys.ts
Prepend tenant:{id} and site:{id} to every key set. E.g. tenant:demo site:1 articles articles/my-slug. This enables per-tenant and per-site purges.
0.25d
1.4.3
Skip surrogate keys for preview
server/middleware/surrogate-keys.ts
If preview_token query param present, do NOT set Surrogate-Key header (already handled by preview-no-store.ts with Cache-Control: no-store).
0.1d
1.4.4
Create purge API route
server/api/cdn-purge.post.ts
Accept POST with { keys: string[] } body. Call CDN vendor purge API (abstract behind utils/cdn-client.ts). Require an internal shared secret (X-Purge-Secret header) — not public. Return { purged: keys.length }.
0.5d
1.4.5
Create CDN client abstraction
server/utils/cdn-client.ts
purgeSurrogateKeys(keys: string[]): Promise<void>. Initial implementation: HTTP call to CDN purge endpoint (vendor-specific). Env vars: CDN_PURGE_URL, CDN_PURGE_TOKEN. Stub for dev mode (log only).
0.5d
1.4.6
Document purge contract for API team
docs/cdn-purge-contract.md (in cms-doc)
Document the POST /api/cdn-purge endpoint contract so the API team can call it on publish events. Include key naming convention.
0.25d
Acceptance: SSR responses include Surrogate-Key header. POST /api/cdn-purge with correct secret purges the CDN. Preview responses have no surrogate keys.
Add stale-if-error=86400 (24h) to all Cache-Control headers. Ensures CDN serves stale content if origin is down. Currently missing.
0.25d
1.5.3
Separate ISR values from CDN values
nuxt.config.ts
Clarify: ISR isr: 300 controls Nitro revalidation, s-maxage controls CDN TTL. They should be independent. Set CDN s-maxage higher (e.g. 3600) and use surrogate-key purge for instant invalidation instead of relying on short TTLs.
0.25d
1.5.4
Add Vary: Accept-Language for i18n
nuxt.config.ts or server/middleware/surrogate-keys.ts
Ensure CDN caches separate versions per locale. Add Vary: Accept-Language, X-Site-Id header to SSR responses.
0.25d
1.5.5
Validate with curl tests
—
Write a shell script scripts/test-cache-headers.sh that curls each route type and asserts correct Cache-Control, Surrogate-Key, Vary headers.
0.25d
Acceptance: All public routes return Cache-Control with s-maxage, stale-while-revalidate, stale-if-error. Vary header includes Accept-Language. Preview routes return no-store.
Change nitro.storage.cache.driver from 'memory' to 'redis'. Use env vars: REDIS_HOST, REDIS_PORT, REDIS_PASSWORD, REDIS_DB. Keep 'memory' fallback when REDIS_HOST is unset (dev mode).
0.25d
2.1.2
Add unstorage Redis driver dependency
package.json
pnpm add unstorage @unstorage/redis-driver (or the Nitro built-in redis driver if available). Verify version compatibility with Nitro.
0.1d
2.1.3
Create Redis health check
server/routes/healthz.get.ts
Extend the existing health endpoint: ping Redis storage, include { redis: "connected" } or { redis: "disconnected" } in response. Return 503 if Redis is down and REDIS_HOST is configured.
0.25d
2.1.4
Add Redis connection environment vars
helm/cms-fo/values.yaml
Add REDIS_HOST, REDIS_PORT, REDIS_PASSWORD (from Secret), REDIS_DB to the env map.
0.1d
Acceptance:GET /healthz reports Redis status. Nitro cache is shared between pod restarts. Memory fallback works in local dev.
On X-Preview-Token or ?preview_token= in request: read preview-token:{value} from Redis storage. If key exists and not expired, set event.context.preview = { token, validUntil } and continue. If key missing/expired, return 403 { error: "Invalid or expired preview token" }. Skip if no token present (public request).
0.5d
2.2.2
Remove hardcoded preview logic
composables/usePreview.ts
Refactor: keep previewToken extraction from query, but rely on the server middleware for validation. Remove any client-side-only validation. The server already sets Cache-Control: no-store via preview-no-store.ts.
0.25d
2.2.3
Add preview token info to SSR context
server/middleware/preview-token-redis.ts
Store validated preview metadata in event.context.preview so downstream handlers (page rendering) can detect preview mode server-side.
0.1d
2.2.4
Write integration test
tests/integration/preview-token.spec.ts
Test with valid token → 200. Expired token → 403. Missing token → normal public response. Ensure Cache-Control: no-store is set in preview mode.
0.5d
Acceptance: Preview tokens are validated server-side against Redis. Multi-instance FO serves identical preview content. Expired tokens are rejected.
Replace current file-read logic with $fetch call to CMS API: GET /public/homepage-blocks?site={siteId}. Forward X-Tenant-Id, Accept-Language, X-Preview-Token headers. Cache response in Nitro storage (Redis) with homepage-blocks:site:{id} key, TTL 5 min.
0.5d
2.3.2
Remove PUT route
server/api/homepage-blocks.put.ts
Delete file entirely. Homepage block editing is handled by the BO → API, not by the FO.
0.1d
2.3.3
Delete local JSON data files
data/homepage-blocks.json
Remove the temporary JSON file. Ensure no other code references it.
0.1d
2.3.4
Update useHomepageBlocks composable
composables/useHomepageBlocks.ts
Update the fetch URL from /api/homepage-blocks?site=... (local) to go through the new proxy route (same URL, but the server-side handler now calls the real API). Verify useAsyncData caching still works.
0.25d
2.3.5
Add surrogate key for homepage
server/api/homepage-blocks.get.ts
Set Surrogate-Key: homepage site:{id} tenant:{tenantId} on response so CDN purge covers homepage changes.
0.1d
Acceptance: Homepage renders from API data. No JSON files in data/. Homepage invalidation works via CDN purge.
Replace current dual-fetch (articles + pages) with single call to /api/search. Add: facets ref, totalResults ref, currentPage ref, loadMore() method, selectedFacets ref. Keep debounce at 300ms. Remove manual result merging logic.
0.5d
2.4.4
Update search results page
pages/search.vue
Add: faceted sidebar (filter by type, category), pagination, result highlighting, “no results” state with suggestions, loading skeleton. Use new useSearch composable.
0.5d
2.4.5
Add search suggestions component
components/SearchSuggestions.vue
Dropdown below search input. Show top 5 results with title + type badge. Debounce 200ms. Keyboard navigation (arrow keys + enter). Close on Escape or click-outside.
0.5d
2.4.6
Integrate suggestions in Header
components/Header.vue
Replace current search input behavior with <SearchSuggestions /> component. Wire to useSearch.handleSearch() on Enter.
0.25d
2.4.7
Write E2E test
tests/e2e/search.spec.ts
Test: type query → suggestions appear → click suggestion → navigate. Full search page with facets. Empty query. No results.
0.5d
Acceptance: Search works end-to-end via the search engine. Faceted results display correctly. Suggestions appear in < 300ms. Empty state handled.
Manage user consent state. Expose: hasAnalyticsConsent: Ref<boolean>, hasMarketingConsent: Ref<boolean>, showConsentBanner: Ref<boolean>, acceptAll(), rejectAll(), acceptCategory(category). Persist to cookie cms_consent with SameSite=Lax; Secure.
0.5d
2.5.2
Create consent banner component
components/ConsentBanner.vue
GDPR-compliant banner: title, description, “Accept all”, “Reject all”, “Customize” buttons. Customize panel: checkboxes for analytics, marketing. Link to privacy policy. Respects prefers-reduced-motion. Accessible (focus trap, ARIA roles).
0.5d
2.5.3
Integrate banner in layout
layouts/default.vue
Render <ConsentBanner /> conditionally based on useConsent().showConsentBanner.
0.1d
2.5.4
Create Matomo plugin
plugins/matomo.client.ts
Watch useConsent().hasAnalyticsConsent. When true: inject Matomo tracker script via useHead(). Configure window._paq with: site ID from env NUXT_PUBLIC_MATOMO_SITE_ID, tracker URL from NUXT_PUBLIC_MATOMO_URL, disableCookies if consent not full. When consent revoked: call _paq.push(['optUserOut']) and remove cookies.
0.5d
2.5.5
Add custom dimensions
plugins/matomo.client.ts
After tracker init, push custom dimensions: dimension1: tenantId, dimension2: siteId, dimension3: locale, dimension4: template name, dimension5: page type (article/page/category/homepage).
0.25d
2.5.6
Track SPA navigation
plugins/matomo.client.ts
Use router.afterEach() hook to push trackPageView with correct title and URL on each client-side navigation.
0.25d
2.5.7
Honor Do Not Track
plugins/matomo.client.ts
Check navigator.doNotTrack === '1'. If set, do not init Matomo even with consent (spec 5.3.5).
0.1d
2.5.8
Add env vars to config
nuxt.config.ts, helm/cms-fo/values.yaml
Add NUXT_PUBLIC_MATOMO_URL, NUXT_PUBLIC_MATOMO_SITE_ID to runtimeConfig.public and Helm values.
0.1d
2.5.9
Write E2E test
tests/e2e/consent-matomo.spec.ts
Test: banner appears on first visit → reject → no Matomo requests. Accept → Matomo requests fired. Revoke → optUserOut called. DNT header → no tracking.
0.5d
Acceptance: Matomo loads only after explicit user consent. Zero tracking without consent. DNT respected. Custom dimensions sent. SPA navigations tracked.
Initialize NodeSDK with: resource (service.name: "cms-fo", service.version, deployment.environment), traceExporter (OTLP HTTP to OTEL_EXPORTER_OTLP_ENDPOINT), metricReader (periodic, 30s interval), auto-instrumentations for http, fetch. Only init if OTEL_EXPORTER_OTLP_ENDPOINT env var is set. No-op in dev.
0.5d
3.1.3
Register OTel in Nitro plugin
server/plugins/otel.ts
Import and start the SDK from server/utils/otel.ts at Nitro startup. Ensure graceful shutdown on SIGTERM.
0.25d
3.1.4
Add tenant/site span attributes
server/middleware/otel-attributes.ts
For every request: add span attributes tenant.id (from runtime config), site.id, http.route (path), http.locale (from Accept-Language).
0.25d
3.1.5
Add env vars
nuxt.config.ts, helm/cms-fo/values.yaml
OTEL_EXPORTER_OTLP_ENDPOINT, OTEL_SERVICE_NAME=cms-fo, OTEL_TRACES_SAMPLER=parentbased_traceidratio, OTEL_TRACES_SAMPLER_ARG=0.01 (1% in prod, 1.0 in staging).
0.1d
Acceptance: Traces appear in Grafana Tempo with service.name=cms-fo. Spans include tenant/site attributes.
In the getDefaultHeaders() function, if running server-side and OTel is active, get current span context and inject traceparent header using W3CTraceContextPropagator. Import from @opentelemetry/core.
0.5d
3.2.2
Create custom SSR render span
server/plugins/otel.ts
Wrap SSR rendering in a custom span: tracer.startSpan('ssr.render', { attributes: { 'http.route': url } }). Record duration. End span after response sent.
0.25d
3.2.3
Verify end-to-end trace
—
Deploy FO + API on staging. Trigger a page load. Verify in Grafana Tempo that the trace shows: FO ssr.render → FO fetch /public/articles → API controller.
0.25d
Acceptance: Distributed traces span FO → API with correct parent-child relationships.
Verify no unsafe-inline or unsafe-eval in production build. Test by temporarily enforcing CSP and checking for violations. Remove any fallback unsafe-* directives.
0.25d
4.3.4
Add connect-src whitelist
server/plugins/csp-hash.ts
Replace hardcoded https://local.api.cms https://api.example.com with env-driven: NUXT_PUBLIC_API_URL, NUXT_PUBLIC_MEDIA_BASE_URL, NUXT_PUBLIC_MATOMO_URL.
async function checkA11y(page: Page, url: string): navigate to URL, inject axe, run axe.run() with WCAG 2.1 AA ruleset. Collect violations. Assert zero violations. Format violations for readable output.
These estimates assume DevOps provisions shared services on schedule.
Calendar dates depend on team capacity and external dependency resolution.
Effort is expressed in developer-days (1 developer).