Back to News & Insights
Web Development September 26, 2026 · 4 min read

How I made my React portfolio 2x faster (Lighthouse 30 to 80) by prerendering a Vite app

My Vite + React portfolio shipped an empty <div id=root>. Here's how prerendering, idle-loading and a few CSS fixes took mobile LCP from 8.1 s to 3.4 s.

How I made my React portfolio 2x faster (Lighthouse 30 to 80) by prerendering a Vite app

My portfolio is a Vite + React single-page app with a cyberpunk terminal theme: matrix rain, a live terminal, mini-games, easter eggs. It looked great on my laptop. Then I ran Lighthouse on mobile:

| Metric (mobile) | Before | |---|---| | Performance | 30 | | Largest Contentful Paint | 8.1 s | | Total Blocking Time | 3,390 ms | | Words a crawler sees without JavaScript | 134 |

Two problems in one: the site was slow, and search engines and AI crawlers that don't run JavaScript saw an empty . Here's what I changed, in order of impact. Prerender the page at build time

A Vite SPA sends an empty shell, then builds the page in the browser. Instead, I render the React tree to HTML during the build and inject it into index.html, so the first byte already contains the real content.

First, an SSR entry that renders the same tree as the app, with StaticRouter instead of BrowserRouter:

On the client, hydrate instead of rendering from scratch, so React reuses the HTML that's already painted:

Result: crawlers now get 1,007 words instead of 134, and the text paints before any JavaScript runs.

Hydration gotchas I hit Animated text started empty. My "decrypt" name effect initialised its state to "", so the prerendered was blank. Fix: start from the real text and scramble inside useEffect. React error #419. renderToString can't wait for React.lazy, so a lazy chart inside made React bail out to client rendering. Fix: only render the lazy component after mount (see step 3), so server and client both render the fallback first. Server and client trees must match exactly. Toasters and providers from App.tsx have to be in the SSR tree too, or hydration fails. I split App into a shared AppShell and AppRoutes used by both. Load the fun stuff after the page is visible

The terminal, games, confetti and cursor effects were all in the main bundle and mounted immediately. Nobody needs a Snake game in the first second, so they now load when the browser is idle:

I also removed an auto-playing boot screen that covered the page for ~3 seconds on every first visit. It was fun. It was also my LCP. Load heavy libraries only near the viewport

The skills radar chart pulls in recharts (~354 KB). It now loads only when its section is within 400px of the screen: Fix the image nobody noticed

My profile photo was a 1.5 MB PNG displayed at 288px. Converted to a 600×600 WebP it's 12 KB, and explicit width/height stop layout shift. The Open Graph image went from 275 KB to 89 KB. Make the animations run on the GPU

Lighthouse's "non-composited animations" audit found two culprits: A background grid animated with background-position, which repaints the whole screen every frame. I moved it to an oversized layer animated with transform: A pulsing ring animated with box-shadow. It's now a pseudo-element that animates transform and opacity.

I also replaced three huge blur(130px) glow blobs with radial-gradient backgrounds, which look nearly identical but cost almost nothing to paint on phones. Stop fonts from blocking the first paint

Three Google Font families were a render-blocking stylesheet. Now they're preloaded, applied from a small async script, and display=swap shows fallback text immediately.

| Metric (mobile) | Before | After | |---|---|---| | Performance | 30 | 80 | | Largest Contentful Paint | 8.1 s | 3.4 s | | Total Blocking Time | 3,390 ms | 240 ms | | Cumulative Layout Shift | 0.014 | 0 | | SEO / Accessibility / Best Practices | 100 / 100 / 100 | 100 / 100 / 100 | | Words visible without JavaScript | 134 | 1,007 |

And the interactive terminal, games and easter eggs all still work — they just wait their turn.

Because every route is prerendered, I could give each project its own case-study page with its own title, description, canonical URL and JSON-LD structured data, all generated at build time from the same data file the UI uses. Add a sitemap and an llms.txt, and search engines and AI assistants can actually read the site.

Want to discuss this further?

Book a free strategy call with our team to see how these insights apply to your specific business goals.

Book a consultation