To crawlers, every page on my site was 46 characters: three stages of SPA prerendering, no framework migration
This site is a Vite + React SPA. Articles live in TypeScript data files, routing is react-router, everything renders in the browser. The developer experience is great — right up until I did something I should have done much earlier:
curl my own article page, strip the scripts and styles, and count the visible text.
| Page | Visible text |
|---|---|
| Home | 27 characters |
| Article index | 16 characters |
| A 3,000-character article | 46 characters |
Those 46 characters were the <title> tag. Body, headings, dates — none of it existed. It all lived on the far side of JavaScript execution.
Google does execute JS, but a zero-authority domain gets a thin render budget and a long queue; Bing, social preview bots, and LLM crawlers mostly do not run JS at all. In other words: every article I wrote was close to nonexistent for search.
Why not just migrate to Next.js
That is the standard answer. I considered it seriously and dropped it, on proportionality:
This site has six games, two Three.js experiments, and a WebSocket multiplayer game — all purely client-side, gaining nothing from SSR, and every one of them would need its window access, dynamic imports, and effects audited for a migration. Rewriting the whole site so crawlers can read articles is risk and effort completely out of scale with the goal.
What I actually needed was narrow: when a crawler asks, the response should contain the text. So solve exactly that.
Stage one: meta injection (already in place)
The starting point was a post-build script: after Vite builds, read dist/index.html as a template and emit one HTML file per route — swapping title, description, canonical, and og tags, writing to the matching path.
That makes share previews and search titles correct — table stakes. But the body was still an empty <div id="root"></div>, hence 46 characters.
Stage two: put the body in #root, then let React throw it away
Stage two has the best return-on-effort of the whole story, and it rests on one React behavior:
createRoot(container).render(app) clears whatever is already inside the container when it mounts.
Normally that is a footnote. Used deliberately, it becomes a feature: you can put anything in #root for crawlers, and React replaces it wholesale on boot. The crawler version and the app version never touch — no hydration, therefore no hydration mismatch.
// In the post-build script: render the article body to HTML, inject into #root
if (bodyHtml) {
html = html.replace('<div id="root"></div>', `<div id="root">${bodyHtml}</div>`);
}
// In the browser: createRoot clears the container on mount;
// the crawler HTML never participates in the appWhere does the body come from? Articles are already TypeScript objects — arrays of paragraph, heading, code, and table blocks. The build script bundles that data module with esbuild so it can be imported, and a hundred-line function turns blocks into semantic HTML — h1, h2, p, pre, table — reusing the article page's classes so the pre-React paint matches.
The key property: there is one copy of the data. The app and the crawler version import the same TS module, so "forgot to sync them" is not a possible state.
| Pages | Before | After |
|---|---|---|
| 34 article pages (17 zh + 17 en) | 46–70 chars each | 1,204–9,980 chars |
| Median | — | ~3,800 chars |
One evening of work; 34 pages went from blank to fully readable.
Stage three: real SSR — but only for the pages that need it
Articles were solved, but composed pages — home, the article index, the games list — were still empty. Their content is not article data; it is the React component tree itself.
This is where renderToString finally enters. Vite supports it as a first-class build: produce a second SSR bundle whose entry does one thing — given a route, return an HTML string:
// entry-server.tsx
import { renderToString } from 'react-dom/server';
import { StaticRouter } from 'react-router';
import { AppRoutes } from './App';
export const render = async (url: string) => {
await i18n.changeLanguage(getLocaleFromPath(url));
return renderToString(
<StaticRouter location={url}>
<AppRoutes />
</StaticRouter>,
);
};The post-build script imports that bundle, calls it once per core route, and injects the result into #root — the same slot as stage two, except this time the content is genuine React output, so the browser can hydrate instead of discarding.
How do the two modes coexist? Pages carrying real React output get a marker attribute, and the entry picks a path accordingly:
// main.tsx: one bundle, two mount modes
if (container.dataset.reactPrerendered === 'true') {
hydrateRoot(container, app) // true SSR pages: adopt the existing DOM
} else {
createRoot(container).render(app) // everything else: clear and repaint
}Three kinds of pages, each getting what fits: articles use the cheap crawler-only body (React repaints anyway), core pages get real SSR plus hydration, and the games and 3D experiments stay purely client-side — they were never meant to be searchable.
Three traps I stepped in
One — any code that touches browser APIs at module load blows up the SSR build. My Firebase setup called getAnalytics() at the top level; Node has no window, and renderToString died before it began. The fix is the honest guard:
const analytics: Analytics | null =
typeof window === "undefined" ? null : getAnalytics(app);Two — internal links in crawler HTML need locale prefixes handled by hand. The app's <Link> component turns /article/x into /en/article/x automatically, but the crawler version is string concatenation. Forget that, and every internal link on English pages points at the Chinese version — crawlers follow them, and your hreflang structure dissolves.
Three — escaping is not optional. Article content includes code blocks full of <, >, and quotes. Every string gets escaped before injection, no exceptions; one miss is a broken layout or an XSS surface. The discipline for a hand-written renderer is: escape by default, always.
If your SPA has this problem
Measure before touching anything. curl your own page, strip the scripts, count the visible text — that number tells you whether you have a problem and how bad it is.
Then pick a stage by need, not all at once:
One — only share previews are wrong → stage one; a meta-injection script is enough.
Two — content pages (articles, docs) need indexing and your data is already structured → stage two. createRoot clearing its container makes the crawler body genuinely zero-risk; this is the sweetest step of the whole path.
Three — composed pages (home, lists) too → stage three: Vite SSR build plus hydrateRoot, with one data attribute letting both modes coexist.
Each stage ships independently. Ship one, measure, then decide about the next. At the end of the road the site is still the same Vite SPA — it just finally has something to say when a crawler asks.
Verify the result directly: right-click this page and view source