> ## Documentation Index
> Fetch the complete documentation index at: https://q.odontox.io/llms.txt
> Use this file to discover all available pages before exploring further.

# 2026 05 12 public pages perf design

# Public Pages Performance — Design

**Date:** 2026-05-12
**Status:** Approved
**Scope:** Performance optimization for unauthenticated/public pages on `odontox.io` only

## Problem

Pingdom HAR (2026-05-12) shows the public landing page at `https://odontox.io/`:

* **Fully loaded:** 1575ms
* **DOMContentLoaded:** 1315ms
* **HTTP→HTTPS redirect:** 146ms wasted round-trip
* **Main JS chunk (`BBB4AvFt.js`):** 1.4MB raw / 432KB brotli — single chunk, 387ms total
* **CSS chunk (`B0vcyaqK.css`):** 504KB raw / 56KB brotli, 321ms total
* **Origin TTFB:** \~384ms (`server-timing: cfOrigin;dur=384`)
* **Cache status:** `MISS` on entry chunk + CSS despite immutable headers (cold edge node)

Root cause investigation (`ui/vite.config.ts`, `ui/src/main.tsx`, lazy-route audit):

The public entry chunk is bloated because **dashboard-only deps are statically imported by code that the public landing route ultimately pulls in via the shared React provider tree**. Specifically: the full Radix UI surface, `recharts`, `framer-motion`, `gsap`. The landing page itself does not render these, but they are included in the entry chunk because of import graph reachability.

Secondary issues:

* No `<link rel="preload">` for above-the-fold fonts (Poppins woff2)
* No `<link rel="preconnect">` for `api.odontox.io`
* No HSTS preload — every cold visit pays the 146ms redirect
* No build-time prerendering — every visitor hydrates a full React tree from a blank shell
* Hero / og-image served as PNG/JPEG, not AVIF/WebP

## Scope

### In scope (public routes, root domain `odontox.io` and `www.odontox.io`)

* `/` — Landing
* `/legal` — Legal hub
* `/privacy`, `/terms`, `/book` — Redirects into `/legal` (still need fast first-paint)
* `/the-recall-effect` — Marketing page
* `/refer`, `/refer-payout-form` — Referral pages
* `/logout-success`, `/login-success` — Post-auth transition pages

### Out of scope (untouched)

* All authenticated routes (`/dashboard/*`, `/patients/*`, `/clinical/*`, etc.)
* Auth subdomain (`id.odontox.io/auth/*`)
* Dashboard subdomain (`go.odontox.io/*`)
* Patient portal (`portal.odontox.io/*`)
* Mobile app, server worker, database, any API endpoint
* Any change to authentication, routing, session, or data fetching logic

## Strategy

Three independent, revertible layers. Each ships separately; each can be reverted without affecting the others.

### Layer 1 — Build-time prerendering of public routes

Add a Vite SSG plugin (`vite-react-ssg` preferred; falls back to `vite-plugin-prerender` if compatibility issues with React 19 + Tailwind v4 emerge during prototyping).

For each public route, the plugin renders the React tree at build time and writes `dist/<route>/index.html` containing the fully-rendered HTML. Cloudflare Pages serves these directly. React still hydrates on the client using the same JS bundle and the same component tree — only the first paint becomes real content instead of a blank shell.

**Code paths unchanged.** The same React components run at build time (Node) and runtime (browser). Hydration uses `hydrateRoot` instead of `createRoot`.

**Hydration safety:** components in the public route tree must produce deterministic output. Any `window`, `localStorage`, `document`, `Date.now()`, `Math.random()`, or user-agent-dependent read during render must be gated behind `useEffect` or `useSyncExternalStore`. Plan includes a pre-audit task.

### Layer 2 — Trim the public JS bundle

Audit static import graph from the public entry. Identify dashboard-only deps (Radix UI components not used by public routes, `recharts`, `framer-motion`, `gsap`) that are reachable from the public entry via shared providers, layouts, or utility modules.

For each such dep:

1. If no public route renders it → split into a chunk that loads only behind authenticated routes via dynamic `import()` at the route boundary.
2. If a public route renders it but only below the fold → lazy-load that section with `React.lazy()` and Suspense.
3. If a public route renders it above the fold → leave alone.

The existing `manualChunks` config in `ui/vite.config.ts` already isolates the largest libs (`vendor-dicom`, `vendor-three`, `vendor-pdf`, etc.). This step extends that pattern.

### Layer 3 — Network & asset hygiene

In `ui/index.html`:

* `<link rel="preload" as="font" type="font/woff2" href="/fonts/poppins-latin-400.woff2" crossorigin>` for the two above-the-fold weights (400, 600)
* `<link rel="preconnect" href="https://api.odontox.io" crossorigin>` and `<link rel="preconnect" href="https://assets.odontox.io" crossorigin>`
* `<link rel="dns-prefetch">` for analytics origins

