Content Management
Content Management — Decoupled Marketing Stack
This architecture isolates the marketing frontend from the core application. By treating the marketing site as a standalone entity, marketing teams and frontend developers can rapidly iterate on copy, design, and A/B tests without triggering the heavy, complex deployment cycles of the primary application stack.
Overview
| Concern | Technology | Role |
|---|---|---|
| Content storage | Sanity.io | Headless CMS / Content Lake (marketing + in-app copy — never PHI) |
| Product analytics & experimentation | PostHog | One BAA-covered vendor spanning product analytics + feature flags + experiments + session replay + client error tracking + LLM observability & evals. This page covers the marketing site only (server-side posthog-node); the product apps are covered elsewhere — see the note below. |
| Marketing frontend | Next.js 14 on GCP Cloud Run | SEO-optimized page rendering (the ui/marketing service) |
Sanity never stores PHI — it holds marketing and in-app copy only. PostHog is BAA-covered (PostHog Cloud, Boost+ platform package), so it may hold product/behavioral data under the BAA; the discipline is to mask PHI in session replay and keep no PHI in flag keys, event names, or property keys. On the public marketing site, visitors are anonymous and identified by opaque IDs only. See HIPAA Compliance.
1. Core Architecture: Strict Separation of Concerns
The Marketing Frontend (Next.js 14 on Cloud Run)
A lightweight Next.js 14 application whose sole responsibility is rendering fast, SEO-optimized marketing pages. It lives in the monorepo as the ui/marketing workspace and deploys as its own GCP Cloud Run service — separate from the applications frontend (ui/web-app) and the backend API. A CSS tweak or hero-copy change ships through its own deploy workflow (GitHub Actions → Artifact Registry → Cloud Run) without touching the application pipeline.
Marketing is a Cloud Run service, not a Vercel deployment. Every frontend in Foundation — applications (ui/web-app, Vite + TanStack) and marketing (ui/marketing, Next.js 14) — runs on Cloud Run. The two are independent services, not a single deployment.
The Core Application
The primary product stack with its own rigorous CI/CD pipeline (GitHub Actions → GCP Cloud Run). It has no runtime dependency on the marketing frontend.
The Shared Content API (Sanity.io)
Although the two deployments are completely independent, Sanity acts as a universal Content Lake for both:
- Marketing site — fetches page copy, hero images, and structured content via GROQ queries at request time or during Next.js ISR (Incremental Static Regeneration).
- Core application — fetches in-app messaging, tooltips, onboarding flows, and help-center articles via the same Sanity Content API. The core app treats Sanity as any other REST/GraphQL backend — a simple
fetch()call, no additional infrastructure needed.
This single source of truth prevents copy drift between the marketing site and the in-app experience.
2. Content Management: Sanity.io
Sanity acts as the decoupled database for all text, images, and structured content.
Schema-Driven Content
Content models are defined in code inside the Sanity Studio project (TypeScript schemas). The marketing Next.js app always receives predictable, strictly-typed JSON payloads. Example schema fragment:
// sanity/schemas/heroSection.ts
export default {
name: 'heroSection',
title: 'Hero Section',
type: 'document',
fields: [
{ name: 'headline', title: 'Headline', type: 'string' },
{ name: 'subheadline', title: 'Subheadline', type: 'text' },
{ name: 'ctaLabel', title: 'CTA Button Label', type: 'string' },
{ name: 'ctaUrl', title: 'CTA Button URL', type: 'url' },
{ name: 'backgroundImage', title: 'Background Image', type: 'image' },
],
}Visual Editing & Live Previews
Marketing users interact with Sanity Studio — a configurable web UI — to edit copy or select A/B test variant text. Sanity's Presentation tool enables live, in-context visual editing overlaid directly on the Next.js marketing site.
Cache Invalidation via Webhooks
When a content editor clicks Publish in Sanity Studio, Sanity fires a webhook to a /api/revalidate route hosted by the ui/marketing Cloud Run service. The route calls revalidatePath() or revalidateTag(), invalidating the relevant ISR cache entries. The live site reflects the new copy within seconds — no code deployment required.
Editor publishes in Sanity Studio
│
▼
Sanity fires POST webhook ──► ui/marketing /api/revalidate (Cloud Run)
│
▼
revalidatePath('/')
(or targeted revalidateTag)
│
▼
Fresh HTML served on next request
3. User Experimentation: PostHog
PostHog handles the statistical modeling and traffic-splitting for A/B tests (feature flags + experiments) with minimal frontend overhead — and doubles as the product-analytics and session-replay tool, so experiment exposures and outcomes land in the same system.
Scope: this section is the marketing site's server-side PostHog (posthog-node, running in ui/marketing middleware) — anonymous visitors, no PHI, flag-driven copy. The product apps wire PostHog differently: ui/web-app uses posthog-js and ui/ios uses posthog-react-native, both with session-replay masking on because they run on clinical screens — that setup lives in Frontend and Mobile. PostHog's full role across the stack (analytics + flags + experiments + replay + client error tracking + LLM observability & evals) is described in Observability.
Evaluating a flag should not add a network round-trip per request. Enable PostHog local evaluation: the posthog-node client polls flag definitions in the background and evaluates them in-process, so getFeatureFlag() resolves from memory. Instantiate a single long-lived PostHog client at module scope and reuse it across requests (constructing one per request defeats local evaluation and leaks connections).
On the marketing site, identify visitors by opaque IDs only (e.g., a random ph_visitor_id cookie) — never pass PHI (names, emails, MRNs) as a distinct ID or event property. PostHog is BAA-covered, but the discipline still holds: no PHI in flag keys, event names, or property keys, and enable PHI masking on session replay. See HIPAA Compliance.
Server-Side Variant Assignment (Next.js Middleware)
Next.js middleware intercepts every request before it reaches the React rendering layer, running inside the ui/marketing Cloud Run service. The middleware:
- Reads (or sets) a stable visitor ID cookie (
ph_visitor_id). - Calls the PostHog SDK with the visitor ID to evaluate which experiment (feature-flag) variant the visitor qualifies for.
- Stores the assigned variant in a request header (e.g.,
x-ph-variant: hero-b) so the page rendering functions can read it.
// lib/posthog.ts — a single long-lived server client (enables in-process local evaluation)
import { PostHog } from 'posthog-node'
export const posthog = new PostHog(process.env.POSTHOG_KEY!, {
host: process.env.POSTHOG_HOST,
// Poll flag definitions in the background so getFeatureFlag() resolves from memory (no per-request call).
personalApiKey: process.env.POSTHOG_PERSONAL_API_KEY,
})// middleware.ts
import { NextRequest, NextResponse } from 'next/server'
import { posthog } from '@/lib/posthog'
export async function middleware(req: NextRequest) {
const visitorId = req.cookies.get('ph_visitor_id')?.value ?? crypto.randomUUID()
// Evaluate the experiment flag for this anonymous visitor — opaque ID only, never PHI.
const variant =
(await posthog.getFeatureFlag('hero-experiment', visitorId)) ?? 'control'
// Forward the variant as a *request* header so Next.js Server Components
// can read it via `headers()` from 'next/headers'.
const requestHeaders = new Headers(req.headers)
requestHeaders.set('x-ph-variant', String(variant))
const res = NextResponse.next({
request: {
headers: requestHeaders,
},
})
res.cookies.set('ph_visitor_id', visitorId, { maxAge: 60 * 60 * 24 * 365 })
return res
}
export const config = { matcher: ['/'] }Zero-Flicker Rendering
Once the middleware assigns a variant, the Next.js server fetches the corresponding variant copy from Sanity and serves a fully rendered HTML page. There is no client-side DOM manipulation, hydration mismatch, or UI flicker — the browser receives the correct content on the very first byte.
User request
│
▼
Next.js Middleware (ui/marketing on Cloud Run)
├─ Read/set visitor cookie
└─ Evaluate PostHog flag → variant = "hero-b"
│
▼
Next.js Page (Server Component)
└─ GROQ query: heroSection[variant == "hero-b"]
│
▼
Sanity Content API returns variant copy
│
▼
Fully-rendered HTML → browser (zero flicker)
Connecting Variant Data to Sanity
Sanity content schemas are extended to support variant fields or separate variant documents:
// sanity/schemas/heroSection.ts (extended for experiments)
{
name: 'experimentVariant',
title: 'Experiment Variant Key',
type: 'string',
description: 'PostHog experiment / feature-flag variant key (e.g. "control", "hero-b")',
}The GROQ query filters on this field at request time:
*[_type == "heroSection" && experimentVariant == $variant][0] {
headline,
subheadline,
ctaLabel,
ctaUrl,
backgroundImage
}4. Component Wiring: Sanity + PostHog
Marketing UI components are authored in React, Next.js, and Tailwind CSS. When AI-assisted scaffolding is used, it is Claude Code (see AI-Native Development) — output is committed straight into the ui/marketing workspace and reviewed like any other code.
Integration with Sanity & PostHog
Each component is wired up to live data in two steps:
- Sanity data — Replace hard-coded strings with props fetched via GROQ queries in the parent Server Component (see the placeholder pattern below).
- PostHog flags — The parent Server Component reads the
x-ph-variantheader (set by the Next.js middleware) and passes the appropriate variant props down to the component.
// app/page.tsx — Server Component wiring everything together
import { headers } from 'next/headers'
import { sanityFetch } from '@/lib/sanity'
import HeroComponent from '@/components/HeroComponent'
export default async function HomePage() {
const variant = headers().get('x-ph-variant') ?? 'control'
const hero = await sanityFetch<HeroData>({
query: HERO_QUERY,
params: { variant },
placeholder: {
headline: 'The Fastest Way to Build Your Product',
subheadline: 'Foundation gives your team a production-ready architecture from day one.',
ctaLabel: 'Get Started Free',
},
})
return <HeroComponent title={hero.headline} subtitle={hero.subheadline} ctaLabel={hero.ctaLabel} />
}5. Developer Velocity: Local Development & Placeholder Pattern
To ensure high developer velocity, engineers must be able to view and interact with the rendered application locally without requiring an active Sanity connection or a live PostHog API key.
Crucial Rule: Default Placeholder Props
Every React/Next.js component that is designed to consume Sanity API data must include robust default placeholder values for all of its props.
This allows developers (and AI agents generating components) to instantly preview rendered UI in a local environment or Storybook simply by dropping the component into a route — completely bypassing the need to fetch live backend data during initial UI iteration.
Example:
// components/HeroComponent.tsx
interface HeroComponentProps {
title?: string
subtitle?: string
ctaLabel?: string
ctaUrl?: string
}
const HeroComponent = ({
title = 'The Fastest Way to Build Your Product',
subtitle = 'Foundation gives your team a production-ready architecture from day one — HIPAA-compliant, fully observable, and infinitely scalable.',
ctaLabel = 'Get Started Free',
ctaUrl = '/signup',
}: HeroComponentProps) => {
return (
<section className="flex flex-col items-center gap-6 py-24 text-center">
<h1 className="text-5xl font-bold tracking-tight">{title}</h1>
<p className="max-w-2xl text-xl text-muted-foreground">{subtitle}</p>
<a href={ctaUrl} className="rounded-lg bg-primary px-8 py-3 text-primary-foreground">
{ctaLabel}
</a>
</section>
)
}
export default HeroComponentDropping <HeroComponent /> with no props into any route immediately renders a realistic, visually complete page with no external dependencies.
Local Sanity Fallback Pattern
For more complex components that issue GROQ queries, the data-fetching helper should return placeholder data when the Sanity project ID is not configured:
// lib/sanity.ts
export async function sanityFetch<T>({ query, params, placeholder }: {
query: string
params?: Record<string, unknown>
placeholder: T
}): Promise<T> {
if (!process.env.NEXT_PUBLIC_SANITY_PROJECT_ID) {
return placeholder
}
// ... live fetch
}The calling page passes placeholder data alongside the query, so the component tree renders correctly in any environment.
Environment Variables
| Variable | Required | Description |
|---|---|---|
NEXT_PUBLIC_SANITY_PROJECT_ID | Prod only | Sanity project ID |
NEXT_PUBLIC_SANITY_DATASET | Prod only | Sanity dataset (e.g. production) |
SANITY_API_TOKEN | Prod only | Server-side Sanity read token (for draft previews) |
POSTHOG_KEY | Prod only | PostHog project API key |
POSTHOG_HOST | Prod only | PostHog Cloud host URL (e.g. https://us.i.posthog.com) |
POSTHOG_PERSONAL_API_KEY | Prod only | Personal API key that enables in-process local flag evaluation |
All variables are optional in local development — the placeholder pattern handles their absence gracefully.
End-to-End Request Flow
Browser Request: GET /
│
▼
GCP Cloud Run (ui/marketing)
└─ Next.js Middleware
├─ Reads ph_visitor_id cookie (or generates one)
├─ Calls PostHog → assigns variant "hero-b"
└─ Sets x-ph-variant: hero-b header
│
▼
Next.js Server Component (app/page.tsx)
├─ Reads x-ph-variant header
├─ GROQ fetch → Sanity Content API
│ └─ Returns hero copy for variant "hero-b"
└─ Renders HeroComponent with live props
│
▼
Fully-rendered HTML sent to browser
(no client-side flicker, no layout shift)
│
▼
PostHog captures the experiment-exposure event
($feature_flag_called) for statistical analysis
Marketing runs as its own Cloud Run service (ui/marketing), separate from the applications frontend (ui/web-app) and the backend API — but it is still part of the same GCP Cloud Run platform, not a third-party host. Marketing content updates (copy edits, A/B variant launches) require zero involvement from backend or platform engineers.