Skip to content

Commit 03bcc10

Browse files
perf(dashboard): lazy-load secondary marketing pages to shrink main bundle (#27)
PR #19 already split /app/* routes out of the entry chunk, but the remaining public marketing pages were still eagerly imported in App.tsx on the theory that "a homepage visitor might click any of them." In practice, BlogPostPage / DocsPage / UseCaseDetailPage etc. are only reachable after at least one click, so paying for their bytes on cold load is wasted weight on the most-visited path. This commit converts the secondary public pages to React.lazy() and relies on Rollup's per-import code-splitting to peel them into their own chunks. The first-paint JS that a homepage visitor downloads is now meaningfully smaller; chunks for /pricing, /docs, /blog, /status, /use-cases, /for-agents are fetched on demand when the visitor clicks into those routes. Bundle sizes (vite build), main entry + each new chunk: | File | Before | After | |-------------------------------------|-----------------|-----------------| | index-*.js (main entry) | 710.87 / 194.51 | 616.57 / 168.76 | | PricingPage chunk | (in main) | 14.43 / 3.87 | | ForAgentsPage chunk | (in main) | 11.43 / 3.53 | | StatusPage chunk | (in main) | 8.67 / 2.73 | | BlogPage chunk | (in main) | 2.22 / 0.97 | | BlogPostPage chunk | (in main) | 3.06 / 1.14 | | DocsPage chunk | (in main) | 10.48 / 4.49 | | UseCasesPage chunk | (in main) | 5.07 / 1.88 | | UseCaseDetailPage chunk | (in main) | 9.63 / 2.90 | | PublicShell chunk (extracted) | (in main) | 9.02 / 2.32 | | posts content chunk | (in main) | 22.68 / 9.95 | | markdown renderer chunk | (in main) | 1.98 / 0.84 | (All sizes in kB; pair is raw / gzip. Vite's 500 kB chunk-size warning still fires on the main entry — most of the remaining bytes are react + react-dom + react-router + MarketingPage's inlined USE_CASES dataset. That's tracked separately.) Net delta on cold load: raw: 710.87 → 616.57 kB (-94.30 kB, -13.3%) gzip: 194.51 → 168.76 kB (-25.75 kB, -13.2%) SSG complication and how it's handled: scripts/prerender.mjs renders public routes through renderToString at build time so crawlers see real HTML, not an empty <div id="root">. React.lazy() during renderToString resolves to the parent Suspense fallback, NOT the page's content — so naively lazy-loading the public pages would mean every pre-rendered HTML (/pricing, /docs, /blog/*, /use-cases/*, …) ships only a <span aria-hidden> instead of the marketing copy. That defeats the entire SEO/GEO setup. Fix: src/entry-server.tsx now declares its own SSRRoutes with synchronous imports for every public page. The SSR bundle is built by Vite as a separate module graph (build({ ssr: 'src/entry-server.tsx' })) and thrown away after prerender, so the static imports there don't bloat the client output. The route table is duplicated between App.tsx (lazy) and entry-server.tsx (static) — small cost, documented inline at the top of entry-server.tsx, and Keep-In-Sync flagged for future route adds. I first tried a unified App.tsx using `import.meta.env.SSR` to switch between lazy and static imports inline. Vite emits a warning when a module is both statically and dynamically imported and refuses to split it — so that approach silently put every "lazy" page back into the main bundle. Separate SSR entry is the only pattern that actually splits. Verification: - npm run build emits 116 static HTML files + 116 .md mirrors (unchanged). - Pre-rendered HTML contains real content (no "Loading…" fallbacks): /pricing 36 kB, /docs 22 kB, /use-cases 73 kB, /blog 14 kB. - npm test → 96 passed, 3 skipped (unchanged from baseline). - tsc --noEmit clean. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent e94748f commit 03bcc10

2 files changed

Lines changed: 114 additions & 26 deletions

File tree

src/App.tsx

Lines changed: 60 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,51 @@
11
import { lazy, Suspense } from 'react'
22
import { BrowserRouter, Navigate, Route, Routes, useLocation } from 'react-router-dom'
33

4-
// Public marketing surfaces — eagerly imported. A marketing visitor might
5-
// click any of these from the homepage nav, so keeping them in the main
6-
// chunk avoids a network round-trip on first interaction. These are also
7-
// the routes that get statically pre-rendered by scripts/prerender.mjs.
4+
// Homepage — eagerly imported. It's the cold-load path and the most-visited
5+
// public surface, so it stays in the main entry chunk.
86
import { MarketingPage } from './pages/MarketingPage'
9-
import { PricingPage } from './pages/PricingPage'
10-
import { ForAgentsPage } from './pages/ForAgentsPage'
11-
import { StatusPage } from './pages/StatusPage'
12-
import { BlogPage } from './pages/BlogPage'
13-
import { BlogPostPage } from './pages/BlogPostPage'
14-
import { DocsPage } from './pages/DocsPage'
15-
import { UseCasesPage } from './pages/UseCasesPage'
16-
import { UseCaseDetailPage } from './pages/UseCaseDetailPage'
177

188
// Auth surfaces — eagerly imported. A marketing visitor can land on /login
199
// directly (deep link, "Sign in" button), and the login form is small.
2010
import { LoginPage } from './pages/LoginPage'
2111
import { LoginCallbackPage } from './pages/LoginCallbackPage'
2212
import { ClaimPage } from './pages/ClaimPage'
2313

14+
// Secondary public marketing surfaces — lazy-loaded on the client so Rollup
15+
// splits each one into its own chunk and a homepage visitor never pays for
16+
// the bytes of pages they didn't navigate to. A click on the nav fetches
17+
// the chunk on demand.
18+
//
19+
// SSR note: scripts/prerender.mjs uses src/entry-server.tsx, which defines
20+
// its OWN AppRoutes with synchronous imports — so renderToString sees real
21+
// components and the pre-rendered HTML contains every word of content.
22+
// React.lazy() during renderToString would otherwise resolve to the
23+
// Suspense fallback and ship empty HTML to crawlers, killing SEO.
24+
const PricingPage = lazy(() =>
25+
import('./pages/PricingPage').then((m) => ({ default: m.PricingPage })),
26+
)
27+
const ForAgentsPage = lazy(() =>
28+
import('./pages/ForAgentsPage').then((m) => ({ default: m.ForAgentsPage })),
29+
)
30+
const StatusPage = lazy(() =>
31+
import('./pages/StatusPage').then((m) => ({ default: m.StatusPage })),
32+
)
33+
const BlogPage = lazy(() =>
34+
import('./pages/BlogPage').then((m) => ({ default: m.BlogPage })),
35+
)
36+
const BlogPostPage = lazy(() =>
37+
import('./pages/BlogPostPage').then((m) => ({ default: m.BlogPostPage })),
38+
)
39+
const DocsPage = lazy(() =>
40+
import('./pages/DocsPage').then((m) => ({ default: m.DocsPage })),
41+
)
42+
const UseCasesPage = lazy(() =>
43+
import('./pages/UseCasesPage').then((m) => ({ default: m.UseCasesPage })),
44+
)
45+
const UseCaseDetailPage = lazy(() =>
46+
import('./pages/UseCaseDetailPage').then((m) => ({ default: m.UseCaseDetailPage })),
47+
)
48+
2449
// Authenticated dashboard surfaces — lazy-loaded. These pages only render
2550
// behind AuthGate (token must be present), so a marketing visitor never
2651
// needs the bytes. Each React.lazy() call ends up in its own chunk; Rollup
@@ -104,23 +129,35 @@ function AppLoadingFallback() {
104129
)
105130
}
106131

