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>
);
}
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.
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 insideuseEffect. -
React error #419.
renderToStringcan't wait forReact.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.tsxhave to be in the SSR tree too, or hydration fails. I splitAppinto a sharedAppShellandAppRoutesused 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>
)}
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>
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 withtransform:
'grid-pan': {
'0%': { transform: 'translate3d(0,0,0)' },
'100%': { transform: 'translate3d(40px,40px,0)' },
},
- A pulsing ring animated with
box-shadow. It's now a pseudo-element that animatestransformandopacity.
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)