Frontend (Web)
Frontend — Applications + Marketing
There are two web surfaces, both deployed to GCP Cloud Run (no Vercel):
ui/web-app— the applications UI. A full-stack Vite + TanStack server (Router + Query, TypeScript) running on Node → GCP Cloud Run — not a static SPA. Its server runtime hosts Better Auth and server-side Drizzle data access, so it needs runtime secrets (DATABASE_URL,BETTER_AUTH_SECRET) — not just build-time config. This is where authenticated, PHI-touching product features live.ui/marketing— the marketing site. Built with Next.js 14 (App Router), TypeScript. Public content only — no auth, no PHI.
Core Stack
| Aspect | Applications (ui/web-app) | Marketing (ui/marketing) |
|---|---|---|
| Framework | Vite + TanStack (Router + Query) | Next.js 14 (App Router) |
| Language | TypeScript (strict) | TypeScript (strict) |
| Hosting | GCP Cloud Run | GCP Cloud Run |
| Auth | Better Auth (hosted here) | None |
| Data access | Drizzle → Neon (direct Postgres, RLS) | None (static / CMS content) |
| Testing | Vitest + Testing Library | Vitest + Testing Library |
| Coverage | ≥ 80% | ≥ 80% |
Auth — Better Auth (on the applications-UI server)
Authentication is Better Auth, self-hosted by the applications-UI server (the Vite + TanStack app's server runtime) — not the marketing site.
- Better Auth stores its tables in Neon (Postgres adapter) → inside the HIPAA boundary; auth branches with each Neon branch for free.
- It issues sessions and a JWKS endpoint. The FastAPI backend and Neon RLS (
pg_session_jwt) validate those JWTs soauth.user_id()drives row access. - Supports email/password, OAuth social, sessions, and 2FA / SSO plugins.
The marketing site (Next.js) hosts no auth and touches no PHI. Only the applications-UI server runs Better Auth and connects to Neon.
Serverless notes (Cloud Run scale-to-zero)
Better Auth is a request-handler library, not an always-on service — all its state (users, sessions, JWKS keys) lives in Neon, so it bundles into the ui/web-app container and scales to zero cleanly (no background worker). Configure it for serverless:
- Rate-limit storage — the default is in-memory, which is wrong for scale-to-zero / multi-instance (not shared, wiped on every cold start). Point it at Neon (or a secondary store). This is the one that bites people.
- Session validation — enable
cookieCacheso most requests skip a Neon round-trip on session lookup, cutting latency + DB load when bursty cold starts all hit the database. - Connections — use Neon's pooled connection string so a burst of Cloud Run instances doesn't exhaust Postgres connections.
- JWKS — have the FastAPI API cache the JWKS (long TTL) so a request during an idle window doesn't cold-start the app server just to fetch keys.
Optional: set min-instances=1 on ui/web-app only if auth-path cold-start latency matters — a cost/latency trade-off, not a requirement.
Data access (applications UI)
Server-side only, via Drizzle ORM over a direct Neon Postgres connection — never the Neon Data API (it sits outside the HIPAA boundary). Drizzle-managed RLS (crudPolicy, authenticatedRole) — defined in the top-level schema/ package alongside the tables and migrations — keeps authorization reviewable through the PR schema-diff gate. See Backend and HIPAA Compliance.
Browser → API calls
The applications UI talks to two backends, and the auth token flows through both:
- The
ui/web-appserver issues the Better Auth session and a signed JWT (Better Auth's JWKS endpoint lives here — see above). - Browser calls to the Python
backend/apigo through the generatedopenapi-fetchclient (types + client insrc/lib/api/, see Backend), which attaches the Better Auth JWT as a bearer token. backend/apivalidates that JWT against the JWKS (BETTER_AUTH_JWKS_URL) and binds it to the Neon connection, so Neon RLS (pg_session_jwt+ theauthenticatedrole) keys row access onauth.user_id().
So the same Better Auth identity that gates the UI server also drives DB-level row isolation on the API — no second login, one identity end-to-end. (Mobile does the same with its session token — see Mobile.)
Environment Variables
| Variable | Surface | Public? | Description |
|---|---|---|---|
DATABASE_URL | app server | No | Neon connection (Better Auth + Drizzle) |
BETTER_AUTH_SECRET | app server | No | Better Auth signing secret |
VITE_API_URL | app | Yes | FastAPI API base URL (inlined at build) |
NEXT_PUBLIC_API_URL | marketing | Yes | Base URL for the marketing site |
VITE_* / NEXT_PUBLIC_* values are inlined into client bundles at build time — they must be safe to expose. Server-only secrets (DATABASE_URL, BETTER_AUTH_SECRET) come from GCP Secret Manager and are never sent to the browser.
Dependencies
Applications (ui/web-app) — runtime
vite,@tanstack/react-router,@tanstack/react-querybetter-auth— auth server + clientdrizzle-orm+pg— data access (Neon)openapi-typescript+openapi-fetch— typed API client generated from the FastAPI OpenAPI schema intosrc/lib/api/(viapnpm gen:api; see Backend)posthog-js— product analytics + session replay on the client (masking on — see below)
Marketing (ui/marketing) — runtime
next
Dev / Test (both)
vitest+@testing-library/react
Product analytics — PostHog (posthog-js)
ui/web-app initialises posthog-js on the client for product analytics, feature flags, experiments, session replay, and client error tracking. Server-side flag evaluation / event capture uses posthog-node (single long-lived client, local evaluation) as on the marketing site. PostHog's full role across the stack (analytics + flags + experiments + replay + client error tracking + LLM observability & evals) is described in Observability and Content Management.
Because replay runs on clinical screens, masking is not optional — this closes a real HIPAA gap:
- Turn on session-replay masking:
maskAllInputs: trueand mask clinical text (maskTextSelectorcovering PHI-bearing elements). - Identify by opaque user IDs only — never a name, email, or MRN.
- Keep no PHI in event names, property keys, or flag keys. See HIPAA Compliance.
// lib/analytics.ts
import posthog from 'posthog-js'
posthog.init(import.meta.env.VITE_POSTHOG_KEY, {
api_host: import.meta.env.VITE_POSTHOG_HOST,
session_recording: {
maskAllInputs: true, // never record raw input values
maskTextSelector: '[data-phi]', // mask clinical text nodes
},
})Cloud Run Deployment
Both surfaces are containerised and deployed to GCP Cloud Run, matching the backend worker. Each has its own Dockerfile; the marketing Next.js build uses output: 'standalone'.
Using Cloud Run for all web + backend keeps everything under one provider, one set of IAM policies, and one GCP BAA.