In `ui/public/_headers`:

* Add `Strict-Transport-Security: max-age=604800; includeSubDomains` initially (1 week). After 1 week of clean traffic, escalate to `max-age=63072000; includeSubDomains; preload` and submit to hstspreload.org.

In R2 / asset pipeline:

* Convert hero image + og-image to AVIF (primary) + WebP (fallback)
* Use `<picture>` with `<source type="image/avif">` and `<source type="image/webp">`
* Confirm `Cache-Control: public, immutable, max-age=31536000` on hashed asset paths

## Measurement

Baseline captured before any change ships; post-deploy captured after each layer.

**Tools:**

* Local: Playwright + Lighthouse (mobile profile, throttled to "Slow 4G")
* Real-edge: Google PageSpeed Insights API targeting `https://odontox.io/` from a fixed location

**Metrics captured per run:**

* FCP, LCP, TTI, TBT, CLS, Speed Index
* Total transferred bytes (JS, CSS, fonts, images, total)
* Number of requests
* Lighthouse Performance score

**Storage:** `docs/perf/baseline-2026-05-12.json` and `docs/perf/post-<layer>-<date>.json`. A small markdown summary in `docs/perf/README.md` tracks the trend.

## Acceptance criteria

Public routes:

* Lighthouse mobile Performance ≥ 90 on `/` (currently estimated 50–70)
* FCP \< 1.2s on Slow 4G profile
* LCP \< 2.0s on Slow 4G profile
* TBT \< 200ms
* Public entry JS (brotli) \< 150KB (currently 432KB)

Non-regression:

* All public routes visually identical (manual screenshot diff)
* All public route functionality preserved (manual smoke test: navigation, scroll, modals, forms)
* All authenticated/dashboard routes unaffected — verified by:
  * Build artifact diff: chunks named under `vendor-*` and the dashboard route chunks must be the same size ±2% before and after Layer 2
  * Manual smoke test of `go.odontox.io` dashboard after deploy
* Zero React hydration warnings in browser console on any public route

## Risks & mitigations

| Risk                                                         | Likelihood | Impact                                    | Mitigation                                                                                                                                        |
| ------------------------------------------------------------ | ---------- | ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- |
| Hydration mismatch from non-deterministic render             | Medium     | Visible re-render flash                   | Pre-audit task to find and gate all `window`/`localStorage`/`Date.now()` reads behind `useEffect`. React 19 warns loudly — caught in dev.         |
| Public component uses a dep we tried to split out            | Medium     | Brief lazy-load delay on interaction      | Step-by-step audit per dep before moving anything. Static analysis via `madge` or hand-traced. If any public route uses it, dep stays.            |
| HSTS preload locks us to HTTPS for 2 years                   | Low        | Hard to revert if we ever need plain HTTP | Two-stage rollout: 1-week max-age first, escalate to preload only after clean traffic. Already 100% HTTPS today.                                  |
| Prerender breaks on a route with weird state                 | Low        | Build fails or HTML is wrong              | Per-route prerender is opt-in via the route list. If a route errors, drop it from the list and ship the rest. Falls back to today's SPA behavior. |
| Cloudflare Pages doesn't serve nested `index.html` correctly | Low        | 404 on `/legal/`                          | Pre-check Cloudflare Pages SSG support; fall back to flat `/legal.html` + `_redirects` rewrite if needed.                                         |

## Rollout

Four deploy phases, each independently revertible. Ordered by ascending risk so any regression is caught with the smallest possible change set still live:

1. **Phase 1 — Network hints + images** (from Layer 3, lowest risk): preload/preconnect hints, HSTS short max-age, AVIF/WebP images. Ship. Observe 24h.
2. **Phase 2 — Bundle trim** (Layer 2, medium risk, biggest size win): chunk-split dashboard deps. Ship. Observe 48h. Verify dashboard bundle sizes unchanged.
3. **Phase 3 — Prerender** (Layer 1, medium risk, biggest first-paint win): prerender public routes. Ship. Observe 48h. Verify zero hydration warnings.
4. **Phase 4 — HSTS preload finalize**: escalate HSTS `max-age` from 1 week to 2 years + add `preload`; submit to hstspreload.org.

Each deploy uses the standard `odontox-commit-deploy` workflow (skill).

## Out of scope, deferred

* Splitting public pages into a separate Astro/Vite app (option C from brainstorm) — bigger refactor, save for later if Layer 1+2 don't hit acceptance criteria
* Service worker / offline support
* HTTP/3 tuning (already enabled by Cloudflare)
* Image CDN (already on R2 with Cloudflare CDN)
* Tailwind config tuning (v4 CSS-driven, already lean)
