JavaScript SEO: Rendering, Hydration, and Googlebot Explained
How Googlebot crawls and renders JavaScript, where client-side rendering and hydration go wrong for search, and how to test what Google actually sees.
JavaScript SEO: Rendering, Hydration, and Googlebot Explained
Google can render JavaScript. That sentence has been true for years, and it is the reason so many teams stop thinking about JavaScript SEO. The problems we find on JavaScript-heavy sites are rarely "Google cannot see anything". They are subtler: links Google cannot follow, content that only appears after a click, error pages that return 200, and metadata that changes between the server HTML and the rendered page.
This guide explains how the pipeline works, which rendering strategy fits which page, and how to verify what Googlebot actually receives.
How Googlebot processes a JavaScript page
Google describes its processing in three phases: crawling, rendering and indexing.
- Crawl. Googlebot requests the URL and receives the initial HTML response. It checks
robots.txtfirst, reads the status code and parses the raw HTML for links and directives. - Render. The page is queued for the Web Rendering Service, which loads it in an up-to-date (evergreen) version of headless Chromium, executes JavaScript and produces the rendered DOM.
- Index. Google indexes the rendered content and extracts any links that only exist after rendering, which then feed back into the crawl queue.
Two consequences follow.
First, the raw HTML still matters. Status codes, robots meta tags and links in the initial response are seen first. If the initial HTML contains noindex, Google may skip rendering entirely, so removing a noindex with JavaScript does not work. The reverse (adding noindex with JavaScript) does work, because Google renders pages that do not start with noindex.
Second, rendering is a separate step. Content and links that only exist after JavaScript runs are discovered later than content in the initial HTML. For a small site that changes rarely, the delay is usually irrelevant. For a large catalog or a news site where freshness matters, it can be the difference between indexing today and indexing next week.
Rendering strategies compared
The core question for any route is: how much meaningful HTML exists before client-side JavaScript runs?
| Strategy | Initial HTML contains | Good fit | Main SEO risk |
|---|---|---|---|
| Client-side rendering (CSR) | An empty HTML frame and script tags | Logged-in dashboards, tools | Content and links depend on rendering succeeding |
| Server-side rendering (SSR) | Full content per request | Product, category, listing pages | Slow server responses hurt TTFB and LCP |
| Static generation (SSG) | Full content built ahead of time | Blogs, docs, marketing pages | Stale content if rebuilds are infrequent |
| Incremental regeneration | Full content, rebuilt on a schedule or on demand | Large catalogs with changing data | Cache invalidation bugs serve outdated prices or stock |
| Dynamic rendering | Full HTML for bots, CSR for users | Legacy workaround only | Maintenance burden, risk of serving different content |
Google now describes dynamic rendering as a workaround rather than a recommended long-term solution. If you are building something new, server rendering or static generation is the safer default for any page you want in search.
Pure client-side rendering is not automatically a failure. Google renders it. But it pushes every risk onto the rendering step: a script error, a blocked API, a slow third-party dependency or a timeout means Google sees the empty frame. It also helps less with other crawlers. Many non-Google crawlers, including several AI crawlers, fetch the raw HTML and do not execute JavaScript at all.
Progressive server rendering
Modern frameworks can send server-rendered HTML in chunks: the page layout and above-the-fold content first, slower sections later as their data resolves. This is good for users and for search, as long as the important content is in the HTML response and not deferred to a client-only component. Check that the chunks containing your main content arrive in the same response and are not replaced by a loading placeholder in the final HTML.
Hydration and why it matters for SEO
Hydration is the step where client-side JavaScript takes over server-rendered HTML: it attaches event handlers and rebuilds the component state so the page becomes interactive.
From Google's point of view, a correctly hydrated page is fine. The content is in the server HTML, and the rendered DOM matches it. The problems come from three patterns.
Hydration mismatches that change content
If the client renders something different from the server (a different title, a different product list, a different price because of locale or time zone), the page the user sees can differ from the server HTML. When that happens, Google may index either version depending on what the rendered DOM ends up containing. Keep metadata, headings and primary content identical between server and client renders.
Content hidden until hydration
Some components render server-side with opacity: 0 or display: none, then reveal themselves after hydration with an animation. Google may still see the text, but users wait for the whole bundle before seeing the hero. That shows up directly in your LCP. The same issue is covered in our guide to improving LCP below 2.5 seconds.
Hydration cost and INP
Hydrating a large component tree is main-thread work. While it runs, taps and clicks wait, which hurts Interaction to Next Paint. Approaches that reduce hydration cost include rendering static parts on the server only (server components or "islands" of interactivity), deferring hydration of below-the-fold widgets, and splitting large bundles by route. Our Core Web Vitals developer guide covers how to find long tasks in a trace.
Links: the most common JavaScript SEO failure
Google discovers URLs by following <a> elements with an href attribute. It does not execute click handlers to find out where a button would navigate.
<!-- Google can follow these -->
<a href="/products/blue-widget">Blue widget</a>
<a href="https://www.example.com/category/widgets">Widgets</a>
<!-- Google cannot reliably follow these -->
<span onclick="router.push('/products/blue-widget')">Blue widget</span>
<a onclick="goTo('widgets')">Widgets</a>
<a href="javascript:void(0)" data-url="/products/blue-widget">Blue widget</a>
<button onclick="location.href='/category/widgets'">Widgets</button>
Client-side routers are compatible with this. Framework link components (Link in Next.js, RouterLink in Vue, and so on) render a real <a href> and intercept the click for client-side navigation. Problems appear when developers bypass them with a custom onClick on a div.
Also avoid fragment-based routing. Google generally ignores everything after #, so /#/products/blue-widget and /#/about are treated as the same URL as the homepage. Use the History API so each view has a real path.
Infinite scroll and "load more"
Google does not scroll like a user, and it does not click "Load more". If products 25 onward only load on scroll or click, give them crawlable paginated URLs as well:
<nav aria-label="Pagination">
<a href="/category/widgets?page=2">Next page</a>
</nav>
Each paginated URL should return its own set of items in the initial HTML and have a self-referencing canonical.
Status codes and soft 404s in single-page apps
In a single-page app, every route is often served by the same HTML file with a 200 status. When a product does not exist, the client renders "Not found", but Google sees a successful response. That is a soft 404, and Search Console reports it under Page indexing.
Fixes, in order of preference:
- Return a real 404 from the server. With server rendering this is straightforward: look up the item before rendering and set the status.
- Redirect to a URL that returns 404, such as
/not-found, which the server answers with a404status. - Add
noindexwith JavaScript when the item is missing:
async function renderProduct(slug) {
const product = await fetchProduct(slug);
if (!product) {
const meta = document.createElement("meta");
meta.name = "robots";
meta.content = "noindex";
document.head.appendChild(meta);
showNotFound();
return;
}
showProduct(product);
}
This works because the page did not start with noindex, so Google renders it and sees the tag added during rendering.
Metadata, canonicals and structured data
Google reads titles, meta descriptions, canonicals and JSON-LD from the rendered DOM. That means injecting them with JavaScript works, but it creates two opportunities for error:
- Conflicting values. If the server HTML has one canonical and JavaScript sets another, Google receives mixed signals. Set the canonical once, preferably in the server response, and never change it client-side.
- Missing values in the raw HTML. Social sharing previews and many non-Google crawlers only read the raw HTML. Server-render the title, description, canonical and Open Graph tags.
Structured data generated by JavaScript is supported by Google, but server-rendered JSON-LD is easier to test and more robust:
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "Product",
"name": "Blue widget",
"offers": {
"@type": "Offer",
"price": "24.00",
"priceCurrency": "USD",
"availability": "https://schema.org/InStock"
}
}
</script>
Resources Google must be able to fetch
Rendering requires every script, stylesheet and API call the page depends on. If robots.txt blocks /static/js/ or /api/, Google renders the page without them. For a client-rendered page, a blocked API usually means an empty page.
Check:
robots.txtdoes not disallow JavaScript, CSS or API paths needed for rendering.- APIs do not require cookies or local storage set on a previous visit. Googlebot loads each page fresh, without state from earlier pages.
- APIs respond quickly and do not rate-limit Googlebot into errors.
- No feature detection that blocks content for unknown browsers or for a headless user agent.
How to test what Google sees
1. Compare raw and rendered HTML
Fetch the raw response and look for your primary content and links:
curl -s -A "Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)" \
https://www.example.com/products/blue-widget | grep -c 'href="/products/'
If the count is zero but the rendered page has dozens of product links, those links depend entirely on rendering.
2. Use URL Inspection
In Search Console, inspect the URL and run a live test. Under "View tested page" you get the rendered HTML, a screenshot and a list of page resources with any that failed to load. Search the rendered HTML for a sentence from your main content. If it is missing, Google did not see it.
The Rich Results Test uses the same rendering and is useful for URLs on properties you do not have Search Console access for.
3. Disable JavaScript in your browser
In Chrome DevTools, open the Command Menu, run "Disable JavaScript" and reload. What remains is approximately what non-rendering crawlers get. It is a fast sanity check for navigation and headings.
4. Watch the Page indexing report
Spikes in "Crawled - currently not indexed", "Soft 404" or "Duplicate, Google chose different canonical than user" after a front-end release are often rendering regressions. Pair this with daily monitoring of your key templates so you catch the change within a day instead of a month.
A checklist for JavaScript sites
- Primary content, headings and internal links are in the server HTML for every indexable route.
- All navigation uses
<a href>with real paths, no fragment routing. - Missing items return a
404or getnoindex, never a200with "Not found" text. - Titles, canonicals and robots meta are set server-side and not changed by client code.
robots.txtallows every resource needed to render.- Paginated or "load more" content has crawlable URLs.
- Hydration does not hide content or block interaction for long periods.
For the broader set of checks beyond JavaScript, see the technical SEO audit checklist.
Summary
- Googlebot crawls the raw HTML first, renders later in evergreen Chromium, and never clicks or scrolls.
- Server rendering or static generation is the safest choice for any page you want indexed; dynamic rendering is a legacy workaround.
- Links must be
<a href>elements with real paths. Click handlers and#routes are not crawlable. - Return real status codes. A
200"Not found" page in a single-page app is a soft 404. - Verify with URL Inspection's rendered HTML and a raw
curl, not with what your browser shows.
Get Your SEO Audit
Every issue in this article, checked on your site by an auditor and delivered as a prioritized roadmap within 48-72 hours.
See pricing