Technical SEO
JavaScript SEO: how Google indexes 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.
Rendering by the numbers
Rendering timeout
If JS doesn't execute within ~3–4 seconds, Google indexes a partially rendered page
CSS time
Resources loading longer than 150 ms may block rendering and hurt crawling efficiency
Indexing
JS content reaches the index with a delay compared to HTML content — sometimes days or weeks
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.
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.
<!-- 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><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.
Rendering approach comparison
| Approach | HTML on server | SEO | Dynamic data |
|---|---|---|---|
| CSR | No | Poor | Full |
| SSR | Yes (each request) | Excellent | High |
| SSG | Yes (at build) | Excellent | No (rebuild needed) |
| ISR | Yes (on timer) | Excellent | Via revalidation |
| Approach | Best for |
|---|---|
| CSR | Authenticated SPAs, dashboards |
| SSR | Personalised pages, catalogues |
| SSG | Blog, landing pages, docs |
| ISR | Price 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.
// 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 — notbutton onClickwithrouter.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
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.
| Pattern | Googlebot sees? | Recommendation |
|---|---|---|
| <img src="/img.webp" loading="lazy"> | Yes | Use it |
| <img data-src="/img.webp"> (JS sets src) | No | Avoid |
| Infinite scroll without URL | First screen only | Add pagination |
| Content behind 'Show more' button | No | Show part in HTML |
| Tabbed content (hidden tabs) | Yes, with delay | Duplicate 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.
curlorwget: get raw HTML without JS. If key meta tags and content are incurloutput — 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.
# 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>, notonClick/button - Structured data (JSON-LD) is in
<head>in the HTML response - Images have a static
srcin 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