Technical SEO

JavaScript SEO: how Google indexes SPAs and React sites

JavaScript SEO: indexing SPAs and React sites

Google's two-wave rendering queue, CSR pitfalls, the difference between SSR/SSG/ISR, and practical methods to verify your JS site is indexed correctly.

How Google renders JavaScript: how it works

For a regular HTML page, Google takes one step: download HTML and immediately see the content. For a JavaScript page, there are three steps: download HTML → load the JS bundle → run it in headless Chromium to get the final DOM.

Google uses its own Chromium-based browser engine — kept up to date with regular updates. So modern JavaScript features (ES2020+, dynamic import, WebAssembly) are understood by Googlebot. The problem is not syntax support, but rendering delay and resource constraints during crawling.

Three JavaScript rendering strategies: SSR and SSG/ISR ship ready HTML, while CSR builds the DOM in the browser and lands in the second indexing wave.

Rendering by the numbers

3–4 s

Rendering timeout

If JS doesn't execute within ~3–4 seconds, Google indexes a partially rendered page

≈ 150 ms

CSS time

Resources loading longer than 150 ms may block rendering and hurt crawling efficiency

2 waves

Indexing

JS content reaches the index with a delay compared to HTML content — sometimes days or weeks

100%

Evergreen Google

Googlebot updates to the latest stable Chromium — modern JS features are fully supported

Two-wave indexing: why JS content reaches the index later

Google splits crawling into two stages. The first wave is crawling: Googlebot downloads HTML and all discovered links. The second wave is rendering: the bot queues pages for rendering, runs JS, and indexes the final DOM.

Between these waves, hours to weeks may pass — depending on site authority, crawl budget, and the size of Google's rendering queue. If your key texts, H1 headings, meta tags, or links are generated only via JS — they'll reach the index with a delay.

Practical conclusion: All SEO-critical content — title, description, H1, links, structured data — must be present in the server HTML response. Then it enters the first indexing wave with no delay.
Two-wave indexing is why new SPA pages 'disappear' from the index for 2–4 weeks after publishing, even though the HTML is physically accessible.

CSR — the main SEO trap

Client-Side Rendering (CSR) is when the server sends an empty <div id="root"></div> and all HTML is built in the browser after the JS bundle loads. React, Vue and Angular in SPA mode work this way by default.

From an SEO perspective, CSR is a problem for several reasons: 1) rendering only happens in the second wave; 2) resources blocked by the crawler (third-party scripts, CDNs with robots.txt) don't load — the page renders incompletely; 3) any JS error = blank page in the index.

HTML
<!-- CSR: what Google sees in the first wave -->
<html>
  <head>
    <title></title> <!-- empty -->
  </head>
  <body>
    <div id="root"></div> <!-- no content -->
    <script src="/bundle.js"></script>
  </body>
</html>
Critical mistake: Generating <title>, <meta name="description"> and <h1> only via client-side JS. Google receives empty or default values during initial crawling and indexes the page without them.

SSR, SSG and ISR: which to choose for SEO

The solution to CSR problems is server-side HTML generation. There are three main approaches, each optimal for its own scenario.

Three JavaScript rendering approaches and their SEO impact.

Rendering approach comparison

ApproachHTML on serverSEODynamic data
CSRNoPoorFull
SSRYes (each request)ExcellentHigh
SSGYes (at build)ExcellentNo (rebuild needed)
ISRYes (on timer)ExcellentVia revalidation
Rendering approaches and suitable use cases:
ApproachBest for
CSRAuthenticated SPAs, dashboards
SSRPersonalised pages, catalogues
SSGBlog, landing pages, docs
ISRPrice catalogues, news

SSR (Server-Side Rendering) — HTML is generated on the server for each request. Maximum data freshness, but higher server load. Next.js: generateMetadata and async components in App Router provide SSR by default.

SSG (Static Site Generation) — HTML is generated once at build time and served as a static file. Fastest TTFB, ideal for SEO. Downside: data changes require a rebuild.

ISR (Incremental Static Regeneration) — a hybrid: pages are built statically, but after a revalidation period (revalidate: 3600) the server regenerates them in the background on next request. In Next.js this is the standard approach for most pages.

TYPESCRIPT
// Next.js App Router — SSG + ISR
export const revalidate = 3600; // revalidate every hour

export async function generateMetadata(): Promise<Metadata> {
  return {
    title: 'Product Catalogue',
    description: 'Full range with up-to-date prices',
  };
}

// All critical content is server-rendered:
export default async function CatalogPage() {
  const products = await fetchProducts(); // server fetch
  return (
    <main>
      <h1>Product Catalogue</h1>
      {products.map(p => <ProductCard key={p.id} {...p} />)}
    </main>
  );
}

