This site started as a single HTML file: one React tree, one bundle, one URL. It also dragged a full WebGL scene (an animated ocean, a ship, a floating Devil Fruit) into memory before you could read a word of it. That was fine back when the site basically was the 3D toy. It stopped being fine the moment I wanted it to also hold tools, blog posts, and project pages that have nothing to do with Three.js.
So this is the log of turning that one page into a prerendered, multi-route site. No rewriting it in Next, no SSR server to babysit, and no letting the ~60 MB WebGL bundle anywhere near a Markdown page.
What I wasn't willing to give up
Before touching anything I wrote down the things I didn't want to trade away:
- Stay on Vite + React. The 3D work is built on
@react-three/fiber. A framework migration would mean rewriting the one part of the site that already worked, which is a strange place to start. - Real URLs with real meta tags.
/lab/mdcompressand/blog/some-posteach need their own<title>, description, and Open Graph tags baked into the HTML — not bolted on by JavaScript after load, where a crawler or a link-preview bot might never see them. - No SSR server. The site is static. It deploys to a CDN and that's the whole backend. I wanted prerendered HTML, not a Node process sitting there answering requests.
- The heavy bundle stays on the homepage. A blog post should never have to download React Three Fiber. That's really the whole point: if every route pays the 3D tax, why bother migrating at all?
Those four bullets ended up making most of the decisions for me.
The stack: react-router v6 + vite-react-ssg
vite-react-ssg does pretty much exactly that: it takes a React Router route table and prerenders every static route to its own HTML file at build time, then hydrates on the client. There's no SSR server at runtime; the prerender happens once, during vite build.
The whole build is two steps:
{
"scripts": {
"build": "tsc -b && vite-react-ssg build"
}
}
One gotcha that's easy to miss: vite-react-ssg is a React Router v6 tool. It's tempting to just grab react-router-dom@7, but the integration expects the v6 data-router API. I pinned react-router-dom@^6.30 and left it alone. This is the kind of thing that quietly eats an afternoon if you find it the hard way.
Entry point is tiny — vite-react-ssg drives both prerender and hydration from the same route table:
// src/main.tsx
import { ViteReactSSG } from "vite-react-ssg"
import { routes } from "./routes"
import "./index.css"
export const createRoot = ViteReactSSG({
routes,
basename: import.meta.env.BASE_URL,
})
Code-splitting is the actual feature
Every route is lazy. Here that's not a performance nicety, it's the whole mechanism that keeps the WebGL bundle off the light pages:
// src/routes.tsx (excerpt)
{ path: "/", lazy: () => import("./pages/HomePage") }, // pulls in R3F
{ path: "/lab/mdcompress", lazy: () => import("./pages/lab/Mdcompress") }, // does not
{ path: "/blog/:slug", lazy: () => import("./pages/blog/Post") }, // does not
Because each page is its own import(), Vite gives each one its own chunk. The Three.js dependency graph is only reachable from HomePage, so it lands in the homepage's chunk and nowhere else. Open /lab/mdcompress and you download the tool, not the ocean.
I treat this as a rule now: /lab/* and /blog/* never import anything that transitively pulls in @react-three/fiber. It's surprisingly easy to break by accident — one shared component quietly imports a 3D helper, and suddenly your Markdown page is shipping a renderer. The only reason the rule actually holds is that the lazy boundary makes the cost show up in the build output, where you can't miss it.
SSR safety: the prerender runs in Node, and Node has no window
This is where a 3D site picks a fight with a static-site generator. The prerender runs your page modules in Node to produce HTML, and Node has no window, no document, no WebGL context. Any module that reaches for those at import or render time takes the build down with it.
vite-react-ssg ships a <ClientOnly> boundary for exactly this case. The 3D surfaces are lazy and client-only, so their heavy modules never even load during prerender:
// src/pages/HomePage.tsx (excerpt)
import { lazy, Suspense } from "react"
import { ClientOnly } from "vite-react-ssg"
const Scene3D = lazy(() => import("../components/Scene3D"))
const RegularScene = lazy(() => import("../components/RegularScene"))
// ...
<ClientOnly>
{() => (
<Suspense fallback={null}>
{isOnePiece ? <Scene3D /> : <RegularScene />}
</Suspense>
)}
</ClientOnly>
The pattern that worked for me: prerender the text of every page, and wrap only the <Canvas> (or anything else that touches browser globals) in ClientOnly. A section with both a headline and a 3D backdrop keeps its headline in the static HTML, which is good for SEO and for the no-JS first paint, while the canvas only hydrates on the client. The handful of spots that needed a one-off check just use a plain typeof window !== "undefined" guard.
Per-route meta, baked into the HTML
A single <Seo> component wraps vite-react-ssg's <Head>, so the tags end up in each prerendered file rather than being set by script:
// usage on a blog post
<Seo
title={`${post.title} — Dhruv Mishra`}
description={post.excerpt}
path={`/blog/${post.slug}`}
type="article"
/>
index.html now holds only truly global head tags; everything route-specific — title, description, canonical, OG, Twitter — is derived per route from a single SITE_URL constant. The canonical URL is overridable, which is what lets the Medium-mirrored posts point search engines back at the original instead of getting flagged as duplicate content.
Dynamic routes: getStaticPaths
Blog posts are one route definition (/blog/:slug) but many output files. vite-react-ssg resolves that the way Next's getStaticPaths does — the page module exports the list of paths to prerender:
// src/pages/blog/Post.tsx
export function getStaticPaths(): string[] {
return posts
.filter((p) => p.status === "published")
.map((p) => `/blog/${p.slug}`)
}
Post bodies are Markdown files loaded at build time with import.meta.glob('../../content/blog/*.md', { query: '?raw', eager: true }), so each post is baked into its own HTML file with no runtime fetch. (This very post is one of those files.)
The war story: Cloudflare's 25 MiB file cap
The migration also moved hosting from GitHub Pages to Cloudflare Pages, and that's where things got interesting. The build was green; the deploy was not. Cloudflare Pages rejects any single file over 25 MiB, and my Devil Fruit model was a 35 MB STL. The 3D scene that motivated the whole site turned out to be the thing blocking it from shipping.
Two fixes, both of which I'd have wanted regardless of the cap:
- Decimate the model. I ran the Devil Fruit mesh through meshoptimizer: 701k triangles down to 105k, 35 MB down to 5 MB, at a geometric error of 0.0008 — invisible at the size it actually renders. No loader change, just a smaller file.
- Delete what was never wired in. I'd been carrying
Gyarados,LuffyFigure,SeaKing, andTurtlecomponents that referenced ~51 MB of models — none of which were even mounted in the scene. They went, along with the dead components that imported them.
Honestly the cap did me a favor. I'd been hauling around 50+ MB of models "for later," and a failing deploy is a very effective way to make "later" turn into "now." The site is leaner for it.
Where it landed
The site is now a handful of independently prerendered routes — homepage, the Lab tools, the blog, the CodeDojo showcase, a 404 — each its own HTML file with its own meta tags, all sharing one design system and one tiny entry chunk. The 3D bundle lives on exactly one route. A pathless layout wraps all of them and fires a single pageview event per navigation, which is, conveniently, how I now know anyone's reading this at all.
If you've got a React SPA that needs real URLs and real meta tags but you'd rather not adopt a server framework, prerendering with vite-react-ssg is a small and very reversible step. The routing was never the hard part. The hard part was deciding what runs in Node versus the browser, and keeping the heaviest dependency fenced off on the one page that actually needs it.