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

> Source: <https://dev.to/shreyashtripathi/how-i-made-my-react-portfolio-2x-faster-lighthouse-30-to-80-by-prerendering-a-vite-app-3j03>
> Published: 2026-09-26 08:15:28+00:00

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 `<div id="root"></div>`. Here's what I changed, in order of impact.

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`:

``` js
// src/entry-server.tsx
import { renderToString } from "react-dom/server";
import { StaticRouter } from "react-router-dom";
import { AppShell, AppRoutes } from "./App";

export function render(url = "/"): string {
  return renderToString(
    <AppShell>
      <StaticRouter location={url}>
        <AppRoutes />
      </StaticRouter>
    </AppShell>
  );
}
```

Then a small Node script builds it and writes one HTML file per route:

```
// scripts/prerender.mjs (simplified)
const { render } = await import("../dist-ssr/entry-server.js");
const template = await readFile("dist/index.html", "utf8");

for (const page of pages) {
  const html = template.replace(
    '<div id="root"></div>',
    `<div id="root">${render(page.url)}</div>`
  );
  await writeFile(`dist/${page.file}`, html);
}
"build": "vite build && vite build --ssr src/entry-server.tsx --outDir dist-ssr && node scripts/prerender.mjs"
```

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

``` js
// src/main.tsx
const container = document.getElementById("root")!;
if (container.hasChildNodes()) hydrateRoot(container, <App />);
else createRoot(container).render(<App />);
```

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

`""`, so the prerendered `<h1>` was blank. Fix: start from the real text and scramble inside `useEffect`.` renderToString` can't wait for `React.lazy`, so a lazy chart inside `<Suspense>` 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.`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.
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:

``` js
const InteractiveLayer = lazy(() => import("@/components/InteractiveLayer"));

const useIdleMount = () => {
  const [ready, setReady] = useState(false);
  useEffect(() => {
    const ric = window.requestIdleCallback;
    if (ric) {
      const id = ric(() => setReady(true), { timeout: 2500 });
      return () => window.cancelIdleCallback(id);
    }
    const t = setTimeout(() => setReady(true), 1200);
    return () => clearTimeout(t);
  }, []);
  return ready;
};

// in the page
{interactive && (
  <Suspense fallback={null}>
    <InteractiveLayer />
  </Suspense>
)}
```

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.

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

```
const { ref, isVisible: near } = useReveal<HTMLDivElement>({
  threshold: 0,
  rootMargin: "400px 0px",
});

<div ref={ref}>
  {near ? (
    <Suspense fallback={<RadarSkeleton />}>
      <SkillsRadar />
    </Suspense>
  ) : (
    <RadarSkeleton />
  )}
</div>
```

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.

Lighthouse's "non-composited animations" audit found two culprits:

`background-position`, which repaints the whole screen every frame. I moved it to an oversized layer animated with `transform`:

```
'grid-pan': {
  '0%':   { transform: 'translate3d(0,0,0)' },
  '100%': { transform: 'translate3d(40px,40px,0)' },
},
```

`box-shadow`. It's now a pseudo-element that animates `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.

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** | 

*(Lighthouse 12, mobile emulation, lab data.)*

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.

You can see the result at **[shreyashtripathi.in](https://shreyashtripathi.in)** — try the terminal (it loads after the page does). I'm a Frontend Developer and UI/UX Engineer in Noida; if you have questions about prerendering a Vite app, ask in the comments.
