Skip to content
CharliezServices

Search

React SEO: Why Server Side Rendering Decides Visibility

7 minute read

A single page app can look perfect in the browser and still hand Googlebot a bare div. Here is what actually reaches the crawler, how to confirm it in under a minute, and the three fixes worth considering.

A site launches, looks sharp, loads instantly on the developer's laptop, and three months later Search Console shows exactly one indexed page. Nothing is set to noindex. The robots file is fine. The sitemap validates. Organic traffic is still zero.

Before checking anything else, open the homepage and press Ctrl+U to view source. Not Inspect Element. If what comes back is about forty lines of HTML containing a single div and a script tag, the diagnosis is finished and everything else is a distraction.

What the crawler actually receives

A stock Vite React build ships an index.html with a head (title, viewport, a stylesheet link) and a body containing <div id="root"></div> plus one module script. Every URL on the site returns that identical file, because the host has a catch-all rewrite sending all paths to index.html.

The crawler fetching /pricing and the crawler fetching /blog/how-we-price therefore receive the same document, with the same title, the same missing description, and zero words of body copy. The content exists, but it lives inside a JavaScript bundle that must download, parse, execute, call an API, and mount before a single sentence enters the DOM.

This is why the site looks fine to you and blank to a machine. Your browser is showing you the DOM after all of that has happened. The crawler's first look happens before any of it has.

Google renders JavaScript, and that sentence hides three problems

It is true that Googlebot executes JavaScript. It is also the single most misleading true statement in technical SEO, because it gets read as "so client rendering is fine" when what it actually means is "so client rendering might eventually work, partially, on some pages."

Rendering costs orders of magnitude more than fetching a text file. Google fetches your HTML, indexes what it can, and puts the page in a render queue handled by a separate service. Time in that queue is variable and it is not something you control. A large publisher with heavy crawl demand gets through it quickly. A four month old consultancy site does not.

Then there is everything that is not Google. Bing renders less aggressively. Most social preview scrapers, LinkedIn and Slack among them, take the raw HTML and nothing more, which is why an unfurled link to a client-rendered SPA shows a generic title on every page. The crawlers behind AI answer engines are mostly in the same category. You are betting your entire distribution on one crawler's rendering pipeline being generous.

The question is not whether Google can execute your JavaScript. It is whether it will, on your site, before the content stops being worth indexing.

This is the mechanism most people miss. When Google parses your raw HTML, it extracts anchor hrefs and queues them for crawling immediately. That is how a normal site's internal pages get found: cheap, fast, first pass.

An SPA shell has no anchors. Not one. React Router's Link component does render a real <a href>, but only after the bundle mounts, which means those hrefs exist solely in the rendered DOM. They can only be discovered in the second pass, so every internal URL inherits the render queue's delay before it is even queued for crawling, let alone rendered itself. Sites that use onClick handlers with history.pushState instead of anchors never expose a crawlable link at all.

A sitemap.xml partly rescues discovery. It does nothing for the rest. Google can now reach /pricing, receives the same empty shell, and has no internal link context telling it which pages matter. Discovery is only the first loss. The second is anchor text, the words that tell a search engine what a page covers.

Diagnosing it in about a minute

Do not trust the Elements panel. It shows the live DOM after scripts have run, which is exactly the view that hides the problem.

  1. View source (Ctrl+U) on a deep page, not the homepage. Look for your actual body copy as plain text.
  2. Run curl -s https://example.com/some-page | head -60 and read what comes back over the wire.
  3. Count crawlable links with curl -s https://example.com | grep -c "<a ". On a broken SPA this returns 0.
  4. In Chrome DevTools, open the command menu and run Disable JavaScript, then reload. What remains is roughly the crawler's first pass.
  5. In Search Console, use URL Inspection, Test Live URL, then View Tested Page and the HTML tab. That is Google's own rendered output, including any case where a script timed out or a request was blocked.

If steps 1 to 4 show an empty shell and step 5 shows full content, you are not safe. You have confirmed that rendering works when Google chooses to spend the resources, which is a different claim from your pages being indexed.

