Performance & SEO

Making a React site rank: prerendering, Core Web Vitals and structured data

React does not prevent a site from ranking. Shipping an empty div and hoping the crawler renders it does. The fix is straightforward and largely a build-time concern.

The claim that "React is bad for SEO" is not quite right, but the instinct behind it is sound. React itself is neutral. What causes problems is the default deployment shape of a client-rendered app: the server returns an almost empty HTML document, and everything the user, or the crawler, sees has to be constructed by JavaScript afterwards.

Google does execute JavaScript. But rendering is queued and resourced separately from crawling, it is not instant, and other search engines and the many social and AI crawlers are considerably less capable. If a page depends on search traffic, the sane approach is to make sure the HTML is complete in the first response and treat JavaScript rendering as a fallback rather than the plan.

The problem, concretely

This is what a crawler receives from a standard client-rendered build:

html
<!doctype html>
<html>
  <head><title>My App</title></head>
  <body>
    <div id="root"></div>
    <script src="/assets/index-a1b2c3.js"></script>
  </body>
</html>

There is no content, no heading, and one generic title shared by every route. Everything meaningful. The h1, the copy, the per-page meta description, the internal links, only exists after the bundle downloads, parses and executes. Best case that costs you time in the indexing queue. Worst case, on a slow render or a JavaScript error, the page is indexed effectively empty.

Fix 1: put real HTML in the response

There are three ways to do this, and for a portfolio, marketing or content site the simplest one is usually correct.

Static prerendering (SSG)

At build time, every route is rendered to a complete HTML file. The server returns finished HTML, then React hydrates it into an interactive app. No Node process is needed in production. The output is static files you can host anywhere.

For anything whose content changes on a deploy rather than per request, this is the right answer. It is what the React version of this site uses.

Server-side rendering (SSR)

HTML is generated per request. Necessary when content is genuinely personalised or changes minute to minute. It costs you a running server and a slower time-to-first-byte, so it is not the default choice.

Dynamic rendering

Serving prerendered HTML to crawlers and the SPA to users. Google has described this as a workaround rather than a recommended pattern, and it introduces a permanent risk of the two versions diverging. Avoid it if you can build statically.

Fix 2: per-route metadata that exists before hydration

Every route needs its own title, description and canonical URL, and they need to be in the served HTML, not injected by an effect after mount. If your metadata is set client-side only, prerendering is what makes it real, because the title is baked into each generated file.

The set worth getting right on every page:

html
<title>Page-specific title. Site Name</title>
<meta name="description" content="Written for a human, roughly 150 characters.">
<link rel="canonical" href="https://example.com/the/page/">
<meta property="og:title" content="...">
<meta property="og:description" content="...">
<meta property="og:image" content="https://example.com/og.png">
<meta property="og:url" content="https://example.com/the/page/">
<meta name="twitter:card" content="summary_large_image">

A note on descriptions: they are not a ranking factor, but they are the copy under your listing, and that drives whether anyone clicks. Write them as a sentence to a person, not as a keyword list. Google frequently rewrites descriptions it finds unhelpful, which is a reasonable signal about what to aim for.

Fix 3: Core Web Vitals, as a build constraint

Core Web Vitals are a genuine but modest ranking signal, and a much larger conversion signal. Three metrics matter, and React projects tend to fail them in characteristic ways.

MetricTargetThe usual React cause
LCPunder 2.5sThe hero renders only after the bundle executes; the image is not preloaded and is the wrong format.
CLSunder 0.1Content mounts into a container with no reserved height, or a web font swaps and reflows the text.
INPunder 200msA long task on the main thread, usually hydration or an oversized bundle, blocks the first interaction.

The fixes are mostly unglamorous:

  • Preload the LCP image and serve it as AVIF or WebP at the size it is actually displayed. Never lazy-load it. That is a guaranteed LCP regression.
  • Set explicit width and height on every image so the browser reserves space before it loads.
  • Use font-display: swap with a metric-compatible fallback, so the swap does not move text.
  • Code-split at the route level, and lazy-load anything heavy below the fold, 3D scenes, charts, embeds.
  • Audit third-party scripts. A single marketing tag can cost more main-thread time than your entire application.

And measure the field data, not just the lab score. Lighthouse runs on your machine on your connection; the Chrome UX Report tells you what real visitors experienced. They frequently disagree, and the field data is the one that counts.

Structured data: useful, and easy to get wrong

JSON-LD does not directly improve rankings. What it does is make you eligible for rich results, which changes how your listing looks and how often it gets clicked.

The rule that matters more than any schema detail: structured data must describe content that is visibly on the page. Marking up an FAQ that a visitor cannot see is a guidelines violation, and it is the most common way sites earn a manual action from an otherwise well-intentioned SEO change.

json
{
  "@context": "https://schema.org",
  "@type": "BlogPosting",
  "headline": "Making a React site rank",
  "datePublished": "2026-07-15",
  "dateModified": "2026-07-15",
  "author": { "@type": "Person", "name": "Irfan Bhat" },
  "mainEntityOfPage": "https://example.com/blog/post/"
}

Add BreadcrumbList where you show breadcrumbs, FAQPage only where the questions and answers are visible, and Person or Organization on the pages that identify you. Validate with the Rich Results Test before shipping, invalid markup is simply ignored, which is a silent failure.

Routing details that quietly cost you

  • Navigate with real <a href> elements. A div with an onClick handler is not a link and will not be followed. React Router's Link renders a proper anchor, keep it that way.
  • Pick one URL form and enforce it. Trailing slash or no trailing slash, consistently, with a 301 for the other.
  • Return a genuine 404 status for missing pages. A "not found" screen served with a 200 is a soft 404, and search engines treat it as a quality problem.
  • Generate the sitemap from your route list at build time so it cannot drift out of date.

In short

Prerender to static HTML, give every route its own metadata in that HTML, treat Core Web Vitals as a constraint you build within rather than a score you chase afterwards, and add structured data that matches what is on the screen. None of it is exotic, and together it removes essentially every technical reason a React site would underperform in search.

Worth saying plainly: none of this makes content rank that does not deserve to. Technical work removes obstacles. What ranks is a page that answers the question better than the alternatives. More on that under technical SEO.


Written by Irfan Bhat, web developer & server engineer, currently technical support & development at ElySpace. More about me, or get in touch if this raised a question.

Read next

Running into this yourself?

If any of the above matches what you are dealing with, describe the situation and I will tell you what I think is going on.

Get in touch