In my last post I said my biggest regret was not starting with SSR or prerendering. This is the follow-up: what I did about it.
Here's the problem. A React single-page app sends every visitor the same index.html: a <div id="root"></div> and a script tag. The content only appears after JavaScript runs.
Google can run JavaScript, but it does so later and less reliably. LinkedIn, WhatsApp, Slack and most other link-preview bots don't run it at all. They read the raw HTML and stop.
For ResumeOrbitz, which is a free resume builder that lives on organic search, that meant every page looked identical to a crawler. Same title, same description, almost no text.
What prerendering actually does
Prerendering means running your app once at build time, for every route, and saving what the browser sees as a real HTML file. No server, no framework migration. You keep your SPA and get static HTML on top.
The pipeline is short:
-
vite buildproduces the normal SPA indist/. - The script copies
dist/index.htmltoapp-shell.html: a pure client-side shell for routes that shouldn't be prerendered (dashboard, editor). - It starts Vite's preview server on
dist/and launches headless Chromium with Playwright. - For each route in
sitemap.xml, it loads the page, waits for React and react-helmet to finish, and grabs the full HTML. - It writes each page as a flat file:
/templatesbecomesdist/templates.html. - Netlify serves those files directly. Protected routes fall back to
app-shell.html.
The result on ResumeOrbitz: all 105 sitemap routes now arrive with their real content, title and meta tags in the first response.
My setup: one Playwright script, run after the build
Everything lives in scripts/prerender.mjs. npm's pre/post hooks wire it in, so a plain npm run build does all three steps:
"prebuild": "tsx scripts/generate-sitemap.ts", "build": "vite build", "postbuild": "node scripts/prerender.mjs" The sitemap is generated first, and the prerender reads its routes straight from it, so the two can never drift apart:
function getRoutesFromSitemap() { const xml = readFileSync(join(root, 'public/sitemap.xml'), 'utf8'); const matches = [...xml.matchAll(/<loc>https:\/\/resumeorbitz\.com([^<]*)<\/loc>/g)]; return matches.map((m) => m[1] || '/'); } The core loop, trimmed:
for (const route of routes) { await page.goto(`${base}${route}`, { waitUntil: 'networkidle' }); await page.waitForSelector('#root > *', { timeout: 10_000 }); await page.waitForTimeout(800); // let react-helmet commit its head tags // (the OG-tag fix goes here, see below) const html = '<!doctype html>\n' + (await page.evaluate(() => document.documentElement.outerHTML)); if (route === '/') homepageHtml = html; // write the homepage last else writeFileSync(outputPathFor(route), html); } writeFileSync(join(distDir, 'index.html'), homepageHtml); Four lessons hide in those few lines:
- A fixed wait beat a smart one. I first polled until the DOM stopped changing. It sometimes "settled" in the pause before Helmet's effect even started, and saved pages without their tags. A plain 800 ms wait has been reliable.
-
page.content()drops tags. It silently left out some of Helmet's injected<script>tags, including JSON-LD schema. Readingdocument.documentElement.outerHTMLgets everything. - Write the homepage last. The preview server falls back to
index.htmlfor any route without a file yet. Overwrite it early, and later pages without their own SEO head inherit the homepage's canonical URL and FAQ schema. - Flat files, not folders.
dist/templates/index.htmlmakes Netlify 301-redirect/templatesto/templates/, which then disagrees with the canonical tag.dist/templates.htmlserves at the exact sitemap URL.
The gotcha: two sets of OG tags, and the wrong one wins
Prerendering worked. View-source showed real content. Then I looked closer at the <head> and found every page carried two og:title tags: the homepage's, and its own.
The cause was in index.html. Like most SPAs, mine had a static block of social tags so that something showed up when people shared links:
<!-- Open Graph / Social sharing --> <meta property="og:type" content="website" /> <meta property="og:title" content="ResumeOrbitz - Build a Resume That Gets You Hired" /> <meta property="og:description" content="Create a professional, ATS-friendly resume..." /> React-helmet doesn't replace those tags. It adds its own per-page tags further down the <head>. So every prerendered page ended up with two og:title tags: the generic one first, the correct one second.
Browsers don't care. Link-preview bots do, and many of them simply take the first tag they find. Left alone, every page on my site would have told LinkedIn it was the homepage.
The fix: strip the static OG and Twitter tags during prerendering, once Helmet's per-page tags are in place. The template keeps its defaults for the non-prerendered fallback, but every prerendered page has exactly one set of tags: its own.
await page.evaluate(() => { document .querySelectorAll( 'meta[name="description"]:not([data-rh]), ' + 'meta[property^="og:"]:not([data-rh]), ' + 'meta[name^="twitter:"]:not([data-rh])' ) .forEach((el) => el.remove()); }); Helmet marks every tag it adds with data-rh, so anything without it is a static leftover. app-shell.html keeps its defaults because it's copied before this runs.
If you view-source a ResumeOrbitz page today, you can still see where the bug was. The <!-- Open Graph --> and <!-- Twitter Card --> comments are there, with nothing underneath, and the real tags sit lower down, marked data-rh="true".
Check your own site in 2 minutes
You don't need any tools beyond a terminal:
# 1. Does the raw HTML have real content, or an empty root? curl -s https://yoursite.com/some-page | grep -o '<h1[^>]*>[^<]*' # 2. How many og:title tags does the page ship? It should be exactly 1. curl -s https://yoursite.com/some-page | grep -c 'property="og:title"' Then paste a URL into LinkedIn's Post Inspector. It shows exactly what LinkedIn's bot reads, and refreshes its cached preview while it's at it.
If check 2 returns 2, you have the same bug I had.
What I'd tell past me
If you're starting a content-heavy React app today, pick a framework with SSR or static generation from day one. If you're already deep into a Vite SPA like I was, a prerender script is a good middle path: one file, no rewrite.
Just check your <head> after you do it. The page can look perfect in the browser while every share bot reads the wrong tags.
Disclosure: I'm the founder of ResumeOrbitz, the site used as the example here.
Over to you: did you go SSR or prerender for your React app? And did the OG tags bite you too?