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 Runnot 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

AspectApplications (ui/web-app)Marketing (ui/marketing)
FrameworkVite + TanStack (Router + Query)Next.js 14 (App Router)
LanguageTypeScript (strict)TypeScript (strict)
HostingGCP Cloud RunGCP Cloud Run
AuthBetter Auth (hosted here)None
Data accessDrizzle → Neon (direct Postgres, RLS)None (static / CMS content)
TestingVitest + Testing LibraryVitest + 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 so auth.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 cookieCache so 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 connectionnever 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:

  1. The ui/web-app server issues the Better Auth session and a signed JWT (Better Auth's JWKS endpoint lives here — see above).
  2. Browser calls to the Python backend/api go through the generated openapi-fetch client (types + client in src/lib/api/, see Backend), which attaches the Better Auth JWT as a bearer token.
  3. backend/api validates that JWT against the JWKS (BETTER_AUTH_JWKS_URL) and binds it to the Neon connection, so Neon RLS (pg_session_jwt + the authenticated role) keys row access on auth.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

VariableSurfacePublic?Description
DATABASE_URLapp serverNoNeon connection (Better Auth + Drizzle)
BETTER_AUTH_SECRETapp serverNoBetter Auth signing secret
VITE_API_URLappYesFastAPI API base URL (inlined at build)
NEXT_PUBLIC_API_URLmarketingYesBase 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-query
  • better-auth — auth server + client
  • drizzle-orm + pg — data access (Neon)
  • openapi-typescript + openapi-fetch — typed API client generated from the FastAPI OpenAPI schema into src/lib/api/ (via pnpm 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: true and mask clinical text (maskTextSelector covering 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.