DEV Community

Shreyash Tripathi
Shreyash Tripathi

Posted on

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

1. 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:

// 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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode
"build": "vite build && vite build --ssr src/entry-server.tsx --outDir dist-ssr && node scripts/prerender.mjs"
Enter fullscreen mode Exit fullscreen mode

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

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

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 <h1> 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 <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.
  • 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.

2. 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:

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>
)}
Enter fullscreen mode Exit fullscreen mode

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.

3. 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:

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

<div ref={ref}>
  {near ? (
    <Suspense fallback={<RadarSkeleton />}>
      <SkillsRadar />
    </Suspense>
  ) : (
    <RadarSkeleton />
  )}
</div>
Enter fullscreen mode Exit fullscreen mode

4. 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.

5. 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:
'grid-pan': {
  '0%':   { transform: 'translate3d(0,0,0)' },
  '100%': { transform: 'translate3d(40px,40px,0)' },
},
Enter fullscreen mode Exit fullscreen mode
  • 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.

6. 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.

The results

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.

Bonus: once pages are real HTML, SEO gets easy

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 — 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.

Top comments (0)