Dynamic links and navigation

Googlebot understands links only in <a href="/url"> format. Links implemented via onClick, button, data-href or window.location are invisible to the crawler. This means pages reachable only through such navigation may not be indexed.

  • Use <a href> for all navigation links — not button onClick with router.push()
  • Pagination via URL parameters (?page=2) — Googlebot crawls such URLs if they appear in HTML
  • SPA router: Next.js <Link> and React Router <Link> generate proper <a> tags on the server
  • Hash navigation (#anchor) doesn't create separate URLs in the index — don't use for content that should rank independently
  • Dynamic menus: if menu items render from an API after page load — Google may not discover them until rendering
Verification: In Google Search Console → URL Inspection → 'View as Googlebot' shows which links the crawler found on the page. If a dynamic link doesn't appear in the list — Googlebot can't see it.

Lazy load and infinite scroll

Images with loading="lazy" are loaded by Googlebot — the modern crawler supports lazy loading. But there's a condition: the image must be in the DOM with a proper src attribute during SSR. If src is set by JavaScript in the browser — the crawler sees an empty tag.

Infinite scroll is a classic problem. Google may load the first 'screen' of content, but subsequent portions loaded via IntersectionObserver are not guaranteed to be indexed. Solution: duplicate content via pagination with real URLs (/catalog?page=2) or add links to lazy-loaded pages in HTML.

PatternGooglebot sees?Recommendation
<img src="/img.webp" loading="lazy">YesUse it
<img data-src="/img.webp"> (JS sets src)NoAvoid
Infinite scroll without URLFirst screen onlyAdd pagination
Content behind 'Show more' buttonNoShow part in HTML
Tabbed content (hidden tabs)Yes, with delayDuplicate in HTML

Diagnostics: how to check JavaScript rendering

The key diagnostic question is: 'What does Googlebot see on my page?' There are several tools to answer it.

Diagnostic tools

  • Google Search Console → URL Inspection: shows a screenshot of the page after Googlebot rendering, a list of resources and discovered links. The most direct verification method.
  • 'View as Googlebot' in GSC: runs the actual crawler and returns HTML after rendering — compare with the source HTML.
  • curl or wget: get raw HTML without JS. If key meta tags and content are in curl output — they'll be in the first indexing wave.
  • Disable JS in Chrome DevTools (F12 → Ctrl+Shift+P → 'Disable JavaScript'): a quick way to see the page as the crawler would without rendering.
  • Screaming Frog with rendering: setting 'Spider > Configuration > Rendering > Googlebot' lets you crawl the site with JS rendering — seeing links found post-render.
Example Next.js configuration for SSR/SSG:
BASH
# Check what Google sees without JS rendering:
curl -A "Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)" \
  https://example.com/page

# Find all title / meta description / h1 in HTML response:
curl -s https://example.com/page | grep -E '<title>|<meta name="description"|<h1'

JavaScript SEO checklist

Key checks

  • Title and meta description are present in the server HTML response (SSR/SSG), not generated by JS
  • H1, H2 and main text content are accessible without JavaScript
  • Navigation links use <a href>, not onClick / button
  • Structured data (JSON-LD) is in <head> in the HTML response
  • Images have a static src in HTML, loading="lazy" only for non-LCP images
  • Robots.txt doesn't block JS/CSS files needed for rendering
  • No JS errors that break rendering (check Chrome DevTools Console on the page)
  • Core Web Vitals: LCP, INP, CLS meet Google's threshold values
  • Pagination via URL parameters, not only via JS state
  • Canonical URLs (<link rel="[canonical](/glossary/canonical)">) in HTML, not set via JS

FAQ

Yes. Googlebot uses Evergreen Chromium and can execute modern JS. The problem is not support but delay: rendering happens in the second wave, which may come days or weeks after the HTML crawl.
Next.js with SSR/SSG — yes, when configured correctly. Plain React SPA (Create React App, Vite without SSR) is CSR by default, which is bad for SEO. The key is server-side generation of meta, h1, and main content.
Dynamic rendering (showing different HTML to Googlebot vs users) is an outdated approach. Google permits it but doesn't recommend it. The right path is SSR or SSG, which serve the same complete HTML to everyone.
Yes. Code splitting via React.lazy and dynamic import improves performance and doesn't harm SEO, as long as critical content (H1, main text) is already present in the initial HTML. Components loaded on demand will still be fetched by Googlebot during rendering.
Use Google Search Console → URL Inspection. Request rendering and compare the screenshot to what a regular user sees. Also: curl-check for the presence of title, h1, and meta description in the HTML response without JS.