Public Pages Performance Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.
Goal: Make the public pages of odontox.io (landing + legal + referral + transition pages) load and paint dramatically faster, without breaking authenticated routes.
Architecture: Four independent, revertible phases shipped in ascending risk order: (1) network hints + AVIF/WebP images + short-max-age HSTS, (2) JS bundle trim via guarded manualChunks splits, (3) Puppeteer-driven snapshot prerender (Playwright, already a dep) that visits each public route on a local vite preview and writes static HTML, (4) HSTS preload finalize.
Tech Stack: Vite, React 19, Tailwind v4, Cloudflare Pages, Playwright (for prerender + measurement), Lighthouse (via Playwright + chrome-launcher).
Spec: docs/superpowers/specs/2026-05-12-public-pages-perf-design.md
File Map
| File | Action | Responsibility |
|---|---|---|
ui/index.html | Modify | Add preload/preconnect/dns-prefetch hints |
ui/public/_headers | Modify | HSTS (short, then long+preload), confirm cache headers |
ui/public/_redirects | Modify | Map nested public route HTML files (Phase 3) |
ui/vite.config.ts | Modify | Add guarded manualChunks for dashboard-only deps |
ui/src/main.tsx | Modify | Emit window.__PRERENDER_READY__ after mount (Phase 3) |
ui/scripts/audit-public-deps.mjs | Create | Static-import graph audit per public route |
ui/scripts/prerender.mjs | Create | Snapshot prerender using Playwright |
ui/scripts/prerender-routes.json | Create | Route list + per-route wait conditions |
ui/scripts/perf-measure.mjs | Create | Lighthouse run via Playwright |
ui/package.json | Modify | New build chain, prerender, audit:public, perf:measure scripts |
ui/postbuild.js | Modify | Apply data-cfasync injection to all prerendered HTML, not just index.html |
ui/src/components/landing/PremiumHero.tsx | Modify (if needed) | Gate any non-deterministic render reads (Phase 3 hydration safety) |
docs/perf/README.md | Create | Perf tracking log |
docs/perf/baseline-2026-05-12.json | Create | Pre-change Lighthouse + asset sizes |
docs/perf/post-phase-N-<date>.json | Create (per phase) | Post-deploy measurements |
Task 1: Capture baseline measurements
Why: every later task is judged against this. Without numbers we can’t tell if we made things worse. Files:-
Create:
ui/scripts/perf-measure.mjs -
Modify:
ui/package.json -
Create:
docs/perf/README.md -
Create:
docs/perf/baseline-2026-05-12.json - Step 1: Write the measurement script
ui/scripts/perf-measure.mjs:
- Step 2: Install the one missing dep
ui/:
lighthouse added to devDependencies. playwright is already present.
- Step 3: Add npm script
ui/package.json scripts block, add:
- Step 4: Run the baseline
ui/:
docs/perf/baseline-2026-05-12.json with one entry per route. The Performance score for / should be roughly in the 50–70 range based on the HAR.
- Step 5: Write the trend log
docs/perf/README.md:
- Step 6: Commit
Task 2: Phase 1 — Resource hints in index.html
Why: preload critical fonts and warm the API/asset connections before the JS bundle even parses. Cheapest possible win.
Files:
-
Modify:
ui/index.html - Step 1: Add preload + preconnect block
ui/index.html, immediately after the <meta name="theme-color" ...> line and BEFORE the <title> (so hints are discovered as early as possible by the preload scanner), insert:
-
preconnectis reserved for origins we hit on first paint (api.odontox.iofor auth check on hydration,assets.odontox.iofor the og-image and any hero asset) -
dns-prefetchfor origins we probably hit but don’t want to pay full TCP+TLS for -
Only
400and600Poppins are preloaded — they cover the body + h1/h2 above the fold.500/700load on demand -
crossoriginis required on font preload (woff2 fonts are CORS-cross-origin by spec even when same-origin) - Step 2: Verify the font filenames exist
- Step 3: Build and visually check
ui/dist/index.html and confirm the new <link> lines are present. Confirm the postbuild script still ran (look for data-cfasync="false" on script tags).
- Step 4: Commit
Task 3: Phase 1 — HSTS short max-age
Why: the 146ms HTTP→HTTPS redirect costs every first-time visitor. HSTS tells the browser “always use HTTPS for this origin.” We start short so it’s easy to revert if some unknown HTTP-only path exists; we escalate later in Phase 4. Files:-
Modify:
ui/public/_headers - Step 1: Add HSTS to the global block
ui/public/_headers, inside the existing /* global block (line 5 area, where Content-Security-Policy: etc. live), add a new line at the end of that block:
604800 (1 week): if anything breaks on a subdomain we missed (e.g., a debug subdomain on plain HTTP), revert is cheap — browsers forget the policy after a week. We escalate to 2 years + preload in Phase 4 after observing one clean week.
Why no preload directive yet: that’s a one-way commitment (registry submission). Phase 4.
- Step 2: Confirm no subdomain runs on plain HTTP
odontox.io,www.odontox.io— Cloudflare-fronted, HTTPS-only ✓id.odontox.io,go.odontox.io,portal.odontox.io— Cloudflare Pages, HTTPS-only ✓api.odontox.io,ph.odontox.io,tenants.odontox.io— Cloudflare Workers, HTTPS-only ✓assets.odontox.io— Cloudflare R2/CDN, HTTPS-only ✓stg.sshssn.workers.dev(staging fallback) — HTTPS-only ✓
includeSubDomains before continuing.
- Step 3: Commit
Task 4: Phase 1 — Hero/og-image AVIF + WebP
Why: the og-image is a 1200×630 PNG served fromassets.odontox.io/odontox-prod-asset/og-image.png. PNG → AVIF typically drops 70–80% of bytes; AVIF + WebP fallback covers 100% of modern browsers.
Files:
- Investigate first, then potentially modify components that render hero/og imagery
- Step 1: Locate the source files
ui/public/og-image.png), convert it locally with sharp or cwebp/avifenc:
https://assets.odontox.io/odontox-prod-asset/og-image.png), download → convert → re-upload via the existing R2 tooling (look for an r2 upload helper in scripts/ or server/).
- Step 2: Pause for user confirmation before touching R2
assets.odontox.io is a shared production bucket. Stop and ask the user before uploading anything new. Show them the local AVIF/WebP file sizes vs the original PNG and ask for go-ahead.
- Step 3: After confirmation — upload AVIF + WebP alongside the PNG
og-image.avifog-image.webp
curl -I https://assets.odontox.io/odontox-prod-asset/og-image.avif returns 200.
- Step 4: Update
<meta property="og:image">if a separate hero image is in play
og:image meta in index.html is used by social crawlers, not by the rendered page. Crawlers like Facebook/X don’t reliably handle AVIF — leave og:image pointing at the PNG. No change to index.html.
For the actual rendered hero / above-the-fold image in the landing page itself, locate the <img> (likely in PremiumHero.tsx or Landing.tsx) and replace it with a <picture>:
width/height attributes prevent CLS. fetchpriority="high" and loading="eager" ensure the LCP element is prioritized (the existing applyImageLoadingPolicy in main.tsx won’t override loading="eager" because it explicitly checks getAttribute('loading') !== 'eager').
- Step 5: If no obvious hero image exists in the landing components
- Step 6: Build and visually verify
<picture> in DevTools — Network panel should show the AVIF request, not the PNG.
- Step 7: Commit
Task 5: Phase 1 deploy
- Step 1: Verify type check + lint pass
ui/:
- Step 2: Run measurement against local preview to sanity-check
- Step 3: Deploy via the odontox-commit-deploy skill
odontox-commit-deploy skill. Use commit message:
- Step 4: Post-deploy verification
- Step 5: Capture Phase 1 measurement
docs/perf/README.md.
- Step 6: Observe 24h
Task 6: Phase 2 — Static-import audit script
Why: the spec assumed Radix/recharts/framer-motion/gsap could all be split out of the public entry. Reality check: framer-motion is imported by 14+ public components (TheRecallEffectPage, ReferEarn, LoginSuccess, LogoutSuccess, DetailedFeatures, BridgeSection, BeforeAfter, BentoFeatures, RubySection, MobileAppCTA, DicomSection, CTA, FAQ, CookieBanner, ScrollToTop). It cannot be split out. We need to know per-dep whether it’s actually reachable from the public route tree before moving anything. Files:-
Create:
ui/scripts/audit-public-deps.mjs -
Modify:
ui/package.json - Step 1: Write the audit script
ui/scripts/audit-public-deps.mjs:
-
Captures only static
import/export ... fromsyntax. Dynamicimport()is intentionally ignored — that’s what we want to encourage. -
Treats
@/as the alias forsrc/(matchesui/vite.config.tsresolve.alias). -
Walks transitively until it hits
node_modulesdeps, then records them. -
Doesn’t actually trace into
node_modules— we only care about whether our code statically reaches the dep. - Step 2: Add npm script
ui/package.json scripts:
- Step 3: Run the audit and save its output
framer-motion will show ❌ (reachable). At minimum recharts should show ✅. Some Radix components likely ✅, others ❌.
- Step 4: Commit
Task 7: Phase 2 — Add manualChunks for confirmed-safe deps only
Why: the audit from Task 6 tells us exactly which deps can be safely chunked out. Any dep that ❌‘d in the audit stays in the public entry because moving it would either break a public route or force a lazy-load that hurts UX. Files:-
Modify:
ui/vite.config.ts - Step 1: Read the audit output
docs/perf/dep-audit-2026-05-12.txt. For each ✅ entry, that dep is a candidate. For each ❌, skip it.
- Step 2: Extend the manualChunks function
ui/vite.config.ts, inside the existing manualChunks(id) function (line ~70), add new conditional blocks BEFORE the return undefined; line. Only add a block for deps marked ✅ in the audit. Example for recharts (almost certainly safe) and any safe Radix components:
-
One new
vendor-*chunk per category of safe deps (don’t make 20 tiny chunks) -
d3-*rides withrechartsbecause recharts depends on a bunch of d3 sub-packages -
Comment each new block with
// confirmed safe by audit YYYY-MM-DD - Step 3: Build and inspect chunk sizes
-
The size of the new
vendor-charts(etc.) chunk(s) - The size of the main entry chunk before/after — there’s no “before” in this build, so compare against the HAR baseline (432KB brotli)
-
Watch for new circular-import / TDZ warnings in the build log. If you see “Cannot set properties of undefined” or “ReferenceError: … is not defined” warnings, back out the chunk that introduced it — that’s the same class of bug the existing comments in
vite.config.tswarn about. - Step 4: Smoke-test the preview build
http://localhost:4173/ and click through:
/(landing)/legal,/the-recall-effect,/refer,/refer-payout-form- Log in via
http://id.localhost:4173/auth/loginif local subdomain routing is set up (skip if not) - Navigate to
/dashboard(you’ll need a real session — alternatively just confirm the route loads its chunks)
- Step 5: Commit
Task 8: Phase 2 — Audit verifies dashboard chunks didn’t grow
Why: the spec’s non-regression criterion says “build artifact diff: chunks named undervendor-* and the dashboard route chunks must be the same size ±2% before and after Layer 2.” We need to confirm this.
Files: none
- Step 1: Capture chunk inventory pre-change
docs/perf/chunks-pre-phase-2.txt.
If you didn’t save it, rebuild from the commit before this task and capture:
- Step 2: Capture chunk inventory post-change
- Step 3: Diff and verify
- Main entry chunk should be smaller (we moved deps out)
- A new
vendor-charts(etc.) chunk should appear - Existing
vendor-dicom,vendor-three,vendor-pdf, etc. should be unchanged within ±2% - Dashboard component chunks (likely named by hash) should be unchanged within ±2%
manualChunks block, rerun audit.
- Step 4: Commit the diff artifacts
Task 9: Phase 2 deploy
- Step 1: Final pre-deploy checks
- Step 2: Deploy via the odontox-commit-deploy skill
- Step 3: Post-deploy verification
- Open https://odontox.io/ in incognito — landing renders, no console errors
- Open https://go.odontox.io/auth/login — auth flow works
-
Log in and navigate to the dashboard — charts module loads (this exercises the new
vendor-chartschunk lazy-load) - Step 4: Capture Phase 2 measurement
docs/perf/README.md.
- Step 5: Observe 48h
Task 10: Phase 3 — Prepare for prerender (hydration safety)
Why: snapshot prerender via Playwright runs the app in a real Chromium and captures the DOM after first paint. Hydration on the real user’s browser then has to produce the same DOM. If any landing component reads time/random/agent during render, hydration will mismatch and React 19 will produce a console warning and re-render the subtree. We need to find and gate any such reads. Files:-
Audit:
src/pages/Landing.tsx,src/pages/LegalPage.tsx,src/pages/TheRecallEffectPage.tsx,src/pages/ReferEarn.tsx,src/pages/ReferPayoutForm.tsx,src/pages/LoginSuccess.tsx,src/pages/LogoutSuccess.tsx, and the components they import fromsrc/components/landing/ - Step 1: Grep for non-deterministic render reads
ui/:
-
Inside
useEffect/ event handler → safe, skip - Top-level component body → unsafe, must fix
-
Inside
useStateinitializer → unsafe, must fix (state init runs at first render) -
Inside
useMemo/useCallbackwith no deps → effectively run-once-at-render — unsafe - Step 2: For each unsafe hit, gate it
useState(() => readWindow()) with useState(null) + useEffect:
useSyncExternalStore:
- Step 3: Commit any gating changes
docs/perf/README.md: “Hydration audit clean — no gating required.”
Task 11: Phase 3 — Emit prerender-ready signal
Why: Playwright needs to know when the page is “done” enough to snapshot.networkidle is unreliable (Sentry/PostHog keep tiny pings open). A custom signal is deterministic.
Files:
-
Modify:
ui/src/main.tsx - Step 1: Add the signal
ui/src/main.tsx, at the very end of the file (after the createRoot(...).render(...) call), append:
if (!didRedirect) { ... createRoot ... } block, after the .render(...) call. If the user is being redirected, we don’t want to signal “ready” — the redirect should resolve first.
- Step 2: Build and verify in browser
http://localhost:4173/ in a browser. Open DevTools console and type:
true
- Step 3: Commit
Task 12: Phase 3 — Prerender route list
Files:-
Create:
ui/scripts/prerender-routes.json - Step 1: Create the route list
ui/scripts/prerender-routes.json:
/,/the-recall-effect,/refer,/refer-payout-form— pure marketing, biggest first-paint win/legal— public, terms/privacy/book all redirect into it/login-success,/logout-success— auth transition pages with motion, prerender stabilizes them- NOT
/privacy,/terms,/book— these are<Navigate>redirects; prerendering them captures a flash of redirect screen - NOT
/auth/*— auth subdomain, has dynamic state - NOT
/dashboard/*,/share/*,/appointment/*,/invitation/*,/onboard/*,/upgrade,/account-status,/session-timeout,/files/tiff-viewer— all stateful
stripScripts: at hydration time these third-party scripts will be loaded by the hydrated React tree. We also don’t want them to fire during the prerender snapshot (double-execution = double telemetry). The prerender script removes any <script src="...domain..."> matching these substrings from the snapshotted HTML.
- Step 2: Commit
Task 13: Phase 3 — Prerender script
Files:-
Create:
ui/scripts/prerender.mjs - Step 1: Write the script
ui/scripts/prerender.mjs:
-
Uses
vite previewto serve the freshly builtdist/. This means prerender runs against the actual production-mode build, not dev. -
Waits for the
__PRERENDER_READY__signal from Task 11. If the signal never fires (page errored), the route fails and the script exits non-zero — that fails the build, which is what we want. - Strips third-party telemetry scripts so they don’t double-fire.
-
Adds an
x-odx-prerenderedmeta tag to the HTML — useful for debugging “is this page coming from a snapshot or live SPA?” - Per-route failures don’t abort the loop — we collect failures and exit non-zero at the end so the build fails after we see all errors.
- Step 2: Add npm script
ui/package.json scripts, replace the build line and add a new prerender line:
vite build → postbuild.js (existing data-cfasync injection on index.html) → prerender.mjs (snapshots routes).
⚠️ Important: postbuild.js currently only operates on dist/index.html. The prerender step re-writes dist/index.html afterward. We need postbuild.js to also apply to the new prerendered files. See Task 14.
- Step 3: Commit
Task 14: Phase 3 — Update postbuild to handle all prerendered HTML
Why: the existingpostbuild.js adds data-cfasync="false" only to dist/index.html. We now have dist/legal/index.html, dist/refer/index.html, etc. — they all need the same treatment.
The prerender script wrote the HTML after postbuild ran for the originals — but the original index.html is now overwritten by the snapshot, so its data-cfasync attributes are gone. So postbuild needs to run after prerender, on all HTML files.
Files:
-
Modify:
ui/postbuild.js -
Modify:
ui/package.json(build script order) - Step 1: Generalize postbuild.js
ui/postbuild.js contents:
- Step 2: Reorder the build script
ui/package.json:
vite build→ writesdist/index.html+ all hashed assetsscripts/prerender.mjs→ writesdist/<route>/index.htmlfor each public route (overwritesdist/index.htmlwith the snapshot version)node postbuild.js→ appliesdata-cfasyncto every.htmlunderdist/
- Step 3: Full build and verify
dist/the-recall-effect/index.html in a browser locally (or cat | head -100). Confirm:
-
The
<head>has all the meta tags fromindex.html -
The
<body>has actual rendered React content (headings, paragraphs visible in source) -
No
<script src="https://www.googletagmanager.com/...">remains (stripped) - Step 4: Commit
Task 15: Phase 3 — Verify SPA fallback still works for unprerendered routes
Why: Cloudflare Pages will servedist/legal/index.html for /legal, but for any route NOT in the prerender list (e.g., /dashboard, /auth/login, /share/abc123), it must still fall through to dist/index.html so React Router handles the route. The existing _redirects already has /* /index.html 200. We need to confirm prerendered routes intercept that.
Files:
-
Modify (if needed):
ui/public/_redirects - Step 1: Inspect existing _redirects
/* /index.html 200 line at the end. Cloudflare Pages matches files first, then redirects — so /legal/ is served as dist/legal/index.html automatically, no extra rule needed. Same for /legal/index.html and /legal (Pages normalizes trailing slash).
- Step 2: Test trailing-slash handling locally
/legal/ returns 404 or wrong content, the preview server isn’t doing the same lookups as Cloudflare Pages. In that case, add to _redirects immediately before the /* fallback:
- Step 3: Commit if
_redirectschanged
_redirects didn’t change, skip this commit.
Task 16: Phase 3 — Test hydration cleanliness
Why: the whole prerender strategy collapses if hydration mismatches throw warnings. We need to look at the browser console. Files: none- Step 1: Run the preview and inspect
- Opens the route
- Records all console messages
- Reports any
warningorerrorwith “hydration” or “did not match” in it
ui/scripts/check-hydration.mjs:
ui/package.json:
- Step 2: Commit
Task 17: Phase 3 deploy
- Step 1: Final pre-deploy checks
- Step 2: Deploy via the odontox-commit-deploy skill
- Step 3: Post-deploy verification
- Incognito → https://odontox.io/ — landing renders instantly with content (no blank flash)
- Network tab — first response is HTML with body content, not a blank shell
-
View source —
<h1>and headline content visible directly in the HTML - Step 4: Capture Phase 3 measurement
docs/perf/README.md. FCP and LCP should drop substantially (the entire React hydration round-trip is gone from the critical path).
- Step 5: Observe 48h
- Any new “Hydration failed” / “Text content does not match” / “Did not match” warnings → revert
- Any spike in client-side errors → revert
- Manually re-check public routes once a day in incognito to confirm visual parity
Task 18: Phase 4 — HSTS preload finalize
Why: after a week of clean traffic atmax-age=604800, we promote to max-age=63072000; includeSubDomains; preload and submit to https://hstspreload.org. This is the final ~146ms saving — browsers on the preload list never even try HTTP for odontox.io.
Prerequisite: at least 7 days have passed since Task 5’s deploy AND no production HTTP-only subdomain has been discovered.
Files:
-
Modify:
ui/public/_headers - Step 1: Update the HSTS line
ui/public/_headers, in the /* block, change the HSTS line:
- Step 2: Deploy via odontox-commit-deploy
- Step 3: Confirm header is live
- Step 4: Submit to hstspreload.org
odontox.io, and submits. Acceptance takes anywhere from a few days to several weeks. Stop and hand off to user at this point with a one-line message:
“Phase 4 deployed. Please submit odontox.io to https://hstspreload.org/ to finalize HSTS preload. The header is already live; this is just the registry submission.”
Task 19: Final measurement + writeup
Files:-
Modify:
docs/perf/README.md - Step 1: Capture final numbers
- Step 2: Update the README with the trend
docs/perf/README.md a summary block:
- Step 3: Commit
Rollback notes
Each phase is independently revertible:- Phase 1: revert the commit that added HSTS / preload tags. Cache-Control already prevents the immutable assets from being stuck.
- Phase 2: revert the
manualChunkschange invite.config.ts. Existing chunk hashes will differ → the existing chunk-recovery script inpublic/chunk-recovery.jshandles the stale-hash 404 case for in-flight users. - Phase 3: revert the
buildscript change to removescripts/prerender.mjsfrom the chain. The prerendered HTML files won’t be regenerated next build; Cloudflare Pages will fall back to servingdist/index.htmlfor all routes (the existing SPA behavior). React Router takes over. - Phase 4: this one is genuinely hard to revert (browser-cached HSTS preload). Don’t do Phase 4 until Phase 1–3 are stable.
odontox-commit-deploy skill.
Out of scope (do not implement in this plan)
- Splitting the public site into a separate Astro/Vite app
- Service worker / offline support
- HTTP/3 tuning beyond what Cloudflare already does
- Image CDN switch
- Tailwind v4 config tuning
- Replacing framer-motion (would be a separate large refactor)

