apps/site: Vite SSR + SSG migration¶
Status: in-progress LNO: L — first-impression UX of the marketing site directly affects signup-funnel conversion; the current "always loading" flash on landing pages costs visitors who bounce before content paints. Owners: @bhanu49, @kantharajucn Last updated: 2026-04-26 Roles affected: every public visitor of
kwilo.ai. No authenticated users (apps/web SPA stays as-is). Primary routes:/,/individuals,/pricing,/privacy-policy(the four prerendered routes onapps/site) Source of the ask: @bhanu49 manual product test on 2026-04-26 — observed "always loading" on landing pages on the live site.
TL;DR¶
apps/site looks SEO-good but UX-bad: the build-time prerender script writes hand-crafted HTML for crawlers, but createRoot(...).render() wipes it on hydration so real users see ~50ms of content → blank → SPA. We migrate to the canonical Vite SSR pattern (entry-client + entry-server + shared main, per vite.dev/guide/ssr) and add a build-time prerender step on top — so the prerendered HTML is what React itself produced, hydrateRoot() reuses it byte-for-byte, and there's no flash. apps/web (the product app) is unchanged. Tracking issue: #653.
Why this exists¶
This is the most important section in the doc. Do not skip it.
Who asked for it¶
- Primary source: @bhanu49 manual test on 2026-04-26 — observed loading flash on
kwilo.aiand asked "aren't the landing pages SSR? I see it's always loading." Backend log + production HTML inspection confirmed the prerender works for crawlers (2530 chars of real HTML inside#rootonhttps://kwilo.ai/) butcreateRoot(...).render()inapps/site/src/main.tsxwipes that on hydration. - Corroborating signals: design review of
apps/site/scripts/prerender.mjs(hand-crafted SEO HTML; doesn't match the React component tree, sohydrateRoot()would log mismatch warnings and fall back to client render anyway). - Self-initiated? The fix decision is engineering-led, but the user pain (always loading) was discovered through real product testing, not a hypothetical concern.
What user pain does it solve¶
A first-time visitor to kwilo.ai on a Fast 3G connection or on a low-spec mobile device sees:
- Network: HTML is served (with prerendered content already inside
#root) — paint #1 is fast and complete. - JS bundle finishes downloading.
- React boots, calls
createRoot(), wipes the#rootcontent, and renders the SPA from scratch — paint #2 is "blank" while React mounts. - SPA renders — paint #3 has the actual content.
Between paint #1 and paint #3, the visitor sees the page content briefly, then a blank/loading state, then real content. On slow connections this looks like the site is "always loading" — and the visitor's first impression is "this site is broken/slow." For a marketing page whose only job is to convert visitors to signups, that flash is a direct funnel cost.
Cost of not doing it¶
- Near-term: every visitor to
kwilo.aion a slow connection sees the flash. Worse perceived performance than competitors → bounces before reading the value prop. We have no telemetry on the marketing site (deliberate, no analytics tracking on landing for privacy) so we don't have a hard number, but the flash is reproducible and observable on Fast 3G. - Within a quarter: as we add more landing routes (per-university SEO pages, blog), every new route inherits the same problem. Each new prerendered route compounds the flash.
- Long-term: the hand-crafted prerender script is a maintenance liability — every time we add a route we have to write SEO HTML by hand AND React components AND hope they roughly match. The SSG approach (React renders the canonical HTML at build time) eliminates this drift class.
If we don't ship: the flash stays, the prerender script keeps drifting from React reality, and we never get the React-truth-on-first-paint property that hydration needs.
Validation¶
- Direct observation: @bhanu49 reproduced the flash in dev tools (Fast 3G throttle) on 2026-04-26.
- Code evidence:
apps/site/src/main.tsxusescreateRoot(...).render()(verified). Theprerender.mjscontent is hand-crafted SEO HTML that doesn't match the React component tree. - Documentation evidence: Vite SSR guide explicitly endorses pre-rendering routes into static HTML using the same SSR logic — this is the documented Vite-native approach. We're not inventing a pattern.
- Prior art: Astro, Next.js SSG, vite-react-ssg, Vike — every modern SSG framework solves this problem the same way: render the React tree at build time, hydrate over it at runtime. We're adopting the underlying primitives directly instead of pulling in a meta-framework.
- What we have NOT validated: we haven't measured before/after Lighthouse or Core Web Vitals deltas. Will measure during Phase 3 deploy verification.
Personas¶
- Priya, 3rd-year ECE learner at JNTU. Lands on
kwilo.ai/individualsfrom a Google search ("Newton's laws presentation") on her phone over 4G. Reads the headline, scrolls, decides whether to sign up. If the page flashes "loading" before content settles, she bounces. - Prof. Suresh, freelance physics tutor. Hits
kwilo.ai/pricingfrom a Twitter link. Wants to compare Free vs Learner vs Pro tiers in 30 seconds. Same flash, same bounce risk. - Googlebot. Crawls
kwilo.ai/and follows internal links. Doesn't care about the flash (no JS execution); does care about meta tags, JSON-LD, semantic HTML structure. Must keep getting the same SEO output.
ICP exclusions¶
- Authenticated users on
apps/web(app.kwilo.ai). Auth-required SPA, not crawlable. The "always loading" UX problem applies but the SSG approach doesn't fit (per-user data on every page). Different track. - Mobile app users (Expo). Native, no HTML rendering. Unrelated.
- Any landing page that has per-request data (e.g. a hypothetical
/pricing?currency=INRthat needs server-side currency lookup). We don't have any today; if/when we do, that's runtime SSR, not SSG, and a separate decision.
Outcomes for the user¶
- Priya hits
kwilo.ai/individualson Fast 3G — sees full content immediately on first paint, no blank/loading flash, no flicker. The page is interactive after JS hydrates (typically <1s) but content is readable from the start. - Prof. Suresh hits
kwilo.ai/pricing— same outcome. Content lands in one paint. - Googlebot continues to see byte-equivalent SEO HTML per route: titles, descriptions, canonical URLs, BreadcrumbList JSON-LD, FAQPage JSON-LD, Organization JSON-LD, OG/Twitter tags. No SEO regression.
- Future contributors adding a new route only have to add the route component + a meta entry — they don't have to write hand-crafted SEO HTML in
prerender.mjs. Less friction, no drift class.
Non-goals¶
- Not migrating apps/web. The product app stays a SPA — auth-required, not crawlable, dynamic per-user data. SSG would be wrong for it.
- Not introducing a meta-framework. No Astro, no Vike, no Next.js. We stay in pure Vite, per vite.dev/guide/ssr.
- Not changing the routing library. React Router 7 stays. We adopt its SSR primitives (
StaticRouterorcreateStaticHandler/StaticRouterProviderper the version) where needed. - Not running a Node server in production. Cloudflare Pages keeps serving static files. The SSR pattern is build-time only.
- Not changing the dependency policy of apps/site. apps/site stays dep-lean per
apps/site/CLAUDE.md. The migration adds onlycompression,express,sirv,cross-envas devDependencies (used by the dev/preview server, never bundled into prod). - Not adding analytics or telemetry. Out of scope.
Metrics¶
- North star: zero flash of empty/loading state on first paint for
/,/individuals,/pricing,/privacy-policyon a Fast 3G throttled DevTools session. Verified manually before merge. - Guardrail #1: SEO output unchanged.
curl -A "Googlebot" https://staging.kwilo.ai/<route>produces a body with the same titles, descriptions, canonical URLs, and JSON-LD blocks the currentprerender.mjsproduces. Diff against the pre-migration output. - Guardrail #2: zero React hydration mismatch warnings in production console.
- Guardrail #3: production deployment remains static-file-only on Cloudflare Pages — no Node server runs in prod.
Mental model¶
Three layers, each with one job:
Build time (CI):
┌──────────────────────────────────────────────────────────┐
│ vite build --outDir dist/client (the SPA bundle) │
│ vite build --ssr ... --outDir dist/server │
│ (the SSR bundle: exports render(url) → renderToString) │
│ scripts/prerender.mjs: │
│ for route in [/, /individuals, /pricing, /privacy-]: │
│ html, head = await render(route) │
│ write dist/client/<route>/index.html │
└──────────────────────────────────────────────────────────┘
│
▼
Runtime (Cloudflare Pages):
┌──────────────────────────────────────────────────────────┐
│ User requests /individuals │
│ CF Pages serves dist/client/individuals/index.html │
│ (already contains real React HTML — paint #1) │
│ Browser parses, downloads JS, runs entry-client.tsx │
│ hydrateRoot(getElementById('root'), <App />) │
│ (reuses existing DOM, attaches handlers — no flash) │
└──────────────────────────────────────────────────────────┘
│
▼
Dev (`pnpm dev`):
┌──────────────────────────────────────────────────────────┐
│ Express + Vite SSR middleware │
│ Each request: vite.ssrLoadModule('/src/entry-server.tsx')│
│ render(url) → string + Vite HMR client │
│ (Real React rendering on every dev request) │
└──────────────────────────────────────────────────────────┘
Three source files split by responsibility:
src/main.tsx— environment-agnostic. App + Router (Static or Browser) + providers. Exports the App tree.src/entry-client.tsx—hydrateRoot(...). Imports from main. Browser-only.src/entry-server.tsx—renderToString(...). Imports from main. Server-only. Tree-shaken from client bundle viaimport.meta.env.SSR.
User flows¶
Happy path — Priya lands on /individuals¶
- Priya clicks a Google result for
kwilo.ai/individuals. - CF edge serves
dist/client/individuals/index.html(cached at edge per Phase 3.5Cache-Controlheaders — fast TTFB). - Browser paints the prerendered HTML — full content visible (paint #1, ~200ms on Fast 3G).
- JS bundle downloads in background.
entry-client.tsxrunshydrateRoot(). React reuses the existing DOM, attaches event handlers, doesn't re-render. No flash.- Priya is reading the value prop while the page becomes interactive.
Edge cases¶
- JS disabled: Priya sees the prerendered HTML. All links still work (anchor tags). Forms (signup CTA) require JS — graceful degradation: the CTA link routes to
app.kwilo.aieven without JS. - Slow JS download (3G fail-mid-load): Paint #1 is complete. Page is non-interactive but readable. Same UX as static HTML — no worse than the current state.
- Crawler (Googlebot) hits
/pricing: getsdist/client/pricing/index.html. Real content. Real meta tags. Real JSON-LD. Same SEO as today (verified by diff during Phase 2). - First deploy after migration: CF edge cache invalidates per s-maxage; subsequent visitors at each edge get the fresh HTML. Browsers revalidate (max-age=0) and get a 304 if their cached copy matches → cheap.
- Hydration mismatch: should not happen if server and client render the same component tree from
main.tsx. If it does, React 18 logs a warning and re-renders client-side. Caught during Phase 3 manual testing.
Design decisions¶
Decision 1: Pure Vite SSR pattern, not a meta-framework¶
Chose: Adopt the canonical pattern from vite.dev/guide/ssr and the bluwy/create-vite-extra/template-ssr-react template. Add a build-time prerender script on top to get SSG without a production Node server.
Why: - We already use Vite. Adopting a meta-framework (Astro, Next.js, Vike) means a new mental model, new file conventions, and a migration cost greater than the problem. - The Vite docs explicitly endorse pre-rendering as "the same production SSR logic" applied at build time. - Pure Vite stays close to the platform — no third-party abstraction layer to debug. - apps/site is small (4 routes); meta-framework features like file-based routing, layouts, data fetching are all overhead we don't need.
Alternatives rejected:
- vite-react-ssg — adds a wrapper library; same outcome with less abstraction
- vite-prerender-plugin (headless browser) — slower builds, harder to debug
- Vike (vite-plugin-ssr) — full meta-framework, overkill for 4 routes
- Astro — new file format + new mental model + means leaving plain Vite
- Match React tree to current prerender HTML + hydrateRoot() — tightly couples a hand-written script to the component tree; brittle as routes grow
Decision 2: SSG (build-time), not runtime SSR¶
Chose: Generate static HTML at build time via scripts/prerender.mjs calling render(url) from the SSR bundle. Cloudflare Pages serves the static files. No Node server in production.
Why: - The 4 routes have no per-request data. Build-time rendering is sufficient. - Keeping the deployment static-file-only preserves Cloudflare Pages' edge-cache simplicity and zero infra surface in production. - Runtime SSR (the bluwy template's default) would require deploying a Node server (Cloud Run? Worker?) — operational cost and latency floor we don't need. - If/when we add a route that genuinely needs per-request data, we can add a Worker for that route specifically, leaving the static landing pages alone.
Alternatives rejected: - Runtime SSR (template as-is) — forces a Node server in production - Hybrid (some routes SSG, some SSR) — deferred until we have a route that actually needs SSR
Decision 3: Three-file entry split per Vite docs¶
Chose: Split apps/site/src/main.tsx into:
- main.tsx — shared App tree, environment-agnostic. Both entries import from here.
- entry-client.tsx — hydrateRoot() only. Imports from main.tsx.
- entry-server.tsx — renderToString() only. Imports from main.tsx. Tree-shaken from the client bundle.
Why: - Vite docs explicitly recommend the three-file split. - Cleaner separation than the bluwy template's two-file (which inlines App into the client entry). - Single source of truth for the component tree — server and client must render the same App, which is enforced by both importing from the same module.
Decision 4: Helmet for per-route head tags¶
Chose: Use react-helmet-async (already a dep — see apps/site/CLAUDE.md) for per-route <title>, <meta>, canonical URL, and JSON-LD blocks. The SSR entry collects helmet output and exposes it as head; the prerender script writes that into the <!--app-head--> placeholder.
Why:
- react-helmet-async is the helmet variant designed for SSR. Already in our dep tree.
- Single source of truth: route components declare their own meta. The prerender script doesn't duplicate the meta; it just collects what helmet writes.
- Works for both server (build-time) and client (runtime tab title updates).
- Eliminates the drift class in the current prerender.mjs where SEO meta is hand-crafted in JS while React might want different titles.
Alternatives rejected:
- routeMeta.ts config consumed by both prerender script and App — would work, but introduces a second source. Helmet is the cleaner answer.
Decision 5: Edge cache headers shipped in same PR (Phase 3.5)¶
Chose: Add Cache-Control: public, max-age=0, s-maxage=3600, stale-while-revalidate=86400 to the four landing routes via apps/site/public/_headers, in the same PR as the SSG migration.
Why:
- Cache headers complement SSG: prerendered HTML doesn't change between deploys, so edge caching is a free win.
- max-age=0 makes deploys reflect on return visitors within seconds (browser revalidates → 304 if matches).
- s-maxage=3600 caches at CF edge for 1h — subsequent visitors at the same edge get cache hits.
- stale-while-revalidate=86400 protects against origin slowness.
- Independent of SSG mechanically (could ship now), but bundling avoids a separate review cycle for a 7-line change.
Risks and mitigations¶
-
Risk: SSR-incompatible code in apps/site (e.g. components that call
window.matchMediaat module top-level, framer-motion configs that read DOM measurements synchronously). Mitigation: Phase 1.6 spike — grep forwindow.,document.,localStorage,IntersectionObserverpatterns. Wrap browser-only paths inif (typeof window !== 'undefined')orif (!import.meta.env.SSR). If a component genuinely can't render on the server, defer it withuseEffectrather than gating with the SSR flag. -
Risk: SEO regression — the new prerender produces different HTML than the current hand-crafted script. Mitigation: Phase 2 explicit diff step. Build with the new code, save outputs. Build with the old code (via git stash), save outputs. Compare titles, descriptions, canonical URLs, OG tags, JSON-LD blocks per route. Should be byte-equivalent or strictly improved. No merge until diff is clean.
-
Risk: Hydration mismatch warnings in production console. Mitigation: Phase 3 manual deploy verification on
staging.kwilo.ai. Open DevTools console on each of the 4 routes; zero hydration warnings is the gate. If any appear, fix before merging to main. -
Risk: framer-motion or other animation libraries break SSR. Mitigation: framer-motion is generally SSR-safe in v11+ (which we're on). Animations that depend on DOM measurements (
useScroll,useViewportScroll) needuseEffectfor measurement; usually their default SSR behavior is to render at the initial state and animate on mount. If we hit a specific component, defer its render viauseEffect-mounted state. -
Risk: Cloudflare Pages output dir change (
dist→dist/client) breaks the deploy. Mitigation: Phase 3 first ships tostaging.kwilo.ai(the staging branch alias). Verify the deploy works there before merging to main. Workflow change is in.github/workflows/site-cf-pages.yml— small, reviewable.
Rollout plan¶
Phase 1 — Scaffold the SSR pattern (~1 day, no behavior change)¶
- Three-file entry split (
main.tsx,entry-client.tsx,entry-server.tsx) - Static routing for SSR (StaticRouter / createStaticRouter per react-router 7+)
index.htmlplaceholder pattern (<!--app-head-->,<!--app-html-->)- Express dev server (
apps/site/server.js) package.jsonscripts (dev,build:client,build:server,preview)- SSR-compatibility spike — grep + fix browser-only code
- Verify dev mode HMR + Tailwind hot reload + theme toggle behave identically
Deferred to Phase 2: the prerender script. Phase 1 ships without changing the production build output.
Phase 2 — Build-time prerender for SSG (~½ day)¶
- Rewrite
scripts/prerender.mjsto importrenderfromdist/server/entry-server.js - Per-route loop: render → replace
<!--app-html-->and<!--app-head-->→ writedist/client/<route>/index.html - Migrate per-route meta to
react-helmet-asyncin route components (Decision 4) - Generate
robots.txtandsitemap.xmltodist/client/ - Diff against current SEO output — must be byte-equivalent or improved
Phase 3 — Deploy verification (~½ day)¶
- Update
.github/workflows/site-cf-pages.yml: output dirdist→dist/client - Push to feature branch; CI deploys to a CF Pages preview
- Verify production deploy on
staging.kwilo.ai(staging branch alias) - Manual test: Fast 3G throttle in DevTools, load each of the 4 routes — confirm no flash
- Crawler test:
curl -A "Googlebot" https://staging.kwilo.ai/<route>per route - DevTools console: zero hydration warnings
Phase 3.5 — Edge cache headers (already done in this branch)¶
Added Cache-Control directives to apps/site/public/_headers for the four landing routes plus /images/* and /logos/*. Independent of SSG mechanically; shipped in same PR.
Phase 4 — Cleanup¶
- Delete the old hand-crafted
scripts/prerender.mjs(replaced) - Update
apps/site/CLAUDE.mdto document the SSR + SSG pattern + the three-file entry split
Success signals¶
What good looks like¶
- Priya hits
kwilo.ai/individualson her phone over Fast 3G — content paints immediately, no flash, no flicker. She reads the value prop while the page hydrates in the background. Signup conversion isn't lost to perceived performance. - A new contributor adds a
/blog/<slug>route — adds the route component, adds an entry to the prerender's pages list. No hand-crafted SEO HTML. The route automatically gets prerendered and edge-cached. - Googlebot crawls every route, finds rich semantic HTML with full meta tags and JSON-LD. Search Console shows zero indexing regressions.
Observable signals¶
- Lighthouse "Largest Contentful Paint" score improves on
/individuals(current: ~2.5s on Fast 3G with flash; target: <1.5s without flash). - Cloudflare analytics shows >90% edge-cache hit rate on landing routes within 24h of Phase 3.5 going live.
- Zero hydration warnings in production console (verified manually during Phase 3).
- Build time stays under ~5s. Dev server starts under ~2s.
Sean Ellis counterfactual¶
If we reverted this change a week after shipping, would users notice and complain with specifics? Yes — at least the team would notice the flash returning on staging tests; @bhanu49 explicitly identified the problem from a manual product test, so reverting it would be visible to him. External users probably wouldn't file a ticket (most users just bounce silently on bad first impressions), but the regression would be observable in Lighthouse and edge-cache metrics.
Open questions¶
- [ ] Do we measure before/after Core Web Vitals for the 4 landing routes? Currently we have no telemetry on apps/site (privacy choice). A one-off Lighthouse run before merge + after merge is cheap; productionizing CWV monitoring is a separate question.
- [ ] When we add
/blog/*(future), do we prerender all blog posts at build time, or hybrid (SSG for known posts, runtime SSR for new ones)? Defer until we have a blog.
Changelog¶
- 2026-04-26 — Doc created. Phase 1 + 2 dispatched to frontend agent; Phase 3.5 cache headers landed manually. Tracking issue #653.
Appendix: Reference¶
Affected files¶
apps/site/src/main.tsx— restructured (shared App tree only, exports for both entries)apps/site/src/entry-client.tsx(new) — hydrateRootapps/site/src/entry-server.tsx(new) — renderToStringapps/site/server.js(new) — Express + Vite SSR dev serverapps/site/index.html— placeholder patternapps/site/scripts/prerender.mjs— rewritten to use SSR bundleapps/site/package.json— new scripts, devDepsapps/site/public/_headers— cache directives (Phase 3.5).github/workflows/site-cf-pages.yml— output dir update
Routes prerendered¶
| Route | Component | SEO meta source |
|---|---|---|
/ |
Landing |
<Helmet> in Landing.tsx |
/individuals |
Individuals |
<Helmet> in Individuals.tsx |
/pricing |
Pricing |
<Helmet> in Pricing.tsx |
/privacy-policy |
Privacy |
<Helmet> in Privacy.tsx |