Fix one: render on the server

The correct fix is to make the server return HTML. In React that means Next.js, React Router in framework mode, or Astro if most of the site is content with a few interactive islands. Vue has Nuxt. This is a migration, not a plugin, and anyone who tells you otherwise has not done one.

Budget for the specific things that break. Anything touching window, document, localStorage or IntersectionObserver at module scope throws during server render. Third-party libraries that assume a browser on import need dynamic imports with SSR disabled. Data fetching moves out of useEffect and into loaders, which is usually an improvement but it is a rewrite of your data layer.

You get real benefits beyond crawling: HTML arrives with content in it, so LCP stops waiting on a bundle. If you are already planning a rebuild, fold it in rather than treating it as a separate SEO project. This is the kind of decision worth settling early in full stack development, when changing the rendering model is a day of work instead of a quarter.

Fix two: prerender at build time

If the content is largely static and a migration is not on the table this quarter, generate the HTML after the build. Static generation gets you most of the crawling benefit for a fraction of the disruption.

The prerender step, concretely

After vite build, run a Node script that serves dist/ on a local static server, launches headless Chrome through Puppeteer or Playwright, and walks a list of routes. For each route: navigate, wait for the app to be genuinely ready, capture await page.content(), then write the result to dist/<route>/index.html.

The waiting condition is where these scripts fail. Do not use networkidle as your signal; one analytics beacon or open socket means it never fires, and a race against a data fetch means you snapshot a loading spinner into your HTML. Have the app set an explicit flag after its first data load completes, something like document.documentElement.dataset.prerenderReady = "true", and wait on that selector with a timeout that fails the build loudly rather than shipping empty pages.

Generate the route list and sitemap.xml from the same source so they cannot drift. And remember the snapshot freezes state at build time, so anything personalised or time-sensitive needs to be excluded or gated behind a client-only render.

One detail that saves hours: if the prerender exists purely to feed crawlers, keep using createRoot, not hydrateRoot. createRoot discards the existing markup and re-renders from scratch, which sidesteps the whole class of hydration mismatch errors. You pay a repaint, and you should check that the re-render does not shift layout, but you avoid a debugging cycle you did not sign up for.

Two traps in every prerendered SPA

Duplicate canonical and meta tags. Your index.html shell has a static title, canonical and og tags. Your app sets its own at runtime through react-helmet or a similar library. The prerendered snapshot ends up containing both, and Google's handling of two conflicting canonical tags is to distrust the signal entirely. Strip the static ones from the shell, or post-process each snapshot to remove duplicates before writing it.

Directory versus clean URL conflicts. The moment you start writing dist/about/index.html, the catch-all SPA rewrite that made everything work becomes a hazard. On nginx the ordering has to be try_files $uri $uri/index.html /index.html; so real files win before the fallback. On Netlify and Vercel, check that the SPA fallback rule is not forced, and pin your trailing slash behaviour, because /about and /about/ both resolving with different canonical tags creates duplicates that undo the work.

Dynamic rendering is the last resort, not the shortcut

Serving prerendered HTML to crawler user agents and the SPA to everyone else works, and it is the option I argue against hardest. Google itself describes it as a workaround. You are maintaining a user-agent list forever, your two outputs will drift, and the day they drift far enough you are cloaking. It buys time during a migration. It is not a destination.

Choose based on how often the content changes

Static generation or a build-time prerender is correct for a marketing site, a services site, or a blog where a deploy per content change is acceptable. Server rendering is correct when content is dynamic, personalised, or numerous enough that rebuilding everything is impractical.

Either way, verify the fix the same way you found the problem: curl the page, count the anchors, read the raw HTML. Do that before spending anything on content or links, because until the HTML contains words, SEO work is being poured into pages that no crawler has ever actually read.

Related service

Want this handled for you?

Technical repair, content matched to real search intent, and reporting that ties rankings to pipeline instead of to a vanity chart.