Plan: SSG / prerender the public homepage (CSR → instant first paint)
Status: Stages 2, 1, 3 + CloudFront routing + Stage 5 guardrails/deploy-wiring IMPLEMENTED & locally verified 2026-06-02 (branch `perf/public-homepage-followup`, not committed). NEXT: commit → deploy
Status: Stages 2, 1, 3 + CloudFront routing + Stage 5 guardrails/deploy-wiring IMPLEMENTED & locally verified 2026-06-02 (branch perf/public-homepage-followup, not committed). NEXT: commit → deploy → measure on beta (§7) → decide Stage 4 (archive).
Author: Amelia (dev agent) — drafted 2026-06-02, revised 2026-06-02 after a code audit + tooling spike.
Prereq: measure real Lighthouse/PSI on beta.batbern.ch before the Stage-3 homepage prerender (see §7) so scope is sized from data, not guesses.
0. Decisions resolved (2026-06-02)
These were the plan’s original open questions; all now decided (with the user) and binding.
- Tooling → build-time Playwright crawl. NOT
vite-react-ssg, NOT React Router 7’s first-party prerender. Rationale in §3 (verified against live npm/GitHub, not guessed). - Render strategy → paint-and-replace, NOT hydration. Keep
createRoot; the prerendered HTML is a fast-painting shell that React replaces on mount. This sidesteps the #1 risk (hydration mismatch) — see §2. - i18n → prerender the German default locale only. Other 9 locales stay client-rendered (they paint the German shell first, then React replaces with the visitor’s locale on mount — acceptable under paint-and-replace, zero hydration warnings).
- Bootstrap spinner → removed entirely. The bespoke red progress-bar
LoadingScreeninmain.tsxis deleted. The page shell renders immediately; the BATbern logo spinner (BATbernLoader) is used only for known-slow data regions (e.g. the homepage event block, which already does this). No full-screen bootstrap spinner. (Product call by the user: “showing the page immediately and our own logo spinner only where it’s slow is better; needing no spinner at all is best.”) - Ordering → decouple FIRST. Stage 2 (config-gate decouple) lands before any prerender because it’s an independent CSR win on every route and is the structural prerequisite for a good homepage shell. Then Stage 1 (static routes), then Stage 3 (homepage).
- Scope this pass → through Stage 3, then pause for beta Lighthouse before deciding on archive (Stage 4).
Key wiring findings from the audit (de-risk the decouple)
- The public shell consumes no config.
useConfigconsumers are:useTurnstile(newsletter widget — the only one on the public path),usePresentationData,useLiveSessionControl,ReleaseNotesInfoBox(all auth/organizer/presentation, rendered only after config loads). - The API base URL does not need the config fetch.
getApiUrl()(runtime-config.ts) derives it from hostname;ConfigController.getApiBaseUrl()returns exactly<host>/api/v1in every env (http://localhost:{port}/api/v1dev,https://api.batbern.ch/api/v1staging+prod). SoapiClientcan self-initialisebaseURL = getApiUrl() + '/api/v1'at module load (byte-identical to config in prod) → homepage event data loads in parallel with the config fetch, not after it.updateApiClientConfig()still runs on config-resolve as the authoritative override (no-op in prod). - Only
useTurnstileneeds softening for the null-config window → adduseOptionalConfig()(returnsAppConfig | null, no throw); treat null as “turnstile not ready/disabled”.useConfig()keeps its throw-if-null contract for the non-public consumers.
1. Why this is the highest-impact remaining lever
The public site is a client-side-rendered (CSR) SPA. Today’s first-paint path (web-frontend/src/main.tsx):
- Browser receives a near-empty
index.html(<div id="root">). - Download + parse + execute the eager JS (~549 KB gz after the carve-outs).
main.tsxrenders a spinner (the red bar), thenawait loadRuntimeConfig()→ aGET /api/v1/confignetwork round-trip — this blocks<App>from mounting at all.<App>mounts →HomePagefetches event data (another round-trip).- Only now does real content paint.
So FCP/LCP are structurally gated on “all JS executed + config fetched + data fetched, then paint.” The bundle carve-outs shrink step 2 — real, but the page stays blank until JS runs. Byte-shaving plateaus here.
Two changes attack this:
- The decouple (Stage 2) removes step 3’s blocking config round-trip from the render path and kills the red-bar spinner — the shell paints as soon as the JS runs, data loads in parallel.
- Prerendering (Stages 1+3) puts real HTML — hero, headings, nav, layout — in the initial response, painted before any JS executes. This removes the blank period and is the change most likely to actually reach the original PageSpeed 80+ target (prod was 25 → 59 after round 1).
2. The design crux
The prerender environment has no backend. At build time there is no /api/v1/config and no event API. Two consequences:
main.tsxblocks the whole app onloadRuntimeConfig(). → Stage 2 decouples this: render<App>immediately, load config in a provider effect, self-init the apiClient base URL from hostname so data still loads. The shell renders with no config; only Cognito/feature-flag-dependent UI waits.- “Build once, deploy everywhere” must survive: never bake environment-specific config or live event data into the static HTML. The prerendered output is the static shell; live data fills in client-side after the normal runtime fetch.
Render strategy — paint-and-replace (decisive simplification):
Rather than hydrateRoot (which demands the prerendered markup match React’s first client render exactly — fragile across our 10-locale i18n and lazy-route Suspense boundaries), we keep createRoot. The crawl writes the rendered DOM into the route’s index.html; at runtime the browser paints that real HTML at first paint, then the JS boots and React createRoot().render() mounts fresh and replaces it. Result:
- Full FCP/LCP win (real content paints before JS executes), AND
- Zero hydration-mismatch risk (React doesn’t hydrate, it re-renders) — the plan’s biggest risk is designed out.
- Cost: a re-render on mount (the prerendered shell stays visible the whole time, so no blank flash; for non-German visitors the shell content swaps to their locale when React mounts). Acceptable for a landing page.
- The one thing we MUST get right is that the prerendered shell looks like the initial client render (reserve hero/LCP image dimensions → no CLS), so the replace is visually seamless.
In our favour: public routes are already MUI-free (Tailwind) — no runtime CSS-in-JS to deal with. Public routes in scope: /, /about, /archive (+/archive/:code), /privacy, /support. Admin/auth/portal routes stay CSR (untouched).
3. Tooling decision — build-time Playwright crawl (RESOLVED)
Verified against live npm + GitHub on 2026-06-02 (not the stale metadata):
| Option | Verdict | Evidence |
|---|---|---|
| Playwright build-time crawl ✅ CHOSEN | Version-immune, reuses an installed dep, no production-bootstrap rewrite to ship static routes | Operates on the built dist (serve via vite preview, drive headless chromium, stub /api/**, snapshot DOM → route/index.html). Indifferent to Vite/RR/helmet versions. |
vite-react-ssg ❌ |
Beta, single-maintainer, officially RR6 | npm latest = 0.9.1-beta.1; published peers cap at vite ^7 / react-router-dom ^6.14.1; Vite 8 support exists only in a 2026-05-07 source commit, unpublished. We’re on Vite 8.0.10 / RR 7.14.2 / helmet-async 2 — install needs --force and is unsupported. |
| RR7 first-party prerender ❌ | Most official, but disproportionate | RR7 ships prerender in react-router.config.ts, but it requires Framework Mode + @react-router/dev — a full migration off our <BrowserRouter> SPA, replacing parts of the tuned vite config (PWA, compression, manualChunks carve-outs, sitemap, static-copy). Treat as its own project if ever wanted. |
Crawl mechanics: tsc && vite build → node scripts/prerender.mjs which (a) serves dist on a local port, (b) launches chromium, © for each route in the prerender list: intercepts **/api/** (stub /api/v1/config; empty/404 the rest) so the app boots backend-free, navigates, waits for content, snapshots page.content(), writes dist/<route>/index.html. Build-once preserved: the snapshot is the locale-de shell with no env config / live data baked in.
4. Phased delivery (each phase prod-safe; staging IS prod; verify on beta before Stage 3)
Stage 2 — Decouple the config gate + remove the bootstrap spinner ⚠️ foundation, ships first
main.tsx: deleteLoadingScreen(red bar) and the blockingawait loadRuntimeConfig(); render<App>immediately under a self-loading<ConfigProvider>. KeepErrorBoundary; do NOT blanket-blank on config failure (public pages work without config now).ConfigProvider: load config in an effect; on resolve callupdateApiClientConfig+setAmplifyRuntimeConfig+ set context. Accept an optionalconfigprop for tests.apiClient: self-initbaseURL = getApiUrl() + '/api/v1'at module load (export agetDefaultApiBaseUrl()fromruntime-config.ts).useOptionalConfig()foruseTurnstile(the sole public-path consumer); null ⇒ turnstile not ready/disabled.- Independently shippable CSR win: every route paints its shell instead of the red bar; the homepage shows the logo spinner only for the event block. Verify on beta → prod.
Stage 1 — Prerender the pure-static public routes (/privacy, /support, /about) 🟢 low risk
- No live-data dependency → lowest risk. German default locale only.
- Add
scripts/prerender.mjs+ wire the build. Makemain.tsxprerender-aware (paint-and-replace; never overwrite a populated#rootwith a spinner — already true after Stage 2). - Add the CloudFront-function change so
/aboutserves/about/index.html(infra; prod-safe-incremental). - Verify on beta, then prod. Acceptance: view-source shows real content pre-JS; Lighthouse FCP improves; no console errors.
Stage 3 — Homepage shell refactor + prerender / 🟡 the high-value one
- Refactor
HomePageso the hero/nav/footer scaffold renders immediately (withBATbernLoader/skeleton for the event-data region) instead of the current full-page spinner (HomePage.tsx:111). Reserve the hero LCP image space (no CLS). - Add
/to the prerender list → the shell paints pre-JS; live event data fills in client-side. - Verify on beta (real CloudFront + resize Lambda) with Lighthouse/PSI vs the pre-prerender beta baseline. Then prod. Then pause per §7.
Stage 4 (deferred to next pass) — Prerender /archive (+ decide /archive/:code)
- Archive has dynamic content too → same shell pattern.
/archive/:codelikely stays CSR or prerenders a shell. Decide after beta numbers from Stage 3.
Stage 5 — Cleanup / guardrails
- Wire prerender into the deploy build + the beta publish script. Add a guardrail test asserting prerendered routes contain expected content (catch a silently-broken prerender). Document the model.
5. Risks
- Locale swap flash (German shell → visitor locale on mount): inherent to paint-and-replace + default-locale prerender. Mitigate by keeping the shell layout locale-invariant (structure/spacing identical; only text swaps).
- CLS if prerendered shell layout ≠ mounted layout (esp. the hero image) → reserve dimensions explicitly.
- “Build once”: the crawl must stub the backend and never bake config/data into HTML.
- CloudFront routing for prerendered routes vs the SPA fallback — serve route HTML for prerendered paths, generic
index.htmlotherwise. - Maintenance: a new public route must be added to the prerender list or it silently falls back to CSR (Stage 5 guardrail).
new Date().getFullYear()inPrivacyPage: prerendered year is fixed at build; under paint-and-replace this self-corrects on mount (no hydration warning). Benign.
6. Cost
No new infra (existing S3+CloudFront). Adds build time (the crawl) + one CloudFront-function tweak. No new runtime dependency (Playwright already present).
7. Sequencing / prerequisite
Measure before Stage 3. After Stage 2 + Stage 1 land, push perf/public-homepage-followup to beta (scripts/deploy/publish-beta-frontend.sh) and run Lighthouse/PSI on beta.batbern.ch (real CloudFront + resize Lambda) to establish the true post-decouple FCP/LCP, then size the Stage-3 homepage prerender from those numbers.
8. Open questions — all resolved (see §0)
i18n→ German default only.Tooling→ Playwright crawl.Dynamic data on→ skeleton-then-client-load (paint-and-replace shell)./Architect involvement→ paint-and-replace + crawl avoid the framework-mode/build-model upheaval that would have warranted it; revisit only if Stage 4 grows.