perf: lazy-load /app routes to shrink marketing bundle#19
Merged
Conversation
Marketing visitors landing on the homepage shouldn't pay the JS cost of the auth-gated dashboard. Converts the 12 /app/* page components and the AppShell layout to React.lazy, splitting them into per-page chunks that only load after AuthGate passes. Public marketing routes, auth routes, and the prerender route list are unchanged. Bundle: main entry 764 KB → 682 KB, with 14 new chunks (AppShell 9.6 KB, 12 pages 2.5-18 KB each, shared ctx 1.5 KB) loaded on demand. SSR prerender still emits 115 HTML files since /app/* was never in the prerender list. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
6 tasks
mastermanas805
added a commit
that referenced
this pull request
May 11, 2026
…undle (#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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Marketing visitors landing on instanode.dev shouldn't pay the JS cost of the auth-gated dashboard. This converts the 12 /app/* page components plus the AppShell layout to React.lazy, splitting them into per-page chunks that only load after AuthGate passes.
SSG compatibility
scripts/prerender.mjs has never had /app/* in its PRERENDER_ROUTES list (auth-gated, uses localStorage). React.lazy components are never invoked during SSG, so the build still emits exactly 115 HTML files. Verified locally.
Test plan
npm run buildsucceeds with smaller main chunk + new per-page chunksnpm testpasses — 26/26 markdown renderer tests