Why doesn't Google index my React site?
Almost always because the page a crawler receives is not the page you see. A React app that renders in the browser serves a near-empty HTML shell and fills it in with JavaScript, and while Google does run that JavaScript, it does so on a later pass rather than at crawl time. Anything decided on the first pass — whether a page is a duplicate, which URL is canonical, which language version applies — is decided on the shell. So before you change anything, read the raw HTML your server actually returns instead of the page your browser renders.
The page a crawler gets is not the page you see
Open your live site and choose view source rather than inspect. Inspect shows the DOM after your JavaScript has run; view source shows the bytes the server actually sent. On a client-rendered React app those bytes are usually a title, a few link tags, one empty container element and a script bundle. That document is what a crawler reads first, and on a young site it may be the only version anything reads for a while.
Google is open about handling JavaScript in separate stages: it crawls the URL, queues the page for rendering, and indexes what comes back — and the render is deferred rather than immediate. Two consequences follow. The first is the obvious one: content that only exists once the bundle runs gets indexed late, if at all. The second does more damage because it never resolves. A handful of signals are read only from that initial response, and no amount of later rendering rescues them.
The canonical tag is the clearest case. Google's own guidance is that a canonical link element belongs in the head of the document, and the same is true of the hreflang annotations that connect language variants. A tag your framework injects afterwards — into the body, or after the head has already been streamed — is a tag that was not there when the decision got made. The page looks correct in a browser, because a browser applies it anyway. It is simply too late for the one reader whose opinion you were trying to change.
Find out what Google actually received
This takes about twenty minutes and it settles the question before you rewrite anything. Do it against the live URL, not a preview build.
- Read the raw response. Fetch the page with curl, or use view source. Ask two questions of what comes back: is the copy you want to rank for present in this text, and is the canonical tag inside the head element rather than further down the document? If the answer to either is no, you have found the problem.
- In Search Console, read the crawled page and not the live test. URL Inspection shows both. The live test is a fresh fetch performed on demand; the crawled HTML is what was actually stored last time. When the two disagree, believe the crawled copy — that disagreement is itself the finding.
- Request a deep link directly. Client-side routers can produce paths the server does not serve. Open one of your inner pages in a clean tab, or curl it, and confirm it answers with that page's own content rather than redirecting to the homepage or returning a page that says nothing was found while still reporting success.
- Confirm you serve one host, not three. Request the www form, the bare form and the insecure form of your domain. Two of the three should redirect to the third. If more than one serves the whole site, you have published the same site at two addresses and asked Google to choose.
The failure that hid from our own tests
We publish AgentCeres — the AI Growth Officer at agentceres.com — on a React framework, and we lost months of indexing to a version of this problem that every check we ran called healthy. It is worth telling in full, because each part failed in a way that looked like success.
Search Console reported a large block of our pages as duplicates with no user-selected canonical, which means it had found no canonical it trusted and had picked one itself. The tag was there. It was roughly forty kilobytes into the response, because the framework streams metadata for any page it renders dynamically, while the head element had closed about a kilobyte and a half from the top. Every browser applied it. The first crawl pass never saw it.
The reason those pages were dynamic at all was the second failure. A dozen routes we believed were prerendered at build time had quietly stopped being: a shared header component reached a translation helper that reads request headers, and reading a request header is enough to make a page dynamic. The build's own route table still printed them as static. The manifest of generated HTML files contained none of them. The summary and the artefact disagreed, and we had been reading the summary.
The third part is the one to carry away. Frameworks that stream metadata usually keep a list of crawlers that get the old blocking behaviour instead, and ours shipped such a list by default. Googlebot was not on it. Google's own inspection tool — the crawler behind the Test live URL button — was. So the button we pressed to check our work fetched the good version every single time, while ordinary crawls kept getting the broken one. If your verification tool is a special case inside your own stack, it is not verification.
Two footnotes, both cheap. Our www hostname served the entire site without redirecting, so Google had indexed it as a second copy and demoted the address in our sitemap; that was two lines of middleware. And if you are tempted by a per-crawler setting, remember that a CDN in front of your origin usually caches without regard to which client asked, so whichever variant lands in the cache is what the next crawler is handed. Behind a CDN, treating one crawler specially is not a rule you can lean on.
What to change, in the order that pays
- Prerender the pages you want found. Marketing pages, docs, anything a stranger might search for — generate the HTML at build time. Static output sidesteps the entire problem, because the first pass already holds both the content and the tags.
- Server-render whatever genuinely has to be dynamic. A page that depends on the request can still return complete HTML. What matters is that the first response is complete, not that it was computed in advance.
- Keep title, description, canonical and language tags in the head of that first response. If your framework can defer them, find the setting that stops it, then confirm the fix by reading raw HTML rather than the rendered DOM. This is the step people believe they have done.
- Give every page a real URL and a real link to it. Routes that live behind a fragment or a click handler are not crawlable. Crawlers follow anchor elements with an href, so a navigation built from buttons is a navigation Google cannot walk.
- Pick one host and redirect the others. Then submit a sitemap that lists only the chosen form, so your own declarations agree with each other.
None of this is a ranking strategy. It is the precondition for one, and it is worth doing in an afternoon precisely so that you never have to think about it again. Once your pages are genuinely being read, the questions that decide whether they earn anything are why a perfectly good page still gets no traffic and where the first visitors actually come from. And if you would rather not own this pipeline at all, a static site generator with a Git-based editor avoids the rendering question by construction, because what it deploys is already finished HTML.
FAQ
- Does Google index JavaScript sites at all?
- Yes. Googlebot runs JavaScript and indexes what the page becomes, but it does so on a deferred pass rather than at crawl time, so anything that only exists after your bundle runs is indexed later and less dependably than content already in the HTML. The parts read only from that first response — the canonical tag and hreflang annotations among them — are not rescued by rendering at all. Treat client-side rendering as a delay plus a risk, not as a hard blocker.
- Do I need server-side rendering to fix this?
- Usually not. Static prerendering is enough for the pages a stranger would ever search for, and it is simpler and cheaper to run than server rendering. Reserve server rendering for pages whose content genuinely depends on who is asking. The rule that matters is not which technique you pick but whether the first response is already complete — a prerendered page and a server-rendered page both satisfy it, and a client-rendered one does not.
- Search Console's live test looks perfect, so why is my page not indexed?
- Because the live test is a fresh fetch and render performed on demand, and your stack may hand it a different response from the one an ordinary crawl gets — ours did, because the framework's default list of crawlers that receive blocking metadata included Google's inspection tool but not Googlebot. Read the stored crawled HTML in the same URL Inspection report instead. When the two disagree, the crawled copy is the one being indexed, and the live test is telling you about a page nobody else is served.
Want this done for you?
AgentCeres is a managed AI marketing team — specialists draft the work, you approve what ships. 14-day free trial, from $39/month.