Every quarter we audit at least a dozen JavaScript-heavy sites, and the same three failure modes come up: pages that render fine in a browser but ship an empty `<body>` to Googlebot, hydration that pushes the LCP element past 4 seconds on mobile, and metadata that mutates client-side after the crawler has already left. The stack is not the problem – React, Vue and Svelte all rank fine when configured correctly. The problem is that "configured correctly" now means understanding Google's Web Rendering Service, choosing the right rendering strategy per route, and treating hydration as a Core Web Vitals cost rather than a free lunch.
This guide is the field manual we hand engineering teams before we start a technical SEO engagement. It covers what Googlebot actually does in 2026, when to pick SSR over SSG over ISR, the hydration patterns that ship the fastest LCP, and the debugging playbook when a page will not index.
How Googlebot actually renders JavaScript in 2026
Googlebot uses an evergreen Chromium (currently pinned about 2-4 weeks behind stable) through the Web Rendering Service (WRS). The pipeline is unchanged since 2019 in shape but tighter in budget:
- Crawl – Googlebot fetches the HTML. The URL enters the render queue.
- Wait – the URL sits in the queue. In 2020 this could take days. In 2026, median is minutes for sites with a healthy crawl budget; sites with low authority or bad server responses still see 6-24 hour delays.
- Render – the WRS runs the page in headless Chromium, executes JS, waits for network idle plus a soft timeout (roughly 20 seconds max), then snapshots the DOM.
- Index – the rendered HTML is compared against the initial HTML. Content that only appears after render is indexed, but with lower confidence.
Two consequences fall out of this. First, anything you can render server-side is indexed sooner and with higher confidence than anything you defer to the client. Second, the WRS does not scroll, does not click, does not accept cookies, and does not wait for user interaction. If your content requires any of those, it does not exist as far as Google is concerned.
SSR, SSG, ISR, streaming – pick per route, not per site
The biggest strategic mistake we see is teams choosing a single rendering strategy for the whole site. Next.js, Nuxt, SvelteKit and Remix all let you mix strategies per route. Do it.
| Strategy | Best for | SEO strengths | SEO risks |
|---|---|---|---|
| SSG (static generation) | Marketing pages, blog posts, evergreen docs | Fastest TTFB, no server dependency, ideal LCP, deterministic HTML | Rebuild required for content updates; scales badly past ~50K pages |
| SSR (server-side rendering) | Personalised pages, logged-in dashboards, high-frequency updates | Fresh HTML every request, full content in initial payload | TTFB depends on server; edge caching required to compete with SSG |
| ISR (incremental static regen) | Large catalogues (product, location, city pages) | SSG-fast serving with background revalidation; handles 100K+ pages | Stale-while-revalidate can serve outdated content briefly; needs cache-busting on critical changes |
| Streaming SSR (React Server Components / Next 15+) | Data-heavy pages where above-the-fold is fast but below-fold is slow | TTFB and LCP decoupled from slow data; content still in initial HTML | Suspense boundaries not indexed reliably until fallback resolves – keep the LCP element in the first flush |
| CSR (client-side rendering only) | Behind-auth apps, tools, editors | None for SEO – avoid for any indexable URL | Empty initial HTML; content depends on JS execution; hydration cost paid by both users and Googlebot |
The rule of thumb we apply on client audits: if the URL is meant to be indexed, its LCP element must be present in the initial HTML response. Everything else is negotiable. If you cannot say yes to that sentence for a route, that route's rendering strategy is wrong.
Hydration is not free – treat it as a CWV cost
Hydration is the moment React (or Vue, or Svelte) walks the server-rendered DOM and attaches event handlers. On the surface it looks like a wash: the HTML was already there, so nothing visible changes. In practice, hydration blocks the main thread, delays INP, and – for the first 1-3 seconds – turns your fast-looking SSR page into a component that cannot respond to input.
The 2026 winning pattern is partial hydration or islands architecture – hydrate only the components that actually need interactivity, leave the rest as static markup. Astro popularised this; Next.js delivers it through React Server Components; Qwik goes further with resumability. The specific framework matters less than the outcome: less JavaScript shipped, less main-thread work, faster INP.
Bad: hydrate the whole tree
// app/products/[slug]/page.jsx – everything is a client component
'use client'
import { useState } from 'react'
import ProductGallery from './ProductGallery'
import ProductDescription from './ProductDescription'
import ReviewsList from './ReviewsList'
import RelatedProducts from './RelatedProducts'
export default function ProductPage({ product }) {
const [tab, setTab] = useState('description')
// The entire page – gallery, description, reviews, related – is hydrated
// on every visit, even though only the tab switcher is interactive.
return (
<>
<ProductGallery images={product.images} />
<TabSwitcher value={tab} onChange={setTab} />
{tab === 'description' && <ProductDescription html={product.descriptionHtml} />}
{tab === 'reviews' && <ReviewsList reviews={product.reviews} />}
<RelatedProducts items={product.related} />
</>
)
}Good: server component with a narrow client island
// app/products/[slug]/page.jsx – server component by default
import ProductGallery from './ProductGallery' // server
import ProductDescription from './ProductDescription' // server
import ReviewsList from './ReviewsList' // server
import RelatedProducts from './RelatedProducts' // server
import TabSwitcher from './TabSwitcher' // 'use client' – the ONLY island
export default async function ProductPage({ params }) {
const product = await getProduct(params.slug)
return (
<>
<ProductGallery images={product.images} />
<TabSwitcher
tabs={[
{ id: 'description', label: 'Description',
content: <ProductDescription html={product.descriptionHtml} /> },
{ id: 'reviews', label: 'Reviews',
content: <ReviewsList reviews={product.reviews} /> },
]}
/>
<RelatedProducts items={product.related} />
</>
)
}
// Hydration cost: one small TabSwitcher instead of the entire page.
// LCP: unchanged. INP: dramatically faster. JS shipped: 60-80% less.The gains are not theoretical. On a recent e-commerce audit we cut JavaScript shipped from 340 KB to 89 KB by moving four page templates from "use client at the top" to server components with narrow islands. INP dropped from 380ms (poor) to 145ms (good) on mid-range Android. Nothing else changed.
Metadata, canonicals, and structured data – server-side only
Every framework ships a way to set `<title>`, meta description, canonical, and JSON-LD from the server. Use it. Mutating any of these client-side is one of the top three indexation bugs we see.
The failure mode is subtle: your page renders fine, the browser DevTools show the correct title, but the *initial HTML* – which is what Googlebot indexes first, and what many crawlers only see – contains a placeholder. If the WRS is delayed or the render times out, the placeholder is what gets indexed.
- `<title>` and meta description – set in `generateMetadata` (Next), `useHead` (Nuxt), or the equivalent server hook. Never `document.title = ...`.
- Canonical URL – always absolute, always server-rendered. If a route has query parameters that do not change content (`?utm_source=...`, `?ref=...`), the canonical must strip them.
- JSON-LD structured data – emit as a `<script type="application/ld+json">` in the server HTML. Client-injected JSON-LD is inconsistently picked up.
- hreflang – must be in the initial HTML, must reference absolute URLs, must be reciprocal across every locale variant.
Core Web Vitals patterns that survive contact with real users
CWV in 2026 is measured field-side via CrUX and Search Console. Lab scores are directional; field data is what ranks. Three patterns move the needle on real users.
LCP: the hero must be in the initial HTML
The LCP element is almost always an above-the-fold image or a heading. Both must be in the HTML response, not deferred. For images:
- Use `next/image` (or the framework equivalent) with `priority` on the LCP image.
- Preload the LCP image with `<link rel="preload" as="image" imagesrcset="..." fetchpriority="high">` in the server HTML.
- Serve AVIF with WebP fallback. Modern CDNs handle this automatically; if yours does not, change CDN before you tune anything else.
- For hero images that depend on user location or A/B tests, resolve the variant server-side. Client-side variant selection guarantees a slow LCP.
INP: the enemy is the long task
INP replaced FID in early 2024 and is stricter. The 200ms good threshold is easy to hit in a lab and easy to blow in the field. The culprit is almost always a long task – a JavaScript execution over 50ms – triggered by hydration, third-party scripts, or a heavy React state update.
- Move analytics, ad, and support-chat scripts to `strategy="lazyOnload"` (Next) or the equivalent.
- Break large lists into virtualised windows. Any list over 50 rows is a candidate.
- Debounce keystroke handlers on search and filter inputs at 200-300ms.
- Prefer CSS-driven interactions (transitions, `:hover`, `:has()`, view transitions) over JS-driven ones where possible – they do not enter the JS main thread at all.
CLS: reserve the space
CLS is largely a solved problem, but the same three mistakes keep showing up in audits: images without dimensions, web fonts without `size-adjust` or `font-display: optional`, and injected banners (cookie, promo, A/B) that push content on hydration. Fix all three or your CLS field score will hover around 0.15 – which is not "poor" but is not a good signal either.
When a page will not index – a debugging playbook
The single most common ticket we get is "this page ranks fine on our staging site but Search Console says `Discovered – currently not indexed`." Work through this list in order. Do not skip steps because you "already checked" – most of the time the fault is at a step the team assumed was fine.
- Fetch the URL with `curl -A "Googlebot"`. Look at the raw HTML. If the page content, title, or canonical is missing, you have a rendering problem – stop and fix that before anything else.
- Run URL Inspection in Search Console → "Test Live URL". Compare the rendered HTML tab against your curl output. If Google sees fewer elements than curl, your JS is failing in the WRS.
- Check the "More info" → "Page resources" list. Any blocked or failed script that touches critical content is a rendering blocker. Robots.txt disallowing `/api/`, `/_next/`, or a CDN subdomain is the classic culprit.
- Verify the canonical. URL inspection shows Google's chosen canonical. If it points to a different URL than you expect (a parameter variant, an http version, a locale variant), fix the canonical tag and reciprocal hreflang.
- Check `noindex` and `X-Robots-Tag`. Both HTML meta and HTTP header. Framework middleware sometimes injects `noindex` on preview deployments and it survives to production.
- Confirm the URL is in a sitemap and the sitemap is submitted. Not required for indexation, but the difference between "submitted" and "not submitted" is often the difference between 24 hours and 3 weeks.
- Look at internal links to the URL. Orphan pages get discovered slowly and indexed slower. Every indexable URL should have at least three internal links from indexed pages.
- Check server response codes and timing. A URL that returns 200 in 4 seconds gets treated worse than one that returns 200 in 400ms. Crawl budget is spent per second, not per request.
- Look at duplicate content. Same content on multiple URLs (with/without trailing slash, with/without `www`, with UTM parameters) fragments crawl budget. Consolidate with 301s and canonicals.
- Check for soft 404s. Pages that return 200 but say "no results found" or "content not available" are demoted to soft 404. Return real 404s for missing content.
The 2026 defaults we recommend
When a client asks "what should we set up on a greenfield project?" – here is our default stack for a Next.js content site. It is boring on purpose. Boring ranks.
- Next.js App Router, React Server Components by default, `use client` only on genuine interaction islands.
- Marketing pages: SSG. Blog posts: SSG. Product/location pages (>100 of them): ISR with 60-minute revalidation.
- `generateMetadata` on every route – title, description, canonical, OG image. No client-side metadata mutations.
- `next/image` with `priority` on LCP images, AVIF via CDN, dimensions set on every image.
- Analytics loaded with `strategy="lazyOnload"`, chat widgets deferred until user intent (scroll depth or 5s).
- Structured data (Article, Product, BreadcrumbList, Organization) rendered as JSON-LD in the server HTML.
- Sitemap generated at build, submitted to GSC, updated on ISR revalidation.
- Middleware guards `noindex` to preview deployments only, with an assertion in CI that production never emits it.
What to do next
Start with an evidence audit, not a rewrite. Take your five most valuable indexable URLs. For each, run `curl -A "Googlebot" [url] | grep "<title\|canonical\|h1"` and confirm the important content is in the initial HTML. Run URL Inspection in Search Console and confirm the rendered HTML matches. If either step fails, you have a rendering problem worth fixing before anything else. If both pass, move to CWV: pull the Field Data from CrUX and check LCP, INP, and CLS at p75 on mobile. Fix whichever is red. Only after both pass should you touch content strategy. If you want the audit done for you, our technical SEO team runs this exact playbook as a fixed-scope engagement – you get a punch list of rendering, indexation, and CWV fixes, prioritised by traffic impact, in about five business days.
