cd /news/ai-crawlers/how-i-made-my-react-portfolio-2x-fas… · home › topics › ai-crawlers › article
[ARTICLE · art-140054] src=dev.to ↗ pub= topic=ai-crawlers verified=true sentiment=↑ positive

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

A developer prerendered a Vite + React portfolio site at build time using renderToString and StaticRouter, then hydrated on the client, raising mobile Lighthouse performance from 30 to 80 and increasing crawlable text from 134 to 1,007 words. The rewrite also deferred heavy interactive components like the terminal, mini-games and a 354 KB recharts radar chart until idle or near-viewport, and removed an auto-playing boot screen that was the largest contentful paint element.

by read5 min views1 publishedSep 26, 2026

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:

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

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

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

── more in #ai-crawlers 4 stories · sorted by recency
── more on @react 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain — perfect for shipping the agent you just read about.

$git push zahid main
→ Live at https://your-agent.zahid.host ✓
Get free account → Pricing
from €0/mo · no card required
LIVE [news/how-i-made-my-react-…] indexed:0 read:5min 2026-09-26 · —