132+
// PublicLoadingFallback — shown while a lazy-loaded public marketing page
133+
// chunk is in flight (e.g. /pricing, /docs, /blog). We render an empty
134+
// span instead of "Loading…" text so a flash of loader copy never appears
135+
// over the layout for a sub-100ms chunk fetch on a warm cache.
136+
//
137+
// Note: this fallback only ever appears in the browser. The SSG path in
138+
// scripts/prerender.mjs uses src/entry-server.tsx, which declares its own
139+
// route tree with synchronous imports — so renderToString resolves real
140+
// content into every pre-rendered HTML file and crawlers never see this.
141+
function PublicLoadingFallback() {
142+
return <span aria-hidden="true" />
143+
}
144+
107145
// PricingPage and ForAgentsPage both wrap themselves in <PublicShell>, and
108146
// MarketingPage inlines its own nav. So routes mount the page directly —
109147
// no extra shell wrapper needed (would cause double nav rendering).
110148

111-
// AppRoutes is the route tree without the surrounding router. Exported so
112-
// the SSR entry (src/entry-server.tsx) can mount it under <StaticRouter>
113-
// for build-time pre-rendering. The browser-side wrapper below stays the
114-
// same — this is just an extraction, no route changes.
149+
// AppRoutes is the browser-side route tree. The SSR entry
150+
// (src/entry-server.tsx) declares its own equivalent tree with synchronous
151+
// imports — see the comment at the top of that file for why.
115152
//
116-
// SSR note: scripts/prerender.mjs only renders public routes (see its
117-
// PRERENDER_ROUTES list — no /app/* paths). React.lazy resolves to a
118-
// Suspense fallback during SSR for unrendered chunks, but since SSG never
119-
// visits an /app route, the lazy components are never invoked server-side
120-
// and the build still emits 115 HTML files.
153+
// The outer <Suspense> here catches the lazy public pages (Pricing, Blog,
154+
// Docs, etc.) while their chunks load. The inner <Suspense> inside the
155+
// /app route handles the lazy /app/* pages — kept separate so a click in
156+
// /app doesn't blank the marketing shell.
121157
export function AppRoutes() {
122158
return (
123-
<Routes>
159+
<Suspense fallback={<PublicLoadingFallback />}>
160+
<Routes>
124161
{/* ─── public marketing surfaces ─────────────────────────── */}
125162
<Route path="/" element={<MarketingPage />} />
126163
<Route path="/pricing" element={<PricingPage />} />
@@ -180,6 +217,7 @@ export function AppRoutes() {
180217

181218
<Route path="*" element={<Navigate to="/" replace />} />
182219
</Routes>
220+
</Suspense>
183221
)
184222
}
185223

src/entry-server.tsx

Lines changed: 54 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,19 +6,69 @@
66
* fetch one of those files and see real content instead of an empty
77
* <div id="root"></div>. That is the entire SEO/GEO fix.
88
*
9-
* Keep this surface tiny — just `render(url)`. The route tree itself lives
10-
* in App.tsx and is shared with the browser entry. */
9+
* Why this file has its own route tree instead of reusing App.tsx's:
10+
*
11+
* The browser-side App.tsx uses React.lazy() for every secondary marketing
12+
* page (PricingPage, BlogPage, DocsPage, UseCasesPage, etc.) so Rollup
13+
* splits them into separate chunks and the homepage cold-load doesn't
14+
* carry their bytes. React.lazy is async by design — during renderToString
15+
* it suspends and the parent <Suspense> renders the fallback, NOT the
16+
* page's content. That would defeat SEO entirely: every pre-rendered HTML
17+
* would contain only the loading fallback.
18+
*
19+
* To fix that without bundling all marketing pages into the client entry,
20+
* this file re-declares AppRoutes with SYNCHRONOUS imports for the lazy
21+
* pages. The SSR bundle (dist-ssr/entry-server.mjs) is built separately
22+
* by Vite via `build({ ssr: 'src/entry-server.tsx' })` — its module graph
23+
* is completely independent of the client bundle, so static imports here
24+
* don't bloat the client output.
25+
*
26+
* Keep the route table here in sync with App.tsx's AppRoutes. The /app/*
27+
* subtree is omitted because auth-gated pages are never pre-rendered. */
1128

1229
import { StrictMode } from 'react'
30+
import { Navigate, Route, Routes } from 'react-router-dom'
1331
import { renderToString } from 'react-dom/server'
1432
import { StaticRouter } from 'react-router-dom/server'
15-
import { AppRoutes } from './App'
33+
34+
// Public marketing pages — synchronous imports so renderToString resolves
35+
// every component to real HTML instead of a Suspense fallback.
36+
import { MarketingPage } from './pages/MarketingPage'
37+
import { PricingPage } from './pages/PricingPage'
38+
import { ForAgentsPage } from './pages/ForAgentsPage'
39+
import { StatusPage } from './pages/StatusPage'
40+
import { BlogPage } from './pages/BlogPage'
41+
import { BlogPostPage } from './pages/BlogPostPage'
42+
import { DocsPage } from './pages/DocsPage'
43+
import { UseCasesPage } from './pages/UseCasesPage'
44+
import { UseCaseDetailPage } from './pages/UseCaseDetailPage'
45+
46+
// SSRRoutes — the SSG-only route tree. Mirrors the public surface of the
47+
// client AppRoutes (everything reachable without auth). The /app/* subtree
48+
// is intentionally omitted: scripts/prerender.mjs never pre-renders auth-
49+
// gated routes (they'd crash on localStorage anyway).
50+
function SSRRoutes() {
51+
return (
52+
<Routes>
53+
<Route path="/" element={<MarketingPage />} />
54+
<Route path="/pricing" element={<PricingPage />} />
55+
<Route path="/for-agents" element={<ForAgentsPage />} />
56+
<Route path="/status" element={<StatusPage />} />
57+
<Route path="/blog" element={<BlogPage />} />
58+
<Route path="/blog/:slug" element={<BlogPostPage />} />
59+
<Route path="/docs" element={<DocsPage />} />
60+
<Route path="/use-cases" element={<UseCasesPage />} />
61+
<Route path="/use-cases/:slug" element={<UseCaseDetailPage />} />
62+
<Route path="*" element={<Navigate to="/" replace />} />
63+
</Routes>
64+
)
65+
}
1666

1767
export function render(url: string): string {
1868
return renderToString(
1969
<StrictMode>
2070
<StaticRouter location={url}>
21-
<AppRoutes />
71+
<SSRRoutes />
2272
</StaticRouter>
2373
</StrictMode>
2474
)

0 commit comments

Comments
 (0)